mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 09:42:22 +00:00
Compare commits
10 Commits
vue-nodes-
...
bl-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5726e5948 | ||
|
|
bd0df83a7c | ||
|
|
c5edbe588c | ||
|
|
6def711414 | ||
|
|
472f90799d | ||
|
|
4c177121a6 | ||
|
|
63181a1ddd | ||
|
|
e17ca7ce71 | ||
|
|
77d2cae301 | ||
|
|
164a4c4c25 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.23.1",
|
||||
"version": "1.23.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.23.1",
|
||||
"version": "1.23.2",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.23.1",
|
||||
"version": "1.23.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -33,6 +32,12 @@ vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/nodePack/usePackUpdateStatus', () => ({
|
||||
usePackUpdateStatus: vi.fn(() => ({
|
||||
isUpdateAvailable: false
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockToggle = vi.fn()
|
||||
const mockHide = vi.fn()
|
||||
const PopoverStub = {
|
||||
@@ -78,9 +83,9 @@ describe('PackVersionBadge', () => {
|
||||
it('renders with installed version from store', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const button = wrapper.findComponent(Button)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.props('label')).toBe('1.5.0') // From mockInstalledPacks
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe('1.5.0') // From mockInstalledPacks
|
||||
})
|
||||
|
||||
it('falls back to latest_version when not installed', () => {
|
||||
@@ -97,9 +102,9 @@ describe('PackVersionBadge', () => {
|
||||
props: { nodePack: uninstalledPack }
|
||||
})
|
||||
|
||||
const button = wrapper.findComponent(Button)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.props('label')).toBe('3.0.0') // From latest_version
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe('3.0.0') // From latest_version
|
||||
})
|
||||
|
||||
it('falls back to NIGHTLY when no latest_version and not installed', () => {
|
||||
@@ -113,9 +118,9 @@ describe('PackVersionBadge', () => {
|
||||
props: { nodePack: noVersionPack }
|
||||
})
|
||||
|
||||
const button = wrapper.findComponent(Button)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
|
||||
})
|
||||
|
||||
it('falls back to NIGHTLY when nodePack.id is missing', () => {
|
||||
@@ -127,16 +132,16 @@ describe('PackVersionBadge', () => {
|
||||
props: { nodePack: invalidPack }
|
||||
})
|
||||
|
||||
const button = wrapper.findComponent(Button)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
|
||||
})
|
||||
|
||||
it('toggles the popover when button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Click the button
|
||||
await wrapper.findComponent(Button).trigger('click')
|
||||
// Click the badge
|
||||
await wrapper.find('[role="button"]').trigger('click')
|
||||
|
||||
// Verify that the toggle method was called
|
||||
expect(mockToggle).toHaveBeenCalled()
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
:label="installedVersion"
|
||||
severity="secondary"
|
||||
icon="pi pi-chevron-right"
|
||||
icon-pos="right"
|
||||
class="rounded-xl text-xs tracking-tighter p-0"
|
||||
:pt="{
|
||||
label: { class: 'pl-2 pr-0 py-0.5' },
|
||||
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
|
||||
}"
|
||||
<div>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
|
||||
aria-haspopup="true"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="toggleVersionSelector"
|
||||
/>
|
||||
@keydown.enter="toggleVersionSelector"
|
||||
@keydown.space="toggleVersionSelector"
|
||||
>
|
||||
<i
|
||||
v-if="isUpdateAvailable"
|
||||
class="pi pi-arrow-circle-up text-blue-600"
|
||||
style="font-size: 8px"
|
||||
/>
|
||||
<span>{{ installedVersion }}</span>
|
||||
<i class="pi pi-chevron-right" style="font-size: 8px" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
@@ -31,11 +36,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
@@ -43,11 +48,17 @@ import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const TRUNCATED_HASH_LENGTH = 7
|
||||
|
||||
const { nodePack, isSelected } = defineProps<{
|
||||
const {
|
||||
nodePack,
|
||||
isSelected,
|
||||
fill = true
|
||||
} = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
isSelected: boolean
|
||||
fill?: boolean
|
||||
}>()
|
||||
|
||||
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
|
||||
const popoverRef = ref()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="!m-0 p-0 rounded-lg"
|
||||
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
@@ -12,7 +12,7 @@
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2.5 px-3 whitespace-nowrap">
|
||||
<span class="py-2 px-3 whitespace-nowrap">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :style="{ width: cssWidth, height: cssHeight }" class="overflow-hidden">
|
||||
<div class="w-full aspect-[7/3] overflow-hidden">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="w-full h-full">
|
||||
<img
|
||||
@@ -41,24 +41,12 @@ import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const {
|
||||
nodePack,
|
||||
width = '100%',
|
||||
height = '12rem'
|
||||
} = defineProps<{
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
|
||||
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
const cssWidth = computed(() => convertToCssValue(width))
|
||||
const cssHeight = computed(() => convertToCssValue(height))
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
|
||||
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
|
||||
footer: { class: 'p-0 m-0' }
|
||||
footer: {
|
||||
class: 'p-0 m-0 flex flex-col gap-0',
|
||||
style: {
|
||||
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
|
||||
}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
@@ -29,75 +34,50 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="self-stretch inline-flex flex-col justify-start items-start"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
|
||||
>
|
||||
<div
|
||||
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
|
||||
<div class="pt-4 px-4 pb-3 w-full h-full">
|
||||
<div class="flex flex-col gap-y-1 w-full h-full">
|
||||
<span
|
||||
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
<span
|
||||
class="text-base font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
:fill="false"
|
||||
/>
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-1"
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
<div
|
||||
v-if="nodesCount"
|
||||
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="text-center justify-center font-medium leading-3"
|
||||
>
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 flex justify-center items-center gap-1">
|
||||
<div
|
||||
v-if="isUpdateAvailable"
|
||||
class="w-4 h-4 relative overflow-hidden"
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
</div>
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ContentDivider :width="0.1" />
|
||||
<PackCardFooter :node-pack="nodePack" />
|
||||
</template>
|
||||
</Card>
|
||||
@@ -110,12 +90,11 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
|
||||
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
|
||||
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import {
|
||||
IsInstallingKey,
|
||||
type MergedNodePack,
|
||||
@@ -130,11 +109,15 @@ const { nodePack, isSelected = false } = defineProps<{
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
|
||||
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
|
||||
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
|
||||
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
const isDisabled = computed(
|
||||
@@ -167,14 +150,14 @@ const formattedLatestVersionDate = computed(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-card::before {
|
||||
.selected-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 3px solid var(--p-primary-color);
|
||||
border: 4px solid var(--p-primary-color);
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
|
||||
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
|
||||
>
|
||||
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
|
||||
@@ -297,6 +297,7 @@ onMounted(async () => {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
CORE_SETTINGS.forEach((setting) => {
|
||||
settingStore.addSetting(setting)
|
||||
})
|
||||
|
||||
@@ -430,14 +430,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: 'Top',
|
||||
name: 'Use new menu',
|
||||
type: 'combo',
|
||||
options: ['Disabled', 'Top', 'Bottom'],
|
||||
migrateDeprecatedValue: (value: string) => {
|
||||
// Floating is now supported by dragging the docked actionbar off.
|
||||
if (value === 'Floating') {
|
||||
return 'Top'
|
||||
}
|
||||
return value
|
||||
}
|
||||
options: ['Disabled', 'Top', 'Bottom']
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Workflow.WorkflowTabsPosition',
|
||||
@@ -470,15 +463,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'hidden',
|
||||
defaultValue: [] as Keybinding[],
|
||||
versionAdded: '1.3.7',
|
||||
versionModified: '1.7.3',
|
||||
migrateDeprecatedValue: (value: any[]) => {
|
||||
return value.map((keybinding) => {
|
||||
if (keybinding['targetSelector'] === '#graph-canvas') {
|
||||
keybinding['targetElementId'] = 'graph-canvas'
|
||||
}
|
||||
return keybinding
|
||||
})
|
||||
}
|
||||
versionModified: '1.7.3'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Keybinding.NewBindings',
|
||||
@@ -716,11 +701,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
name: 'The active color palette id',
|
||||
type: 'hidden',
|
||||
defaultValue: 'dark',
|
||||
versionModified: '1.6.7',
|
||||
migrateDeprecatedValue(value: string) {
|
||||
// Legacy custom palettes were prefixed with 'custom_'
|
||||
return value.startsWith('custom_') ? value.replace('custom_', '') : value
|
||||
}
|
||||
versionModified: '1.6.7'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.CustomColorPalettes',
|
||||
|
||||
@@ -46,22 +46,26 @@ export function transformNodeDefV1ToV2(
|
||||
const outputs: OutputSpecV2[] = []
|
||||
|
||||
if (nodeDefV1.output) {
|
||||
nodeDefV1.output.forEach((outputType, index) => {
|
||||
const outputSpec: OutputSpecV2 = {
|
||||
index,
|
||||
name: nodeDefV1.output_name?.[index] || `output_${index}`,
|
||||
type: Array.isArray(outputType) ? 'COMBO' : outputType,
|
||||
is_list: nodeDefV1.output_is_list?.[index] || false,
|
||||
tooltip: nodeDefV1.output_tooltips?.[index]
|
||||
}
|
||||
if (Array.isArray(nodeDefV1.output)) {
|
||||
nodeDefV1.output.forEach((outputType, index) => {
|
||||
const outputSpec: OutputSpecV2 = {
|
||||
index,
|
||||
name: nodeDefV1.output_name?.[index] || `output_${index}`,
|
||||
type: Array.isArray(outputType) ? 'COMBO' : outputType,
|
||||
is_list: nodeDefV1.output_is_list?.[index] || false,
|
||||
tooltip: nodeDefV1.output_tooltips?.[index]
|
||||
}
|
||||
|
||||
// Add options for combo outputs
|
||||
if (Array.isArray(outputType)) {
|
||||
outputSpec.options = outputType
|
||||
}
|
||||
// Add options for combo outputs
|
||||
if (Array.isArray(outputType)) {
|
||||
outputSpec.options = outputType
|
||||
}
|
||||
|
||||
outputs.push(outputSpec)
|
||||
})
|
||||
outputs.push(outputSpec)
|
||||
})
|
||||
} else {
|
||||
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the V2 node definition
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { SettingParams } from '@/types/settingTypes'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { runSettingMigrations } from '@/utils/migration/settingsMigration'
|
||||
|
||||
export const getSettingInfo = (setting: SettingParams) => {
|
||||
const parts = setting.category || setting.id.split('.')
|
||||
@@ -20,10 +21,6 @@ export interface SettingTreeNode extends TreeNode {
|
||||
data?: SettingParams
|
||||
}
|
||||
|
||||
function tryMigrateDeprecatedValue(setting: SettingParams, value: any) {
|
||||
return setting?.migrateDeprecatedValue?.(value) ?? value
|
||||
}
|
||||
|
||||
function onChange(setting: SettingParams, newValue: any, oldValue: any) {
|
||||
if (setting?.onChange) {
|
||||
setting.onChange(newValue, oldValue)
|
||||
@@ -54,16 +51,12 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
|
||||
// Clone the incoming value to prevent external mutations
|
||||
const clonedValue = _.cloneDeep(value)
|
||||
const newValue = tryMigrateDeprecatedValue(
|
||||
settingsById.value[key],
|
||||
clonedValue
|
||||
)
|
||||
const oldValue = get(key)
|
||||
if (newValue === oldValue) return
|
||||
if (clonedValue === oldValue) return
|
||||
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
settingValues.value[key] = newValue
|
||||
await api.storeSetting(key, newValue)
|
||||
onChange(settingsById.value[key], clonedValue, oldValue)
|
||||
settingValues.value[key] = clonedValue
|
||||
await api.storeSetting(key, clonedValue)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,12 +95,7 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
|
||||
settingsById.value[setting.id] = setting
|
||||
|
||||
if (settingValues.value[setting.id] !== undefined) {
|
||||
settingValues.value[setting.id] = tryMigrateDeprecatedValue(
|
||||
setting,
|
||||
settingValues.value[setting.id]
|
||||
)
|
||||
}
|
||||
// Trigger onChange callback with current value
|
||||
onChange(setting, get(setting.id), undefined)
|
||||
}
|
||||
|
||||
@@ -122,6 +110,9 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
)
|
||||
}
|
||||
settingValues.value = await api.getSettings()
|
||||
|
||||
// Run migrations after loading values but before settings are registered
|
||||
await runSettingMigrations()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -8742,16 +8742,42 @@ export interface components {
|
||||
MoonvalleyUploadResponse: {
|
||||
access_url?: string
|
||||
}
|
||||
/** @description GitHub release webhook payload based on official webhook documentation */
|
||||
GithubReleaseWebhook: {
|
||||
/** @description The action performed on the release */
|
||||
action: string
|
||||
/**
|
||||
* @description The action performed on the release
|
||||
* @enum {string}
|
||||
*/
|
||||
action:
|
||||
| 'published'
|
||||
| 'unpublished'
|
||||
| 'created'
|
||||
| 'edited'
|
||||
| 'deleted'
|
||||
| 'prereleased'
|
||||
| 'released'
|
||||
/** @description The release object */
|
||||
release: {
|
||||
/** @description The ID of the release */
|
||||
id: number
|
||||
/** @description The node ID of the release */
|
||||
node_id: string
|
||||
/** @description The API URL of the release */
|
||||
url: string
|
||||
/** @description The HTML URL of the release */
|
||||
html_url: string
|
||||
/** @description The URL to the release assets */
|
||||
assets_url?: string
|
||||
/** @description The URL to upload release assets */
|
||||
upload_url?: string
|
||||
/** @description The tag name of the release */
|
||||
tag_name: string
|
||||
/** @description The branch or commit the release was created from */
|
||||
target_commitish: string
|
||||
/** @description The name of the release */
|
||||
name: string
|
||||
name?: string | null
|
||||
/** @description The release notes/body */
|
||||
body: string
|
||||
body?: string | null
|
||||
/** @description Whether the release is a draft */
|
||||
draft: boolean
|
||||
/** @description Whether the release is a prerelease */
|
||||
@@ -8760,33 +8786,228 @@ export interface components {
|
||||
* Format: date-time
|
||||
* @description When the release was created
|
||||
*/
|
||||
created_at?: string
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the release was published
|
||||
*/
|
||||
published_at?: string
|
||||
published_at?: string | null
|
||||
author: components['schemas']['GithubUser']
|
||||
/** @description URL to the tarball */
|
||||
tarball_url?: string
|
||||
tarball_url: string
|
||||
/** @description URL to the zipball */
|
||||
zipball_url?: string
|
||||
/** @description The branch or commit the release was created from */
|
||||
target_commitish: string
|
||||
}
|
||||
repository: {
|
||||
/** @description The name of the repository */
|
||||
name: string
|
||||
/** @description The full name of the repository (owner/repo) */
|
||||
full_name: string
|
||||
/** @description The HTML URL of the repository */
|
||||
html_url: string
|
||||
/** @description The clone URL of the repository */
|
||||
clone_url: string
|
||||
zipball_url: string
|
||||
/** @description Array of release assets */
|
||||
assets: components['schemas']['GithubReleaseAsset'][]
|
||||
}
|
||||
repository: components['schemas']['GithubRepository']
|
||||
sender: components['schemas']['GithubUser']
|
||||
organization?: components['schemas']['GithubOrganization']
|
||||
installation?: components['schemas']['GithubInstallation']
|
||||
enterprise?: components['schemas']['GithubEnterprise']
|
||||
}
|
||||
/** @description A GitHub user */
|
||||
GithubUser: {
|
||||
/** @description The user's login name */
|
||||
login: string
|
||||
/** @description The user's ID */
|
||||
id: number
|
||||
/** @description The user's node ID */
|
||||
node_id: string
|
||||
/** @description URL to the user's avatar */
|
||||
avatar_url: string
|
||||
/** @description The user's gravatar ID */
|
||||
gravatar_id?: string | null
|
||||
/** @description The API URL of the user */
|
||||
url: string
|
||||
/** @description The HTML URL of the user */
|
||||
html_url: string
|
||||
/**
|
||||
* @description The type of user
|
||||
* @enum {string}
|
||||
*/
|
||||
type: 'Bot' | 'User' | 'Organization'
|
||||
/** @description Whether the user is a site admin */
|
||||
site_admin: boolean
|
||||
}
|
||||
/** @description A GitHub repository */
|
||||
GithubRepository: {
|
||||
/** @description The repository ID */
|
||||
id: number
|
||||
/** @description The repository node ID */
|
||||
node_id: string
|
||||
/** @description The name of the repository */
|
||||
name: string
|
||||
/** @description The full name of the repository (owner/repo) */
|
||||
full_name: string
|
||||
/** @description Whether the repository is private */
|
||||
private: boolean
|
||||
owner: components['schemas']['GithubUser']
|
||||
/** @description The HTML URL of the repository */
|
||||
html_url: string
|
||||
/** @description The repository description */
|
||||
description?: string | null
|
||||
/** @description Whether the repository is a fork */
|
||||
fork: boolean
|
||||
/** @description The API URL of the repository */
|
||||
url: string
|
||||
/** @description The clone URL of the repository */
|
||||
clone_url: string
|
||||
/** @description The git URL of the repository */
|
||||
git_url: string
|
||||
/** @description The SSH URL of the repository */
|
||||
ssh_url: string
|
||||
/** @description The default branch of the repository */
|
||||
default_branch: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was last pushed to
|
||||
*/
|
||||
pushed_at: string
|
||||
}
|
||||
/** @description A GitHub release asset */
|
||||
GithubReleaseAsset: {
|
||||
/** @description The asset ID */
|
||||
id: number
|
||||
/** @description The asset node ID */
|
||||
node_id: string
|
||||
/** @description The name of the asset */
|
||||
name: string
|
||||
/** @description The label of the asset */
|
||||
label?: string | null
|
||||
/** @description The content type of the asset */
|
||||
content_type: string
|
||||
/**
|
||||
* @description The state of the asset
|
||||
* @enum {string}
|
||||
*/
|
||||
state: 'uploaded' | 'open'
|
||||
/** @description The size of the asset in bytes */
|
||||
size: number
|
||||
/** @description The number of downloads */
|
||||
download_count: number
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the asset was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the asset was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/** @description The browser download URL */
|
||||
browser_download_url: string
|
||||
uploader: components['schemas']['GithubUser']
|
||||
}
|
||||
/** @description A GitHub organization */
|
||||
GithubOrganization: {
|
||||
/** @description The organization's login name */
|
||||
login: string
|
||||
/** @description The organization ID */
|
||||
id: number
|
||||
/** @description The organization node ID */
|
||||
node_id: string
|
||||
/** @description The API URL of the organization */
|
||||
url: string
|
||||
/** @description The API URL of the organization's repositories */
|
||||
repos_url: string
|
||||
/** @description The API URL of the organization's events */
|
||||
events_url: string
|
||||
/** @description The API URL of the organization's hooks */
|
||||
hooks_url: string
|
||||
/** @description The API URL of the organization's issues */
|
||||
issues_url: string
|
||||
/** @description The API URL of the organization's members */
|
||||
members_url: string
|
||||
/** @description The API URL of the organization's public members */
|
||||
public_members_url: string
|
||||
/** @description URL to the organization's avatar */
|
||||
avatar_url: string
|
||||
/** @description The organization description */
|
||||
description?: string | null
|
||||
}
|
||||
/** @description A GitHub App installation */
|
||||
GithubInstallation: {
|
||||
/** @description The installation ID */
|
||||
id: number
|
||||
account: components['schemas']['GithubUser']
|
||||
/**
|
||||
* @description Repository selection for the installation
|
||||
* @enum {string}
|
||||
*/
|
||||
repository_selection: 'selected' | 'all'
|
||||
/** @description The API URL for access tokens */
|
||||
access_tokens_url: string
|
||||
/** @description The API URL for repositories */
|
||||
repositories_url: string
|
||||
/** @description The HTML URL of the installation */
|
||||
html_url: string
|
||||
/** @description The GitHub App ID */
|
||||
app_id: number
|
||||
/** @description The target ID */
|
||||
target_id: number
|
||||
/** @description The target type */
|
||||
target_type: string
|
||||
/** @description The installation permissions */
|
||||
permissions: Record<string, never>
|
||||
/** @description The events the installation subscribes to */
|
||||
events: string[]
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the installation was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the installation was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/** @description The single file name if applicable */
|
||||
single_file_name?: string | null
|
||||
}
|
||||
/** @description A GitHub enterprise */
|
||||
GithubEnterprise: {
|
||||
/** @description The enterprise ID */
|
||||
id: number
|
||||
/** @description The enterprise slug */
|
||||
slug: string
|
||||
/** @description The enterprise name */
|
||||
name: string
|
||||
/** @description The enterprise node ID */
|
||||
node_id: string
|
||||
/** @description URL to the enterprise avatar */
|
||||
avatar_url: string
|
||||
/** @description The enterprise description */
|
||||
description?: string | null
|
||||
/** @description The enterprise website URL */
|
||||
website_url?: string | null
|
||||
/** @description The HTML URL of the enterprise */
|
||||
html_url: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the enterprise was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the enterprise was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
}
|
||||
ReleaseNote: {
|
||||
/** @description Unique identifier for the release note */
|
||||
id?: number
|
||||
id: number
|
||||
/**
|
||||
* @description The project this release note belongs to
|
||||
* @enum {string}
|
||||
@@ -8805,7 +9026,7 @@ export interface components {
|
||||
* Format: date-time
|
||||
* @description When the release note was published
|
||||
*/
|
||||
published_at?: string
|
||||
published_at: string
|
||||
}
|
||||
}
|
||||
responses: never
|
||||
@@ -11011,6 +11232,8 @@ export interface operations {
|
||||
sort?: string[]
|
||||
/** @description node_id to use as filter */
|
||||
node_id?: string[]
|
||||
/** @description Comfy UI version */
|
||||
comfyui_version?: string
|
||||
/** @description The platform requesting the nodes */
|
||||
form_factor?: string
|
||||
}
|
||||
@@ -11584,6 +11807,10 @@ export interface operations {
|
||||
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
|
||||
/** @description The current version to filter release notes */
|
||||
current_version?: string
|
||||
/** @description The locale for the release notes */
|
||||
locale?: 'en' | 'es' | 'fr' | 'ja' | 'ko' | 'ru' | 'zh'
|
||||
/** @description The platform requesting the release notes */
|
||||
form_factor?: string
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
@@ -11623,7 +11850,20 @@ export interface operations {
|
||||
processReleaseWebhook: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
header: {
|
||||
/** @description The name of the event that triggered the delivery */
|
||||
'X-GitHub-Event': 'release'
|
||||
/** @description A globally unique identifier (GUID) to identify the event */
|
||||
'X-GitHub-Delivery': string
|
||||
/** @description The unique identifier of the webhook */
|
||||
'X-GitHub-Hook-ID': string
|
||||
/** @description HMAC hex digest of the request body using SHA-256 hash function */
|
||||
'X-Hub-Signature-256'?: string
|
||||
/** @description The type of resource where the webhook was created */
|
||||
'X-GitHub-Hook-Installation-Target-Type'?: string
|
||||
/** @description The unique identifier of the resource where the webhook was created */
|
||||
'X-GitHub-Hook-Installation-Target-ID'?: string
|
||||
}
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
@@ -11649,6 +11889,15 @@ export interface operations {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Validation failed or endpoint has been spammed */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
|
||||
@@ -43,8 +43,6 @@ export interface SettingParams extends FormItem {
|
||||
category?: string[]
|
||||
experimental?: boolean
|
||||
deprecated?: boolean
|
||||
// Deprecated values are mapped to new values.
|
||||
migrateDeprecatedValue?: (value: any) => any
|
||||
// Version of the setting when it was added
|
||||
versionAdded?: string
|
||||
// Version of the setting when it was last modified
|
||||
|
||||
88
src/utils/migration/settingsMigration.ts
Normal file
88
src/utils/migration/settingsMigration.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Keybinding } from '@/schemas/keyBindingSchema'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
export interface SettingMigration {
|
||||
condition: () => boolean
|
||||
migrate: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting value migrations that transform deprecated values to new formats.
|
||||
* These run after settings are loaded from the server but before they are
|
||||
* registered, ensuring settings have valid values when the app initializes.
|
||||
*/
|
||||
export const SETTING_MIGRATIONS: SettingMigration[] = [
|
||||
// Migrate Comfy.UseNewMenu "Floating" value to "Top"
|
||||
{
|
||||
condition: () => {
|
||||
const settingStore = useSettingStore()
|
||||
return (
|
||||
settingStore.exists('Comfy.UseNewMenu') &&
|
||||
(settingStore.get('Comfy.UseNewMenu') as string) === 'Floating'
|
||||
)
|
||||
},
|
||||
migrate: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
await settingStore.set('Comfy.UseNewMenu', 'Top')
|
||||
}
|
||||
},
|
||||
// Migrate Comfy.Keybinding.UnsetBindings targetSelector to targetElementId
|
||||
{
|
||||
condition: () => {
|
||||
const settingStore = useSettingStore()
|
||||
if (!settingStore.exists('Comfy.Keybinding.UnsetBindings')) return false
|
||||
const keybindings = settingStore.get(
|
||||
'Comfy.Keybinding.UnsetBindings'
|
||||
) as Keybinding[]
|
||||
return keybindings.some((kb: any) => 'targetSelector' in kb)
|
||||
},
|
||||
migrate: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const keybindings = settingStore.get(
|
||||
'Comfy.Keybinding.UnsetBindings'
|
||||
) as Keybinding[]
|
||||
const migrated = keybindings.map((keybinding) => {
|
||||
// Create a new object to avoid mutating the original
|
||||
const newKeybinding = { ...keybinding }
|
||||
if (
|
||||
'targetSelector' in newKeybinding &&
|
||||
newKeybinding['targetSelector'] === '#graph-canvas'
|
||||
) {
|
||||
newKeybinding['targetElementId'] = 'graph-canvas'
|
||||
delete newKeybinding['targetSelector']
|
||||
}
|
||||
return newKeybinding
|
||||
})
|
||||
await settingStore.set('Comfy.Keybinding.UnsetBindings', migrated)
|
||||
}
|
||||
},
|
||||
// Migrate Comfy.ColorPalette custom_ prefix
|
||||
{
|
||||
condition: () => {
|
||||
const settingStore = useSettingStore()
|
||||
return (
|
||||
settingStore.exists('Comfy.ColorPalette') &&
|
||||
(settingStore.get('Comfy.ColorPalette') as string).startsWith('custom_')
|
||||
)
|
||||
},
|
||||
migrate: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const value = settingStore.get('Comfy.ColorPalette') as string
|
||||
await settingStore.set('Comfy.ColorPalette', value.replace('custom_', ''))
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Runs all setting migrations that meet their conditions.
|
||||
* This is called after loadSettingValues() to ensure all deprecated
|
||||
* setting values are migrated to their current format before the
|
||||
* application starts using them.
|
||||
*/
|
||||
export async function runSettingMigrations(): Promise<void> {
|
||||
for (const migration of SETTING_MIGRATIONS) {
|
||||
if (migration.condition()) {
|
||||
await migration.migrate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,21 +92,6 @@ describe('useSettingStore', () => {
|
||||
'Setting test.setting must have a unique ID.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should migrate deprecated values', () => {
|
||||
const setting: SettingParams = {
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default',
|
||||
migrateDeprecatedValue: (value: string) => value.toUpperCase()
|
||||
}
|
||||
|
||||
store.settingValues['test.setting'] = 'oldvalue'
|
||||
store.addSetting(setting)
|
||||
|
||||
expect(store.settingValues['test.setting']).toBe('OLDVALUE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('get and set', () => {
|
||||
|
||||
271
tests-ui/tests/utils/migration/settingsMigration.test.ts
Normal file
271
tests-ui/tests/utils/migration/settingsMigration.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import {
|
||||
SETTING_MIGRATIONS,
|
||||
runSettingMigrations
|
||||
} from '@/utils/migration/settingsMigration'
|
||||
|
||||
// Mock the api
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the app
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('settingsMigration', () => {
|
||||
let store: ReturnType<typeof useSettingStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
store = useSettingStore()
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock the store methods to avoid needing registered settings
|
||||
vi.spyOn(store, 'set').mockImplementation(async (key, value) => {
|
||||
store.settingValues[key] = value
|
||||
await api.storeSetting(key, value)
|
||||
})
|
||||
|
||||
vi.spyOn(store, 'get').mockImplementation((key) => {
|
||||
return store.settingValues[key]
|
||||
})
|
||||
|
||||
vi.spyOn(store, 'exists').mockImplementation((key) => {
|
||||
return key in store.settingValues
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.UseNewMenu migration', () => {
|
||||
it('should migrate "Floating" value to "Top"', async () => {
|
||||
// Setup initial state with old value
|
||||
store.settingValues = { 'Comfy.UseNewMenu': 'Floating' }
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[0]
|
||||
expect(migration.condition()).toBe(true)
|
||||
|
||||
// Run migration
|
||||
await migration.migrate()
|
||||
|
||||
// Verify the value was updated
|
||||
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Top')
|
||||
expect(api.storeSetting).toHaveBeenCalledWith('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
it('should not migrate when value is not "Floating"', () => {
|
||||
// Setup with different value
|
||||
store.settingValues = { 'Comfy.UseNewMenu': 'Bottom' }
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[0]
|
||||
expect(migration.condition()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not migrate when setting does not exist', () => {
|
||||
// No settings
|
||||
store.settingValues = {}
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[0]
|
||||
expect(migration.condition()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Keybinding.UnsetBindings migration', () => {
|
||||
it('should migrate targetSelector to targetElementId', async () => {
|
||||
// Setup with old format
|
||||
store.settingValues = {
|
||||
'Comfy.Keybinding.UnsetBindings': [
|
||||
{ targetSelector: '#graph-canvas', key: 'a' },
|
||||
{ targetSelector: '#other', key: 'b' }
|
||||
]
|
||||
}
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[1]
|
||||
expect(migration.condition()).toBe(true)
|
||||
|
||||
// Run migration
|
||||
await migration.migrate()
|
||||
|
||||
// Verify the migration
|
||||
const result = store.settingValues['Comfy.Keybinding.UnsetBindings']
|
||||
expect(result).toEqual([
|
||||
{ targetElementId: 'graph-canvas', key: 'a' },
|
||||
{ targetSelector: '#other', key: 'b' } // Only #graph-canvas is migrated
|
||||
])
|
||||
expect(api.storeSetting).toHaveBeenCalledWith(
|
||||
'Comfy.Keybinding.UnsetBindings',
|
||||
result
|
||||
)
|
||||
})
|
||||
|
||||
it('should delete targetSelector property after migration', async () => {
|
||||
// Setup with old format
|
||||
store.settingValues = {
|
||||
'Comfy.Keybinding.UnsetBindings': [
|
||||
{ targetSelector: '#graph-canvas', key: 'a' }
|
||||
]
|
||||
}
|
||||
|
||||
// Run migration
|
||||
await SETTING_MIGRATIONS[1].migrate()
|
||||
|
||||
// Verify targetSelector is deleted and targetElementId is added
|
||||
const result = store.settingValues['Comfy.Keybinding.UnsetBindings'][0]
|
||||
expect(result).not.toHaveProperty('targetSelector')
|
||||
expect(result).toHaveProperty('targetElementId', 'graph-canvas')
|
||||
expect(result).toHaveProperty('key', 'a')
|
||||
})
|
||||
|
||||
it('should not migrate when all keybindings use new format', () => {
|
||||
// Setup with new format
|
||||
store.settingValues = {
|
||||
'Comfy.Keybinding.UnsetBindings': [
|
||||
{ targetElementId: 'graph-canvas', key: 'a' }
|
||||
]
|
||||
}
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[1]
|
||||
expect(migration.condition()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not migrate when setting does not exist', () => {
|
||||
// No settings
|
||||
store.settingValues = {}
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[1]
|
||||
expect(migration.condition()).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty keybindings array', () => {
|
||||
// Empty array
|
||||
store.settingValues = { 'Comfy.Keybinding.UnsetBindings': [] }
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[1]
|
||||
expect(migration.condition()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.ColorPalette migration', () => {
|
||||
it('should remove "custom_" prefix', async () => {
|
||||
// Setup with old format
|
||||
store.settingValues = { 'Comfy.ColorPalette': 'custom_mytheme' }
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[2]
|
||||
expect(migration.condition()).toBe(true)
|
||||
|
||||
// Run migration
|
||||
await migration.migrate()
|
||||
|
||||
// Verify the migration
|
||||
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
|
||||
expect(api.storeSetting).toHaveBeenCalledWith(
|
||||
'Comfy.ColorPalette',
|
||||
'mytheme'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not migrate when value does not start with "custom_"', () => {
|
||||
// Setup with value that doesn't need migration
|
||||
store.settingValues = { 'Comfy.ColorPalette': 'dark' }
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[2]
|
||||
expect(migration.condition()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not migrate when setting does not exist', () => {
|
||||
// No settings
|
||||
store.settingValues = {}
|
||||
|
||||
// Check condition
|
||||
const migration = SETTING_MIGRATIONS[2]
|
||||
expect(migration.condition()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runSettingMigrations', () => {
|
||||
it('should run all applicable migrations', async () => {
|
||||
// Setup state that triggers all migrations
|
||||
store.settingValues = {
|
||||
'Comfy.UseNewMenu': 'Floating',
|
||||
'Comfy.Keybinding.UnsetBindings': [
|
||||
{ targetSelector: '#graph-canvas', key: 'a' }
|
||||
],
|
||||
'Comfy.ColorPalette': 'custom_mytheme'
|
||||
}
|
||||
|
||||
// Run all migrations
|
||||
await runSettingMigrations()
|
||||
|
||||
// Verify all migrations ran
|
||||
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Top')
|
||||
expect(store.settingValues['Comfy.Keybinding.UnsetBindings']).toEqual([
|
||||
{ targetElementId: 'graph-canvas', key: 'a' }
|
||||
])
|
||||
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
|
||||
|
||||
// Verify all API calls were made
|
||||
expect(api.storeSetting).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should only run migrations that meet their conditions', async () => {
|
||||
// Setup state that only triggers one migration
|
||||
store.settingValues = {
|
||||
'Comfy.UseNewMenu': 'Bottom', // Won't migrate
|
||||
'Comfy.ColorPalette': 'custom_mytheme' // Will migrate
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
await runSettingMigrations()
|
||||
|
||||
// Verify only one migration ran
|
||||
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Bottom')
|
||||
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
|
||||
|
||||
// Only one API call
|
||||
expect(api.storeSetting).toHaveBeenCalledTimes(1)
|
||||
expect(api.storeSetting).toHaveBeenCalledWith(
|
||||
'Comfy.ColorPalette',
|
||||
'mytheme'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle no migrations needed', async () => {
|
||||
// Setup state that doesn't trigger any migrations
|
||||
store.settingValues = {
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.Keybinding.UnsetBindings': [
|
||||
{ targetElementId: 'graph-canvas', key: 'a' }
|
||||
],
|
||||
'Comfy.ColorPalette': 'dark'
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
await runSettingMigrations()
|
||||
|
||||
// No API calls should be made
|
||||
expect(api.storeSetting).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user