mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-10 00:15:50 +00:00
Compare commits
4 Commits
jaeone/fe-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47118ef64f | ||
|
|
f110af79f7 | ||
|
|
8972d27689 | ||
|
|
72d1261983 |
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "TEST_MISSING_PACK_NODE_A",
|
||||
"pos": [48, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "TEST_MISSING_PACK_NODE_B",
|
||||
"pos": [520, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -45,8 +45,6 @@ export const TestIds = {
|
||||
errorOverlayMessages: 'error-overlay-messages',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
missingNodePackExpand: 'missing-node-pack-expand',
|
||||
missingNodePackCount: 'missing-node-pack-count',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
errorDialog: 'error-dialog',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
@@ -12,97 +12,97 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show missing node pack card with guidance', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
await expect(
|
||||
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should show unknown pack node rows by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
|
||||
await expect(
|
||||
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should locate missing node from the row label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
|
||||
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBeforeLocate)
|
||||
})
|
||||
|
||||
test('Should toggle grouped pack nodes from chevron and title', async ({
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_same_pack'
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
const packTitle = missingNodeCard.getByRole('button', {
|
||||
name: 'test-missing-node-pack'
|
||||
})
|
||||
const expandButton = missingNodeCard
|
||||
.getByTestId(TestIds.dialogs.missingNodePackExpand)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
const firstNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_A'
|
||||
})
|
||||
const secondNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_B'
|
||||
})
|
||||
|
||||
await expect(packTitle).toBeVisible()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
|
||||
).toHaveText('2')
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
await expandButton.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /collapse/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const locateButton = missingNodeCard.getByRole('button', {
|
||||
name: /locate/i
|
||||
})
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
// TODO: Add navigation assertion once subgraph node ID deduplication
|
||||
// timing is fixed. Currently, collectMissingNodes runs before
|
||||
// configure(), so execution IDs use pre-remapped node IDs that don't
|
||||
// match the runtime graph. See PR #9510 / #8762.
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
@@ -51,5 +51,5 @@ const {
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const { error } = useImage(computed(() => ({ src, alt })))
|
||||
const { error } = useImageQuiet(computed(() => ({ src, alt })))
|
||||
</script>
|
||||
|
||||
@@ -71,11 +71,12 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
|
||||
name: 'MissingPackGroupRow',
|
||||
template: `<div class="pack-row" data-testid="pack-row"
|
||||
:data-show-info-button="String(showInfoButton)"
|
||||
:data-show-node-id-badge="String(showNodeIdBadge)"
|
||||
>
|
||||
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
|
||||
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
|
||||
</div>`,
|
||||
props: ['group', 'showInfoButton'],
|
||||
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
|
||||
emits: ['locate-node', 'open-manager-info']
|
||||
}
|
||||
}))
|
||||
@@ -121,6 +122,7 @@ function makePackGroups(count = 2): MissingPackGroup[] {
|
||||
function renderCard(
|
||||
props: Partial<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}> = {}
|
||||
) {
|
||||
@@ -128,6 +130,7 @@ function renderCard(
|
||||
const result = render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
...props
|
||||
},
|
||||
@@ -166,10 +169,12 @@ describe('MissingNodeCard', () => {
|
||||
|
||||
it('passes props correctly to MissingPackGroupRow children', () => {
|
||||
renderCard({
|
||||
showInfoButton: true
|
||||
showInfoButton: true,
|
||||
showNodeIdBadge: true
|
||||
})
|
||||
const row = screen.getAllByTestId('pack-row')[0]
|
||||
expect(row.getAttribute('data-show-info-button')).toBe('true')
|
||||
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -251,6 +256,7 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onLocateNode
|
||||
},
|
||||
@@ -273,6 +279,7 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onOpenManagerInfo
|
||||
},
|
||||
|
||||
@@ -56,29 +56,27 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
</div>
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
|
||||
<div v-if="shouldShowManagerButtons" class="px-4">
|
||||
<Button
|
||||
v-if="hasInstalledPacksPendingRestart"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
@@ -107,8 +105,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
|
||||
const { showInfoButton, missingPackGroups } = defineProps<{
|
||||
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -61,16 +61,16 @@ const i18n = createI18n({
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
install: 'Install',
|
||||
loading: 'Loading',
|
||||
search: 'Search'
|
||||
loading: 'Loading'
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate node on canvas',
|
||||
missingNodePacks: {
|
||||
unknownPack: 'Unknown pack',
|
||||
installNodePack: 'Install node pack',
|
||||
installing: 'Installing...',
|
||||
installed: 'Installed',
|
||||
searchInManager: 'Search in Node Manager',
|
||||
viewInManager: 'View in Manager',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand'
|
||||
@@ -100,6 +100,7 @@ function renderRow(
|
||||
props: Partial<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
@@ -109,6 +110,7 @@ function renderRow(
|
||||
props: {
|
||||
group: makeGroup(),
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
onLocateNode,
|
||||
onOpenManagerInfo,
|
||||
...props
|
||||
@@ -116,6 +118,7 @@ function renderRow(
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
DotSpinner: {
|
||||
template: '<span role="status" aria-label="loading" />'
|
||||
}
|
||||
@@ -155,7 +158,7 @@ describe('MissingPackGroupRow', () => {
|
||||
|
||||
it('renders node count', () => {
|
||||
renderRow()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders count of 5 for 5 nodeTypes', () => {
|
||||
@@ -168,19 +171,38 @@ describe('MissingPackGroupRow', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Expand / Collapse', () => {
|
||||
it('starts collapsed', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands when chevron is clicked', async () => {
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when chevron is clicked again', async () => {
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type List', () => {
|
||||
it('hides multiple nodeTypes behind the expand control by default', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
|
||||
})
|
||||
async function expand(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
}
|
||||
|
||||
it('renders all nodeTypes after expanding', async () => {
|
||||
it('renders all nodeTypes when expanded', async () => {
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
@@ -190,87 +212,40 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
await expand(user)
|
||||
expect(screen.getByText('NodeA')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeB')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides multiple nodeTypes again after collapsing', async () => {
|
||||
const { user } = renderRow()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
it('shows nodeId badge when showNodeIdBadge is true', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides a single nodeType without an expand control', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Expand' })
|
||||
).not.toBeInTheDocument()
|
||||
it('hides nodeId badge when showNodeIdBadge is false', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: false })
|
||||
await expand(user)
|
||||
expect(screen.queryByText('#10')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
|
||||
const { user, onLocateNode } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'my-pack' }))
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('100')
|
||||
})
|
||||
|
||||
it('moves locate to the header when there is one nodeType', async () => {
|
||||
const { user, onLocateNode } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Locate node on canvas' })
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('100')
|
||||
})
|
||||
|
||||
it('emits locateNode when expanded child Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
it('emits locateNode when Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('emits locateNode when node label is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
await user.click(screen.getByRole('button', { name: 'MissingA' }))
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', () => {
|
||||
renderRow({
|
||||
it('does not show Locate for nodeType without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -278,6 +253,7 @@ describe('MissingPackGroupRow', () => {
|
||||
|
||||
it('handles mixed nodeTypes with and without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
showNodeIdBadge: true,
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
{ type: 'WithId', nodeId: '100', isReplaceable: false },
|
||||
@@ -285,7 +261,7 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
await expand(user)
|
||||
expect(screen.getByText('WithId')).toBeInTheDocument()
|
||||
expect(screen.getByText('WithoutId')).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -298,25 +274,21 @@ describe('MissingPackGroupRow', () => {
|
||||
it('hides install UI when shouldShowManagerButtons is false', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
renderRow()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides install UI when packId is null', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Search when packId exists but pack not in registry', () => {
|
||||
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = []
|
||||
renderRow()
|
||||
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Installed" state when pack is installed', () => {
|
||||
@@ -340,9 +312,7 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
renderRow()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Install' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Install node pack')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls installAllPacks when Install button is clicked', async () => {
|
||||
@@ -350,7 +320,9 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Install' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Install node pack/ })
|
||||
)
|
||||
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -397,7 +369,7 @@ describe('MissingPackGroupRow', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty nodeTypes array', () => {
|
||||
renderRow({ group: makeGroup({ nodeTypes: [] }) })
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,221 +1,187 @@
|
||||
<template>
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<div class="mb-2 flex w-full flex-col">
|
||||
<!-- Pack header row: pack name + info + chevron -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<!-- Warning icon for unknown packs -->
|
||||
<i
|
||||
v-if="group.packId === null && !group.isResolving"
|
||||
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:class="
|
||||
group.packId === null && !group.isResolving
|
||||
? 'text-warning-background'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="missing-node-pack-expand"
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
{ 'rotate-180': expanded }
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
v-if="isUnknownPack"
|
||||
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<button
|
||||
v-if="hasMultipleNodeTypes && !group.isResolving"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="primaryLocatableNodeType && !group.isResolving"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-labels: individual node instances, each with their own Locate button -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
"
|
||||
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ packDisplayName }}
|
||||
</span>
|
||||
#{{ nodeType.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="showInstallAction" class="ml-auto shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: isPackInstalled
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('g.install')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
|
||||
<div
|
||||
v-if="
|
||||
shouldShowManagerButtons &&
|
||||
group.packId !== null &&
|
||||
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
|
||||
"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
:disabled="
|
||||
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
|
||||
"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
|
||||
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: comfyManagerStore.isPackInstalled(group.packId)
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('rightSidePanel.missingNodePacks.installNodePack')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Registry still loading: packId known but result not yet available -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<div
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="text-foreground min-w-0 truncate">
|
||||
{{ t('g.search') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<ul
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
<!-- Search in Manager: fetch done but pack not found in registry -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
'text-muted-foreground hover:text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</TransitionCollapse>
|
||||
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref, computed } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
@@ -227,9 +193,10 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { group, showInfoButton } = defineProps<{
|
||||
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -238,10 +205,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
@@ -256,72 +219,17 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||
nodePack.value ? [nodePack.value] : []
|
||||
)
|
||||
|
||||
const isUnknownPack = computed(
|
||||
() => group.packId === null && !group.isResolving
|
||||
)
|
||||
|
||||
const packDisplayName = computed(() => {
|
||||
if (group.packId === null) {
|
||||
return t('rightSidePanel.missingNodePacks.unknownPack')
|
||||
}
|
||||
return nodePack.value?.name ?? group.packId
|
||||
})
|
||||
|
||||
const isPackInstalled = computed(
|
||||
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
|
||||
)
|
||||
|
||||
const showInstallAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
(nodePack.value !== null || isPackInstalled.value)
|
||||
)
|
||||
|
||||
const showLoadingAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
!showInstallAction.value &&
|
||||
isLoading.value
|
||||
)
|
||||
|
||||
const showSearchAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
!showInstallAction.value &&
|
||||
!showLoadingAction.value
|
||||
)
|
||||
|
||||
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
|
||||
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
|
||||
const expanded = computed(
|
||||
() =>
|
||||
expandedOverride.value ??
|
||||
(isUnknownPack.value && hasMultipleNodeTypes.value)
|
||||
)
|
||||
const showNodeTypeList = computed(
|
||||
() =>
|
||||
(isUnknownPack.value && group.nodeTypes.length === 1) ||
|
||||
(hasMultipleNodeTypes.value && expanded.value)
|
||||
)
|
||||
const primaryLocatableNodeType = computed(() => {
|
||||
if (isUnknownPack.value) return null
|
||||
if (group.nodeTypes.length !== 1) return null
|
||||
const [nodeType] = group.nodeTypes
|
||||
return isLocatableNodeType(nodeType) ? nodeType : null
|
||||
})
|
||||
|
||||
function handlePackInstallClick() {
|
||||
if (!group.packId) return
|
||||
if (!isPackInstalled.value) {
|
||||
if (!comfyManagerStore.isPackInstalled(group.packId)) {
|
||||
void installAllPacks()
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expandedOverride.value = !expanded.value
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
@@ -333,14 +241,10 @@ function getLabel(nodeType: MissingNodeType): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function isLocatableNodeType(
|
||||
nodeType: MissingNodeType
|
||||
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
|
||||
return typeof nodeType !== 'string' && nodeType.nodeId != null
|
||||
}
|
||||
|
||||
function handleLocateNode(nodeType: MissingNodeType) {
|
||||
if (!isLocatableNodeType(nodeType)) return
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
|
||||
27
src/composables/useImageQuiet.ts
Normal file
27
src/composables/useImageQuiet.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useImage } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* `useImage()` that handles load failures quietly.
|
||||
*
|
||||
* `useImage()` already surfaces failures via its returned `error` ref (callers
|
||||
* render a fallback). By default vueuse ALSO forwards the error to
|
||||
* `globalThis.reportError`, which our error monitoring (Datadog RUM) captures as
|
||||
* an unhandled error for every broken image — 404'd thumbnails, expired share
|
||||
* links, in-app browsers that re-fetch in a loop. Broken images are expected,
|
||||
* not bugs, so handle the failure here instead of letting it surface globally.
|
||||
* The returned `error` ref behaviour is unchanged.
|
||||
*
|
||||
* `asyncStateOptions` is forwarded to `useImage`, so callers can still tune the
|
||||
* other `useAsyncState` fields; only `onError` is fixed to the quiet default.
|
||||
*/
|
||||
export function useImageQuiet(
|
||||
options: Parameters<typeof useImage>[0],
|
||||
asyncStateOptions?: Parameters<typeof useImage>[1]
|
||||
) {
|
||||
return useImage(options, {
|
||||
...asyncStateOptions,
|
||||
onError: () => {
|
||||
// Surfaced via the returned `error` ref; see the doc comment above.
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3628,10 +3628,12 @@
|
||||
"unsupportedTitle": "Unsupported Node Packs",
|
||||
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
|
||||
"installAll": "Install All",
|
||||
"installNodePack": "Install node pack",
|
||||
"unknownPack": "Unknown pack",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"applyChanges": "Apply Changes",
|
||||
"searchInManager": "Search in Node Manager",
|
||||
"viewInManager": "View in Manager",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand"
|
||||
|
||||
@@ -128,10 +128,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
@@ -190,7 +190,7 @@ const tooltipDelay = computed<number>(() =>
|
||||
settingStore.get('LiteGraph.Node.TooltipDelay')
|
||||
)
|
||||
|
||||
const { isLoading, error } = useImage({
|
||||
const { isLoading, error } = useImageQuiet({
|
||||
src: asset.preview_url ?? '',
|
||||
alt: displayName.value
|
||||
})
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage, whenever } from '@vueuse/core'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
|
||||
@@ -34,7 +35,7 @@ const emit = defineEmits<{
|
||||
view: []
|
||||
}>()
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
const { state, error, isReady } = useImageQuiet({
|
||||
src: asset.src ?? '',
|
||||
alt: getAssetDisplayName(asset)
|
||||
})
|
||||
|
||||
@@ -35,12 +35,17 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
|
||||
let testId = 0
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
delete window.__comfyDesktop2
|
||||
delete window.__comfyDesktop2Remote
|
||||
})
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
testId++
|
||||
})
|
||||
|
||||
@@ -242,7 +247,126 @@ describe('downloadModel', () => {
|
||||
beforeEach(() => {
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
})
|
||||
|
||||
it('uses the Desktop2 bridge directly instead of the browser fallback', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).toHaveBeenCalledWith(
|
||||
'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
'model.safetensors',
|
||||
'checkpoints'
|
||||
)
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs Desktop2 bridge failures without falling back to browser download', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bridgeError = new Error('bridge failed')
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockRejectedValue(bridgeError)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to start Desktop2 model download:',
|
||||
bridgeError
|
||||
)
|
||||
})
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs synchronous Desktop2 bridge failures without crashing', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bridgeError = new Error('bridge failed before returning a promise')
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockImplementation(() => {
|
||||
throw bridgeError
|
||||
})
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to start Desktop2 model download:',
|
||||
bridgeError
|
||||
)
|
||||
})
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps remote Desktop2 sessions on the browser fallback', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2Remote = true
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).not.toHaveBeenCalled()
|
||||
expect(anchorClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens the model library sidebar before starting a desktop download', () => {
|
||||
|
||||
@@ -3,6 +3,21 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
interface ComfyDesktop2Bridge {
|
||||
downloadModel: (
|
||||
url: string,
|
||||
filename: string,
|
||||
directory: string
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__comfyDesktop2?: ComfyDesktop2Bridge
|
||||
__comfyDesktop2Remote?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
'https://civitai.red/',
|
||||
@@ -35,6 +50,17 @@ export interface ModelWithUrl {
|
||||
directory: string
|
||||
}
|
||||
|
||||
async function startDesktop2ModelDownload(
|
||||
bridge: ComfyDesktop2Bridge,
|
||||
model: ModelWithUrl
|
||||
): Promise<void> {
|
||||
try {
|
||||
await bridge.downloadModel(model.url, model.name, model.directory)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to start Desktop2 model download:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a model download URL to a browsable page URL.
|
||||
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
|
||||
@@ -63,6 +89,12 @@ export function downloadModel(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
const desktop2Bridge = window.__comfyDesktop2
|
||||
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
|
||||
void startDesktop2ModelDownload(desktop2Bridge, model)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDesktop) {
|
||||
const link = document.createElement('a')
|
||||
link.href = model.url
|
||||
|
||||
@@ -208,6 +208,23 @@ describe('GtmTelemetryProvider', () => {
|
||||
expect(entry!.error as string).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('pushes execution_start', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'execution_start'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes execution_success with job_id', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackExecutionSuccess({ jobId: 'job-1' })
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'execution_success',
|
||||
job_id: 'job-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes select_content for template events', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackTemplate({
|
||||
|
||||
@@ -59,8 +59,6 @@ import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ShareFlowMetadata,
|
||||
SurveyResponses,
|
||||
TemplateLibraryClosedMetadata,
|
||||
@@ -288,8 +286,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
}
|
||||
const enterLinearMetadata: EnterLinearMetadata = {}
|
||||
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
|
||||
const executionErrorMetadata: ExecutionErrorMetadata = { jobId: 'job-1' }
|
||||
const executionSuccessMetadata: ExecutionSuccessMetadata = { jobId: 'job-1' }
|
||||
const authMetadata: AuthMetadata = {}
|
||||
|
||||
it.for<
|
||||
@@ -355,16 +351,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
(p) => p.trackShareFlow(shareFlowMetadata),
|
||||
TelemetryEvents.SHARE_FLOW
|
||||
],
|
||||
[
|
||||
'trackExecutionError',
|
||||
(p) => p.trackExecutionError(executionErrorMetadata),
|
||||
TelemetryEvents.EXECUTION_ERROR
|
||||
],
|
||||
[
|
||||
'trackExecutionSuccess',
|
||||
(p) => p.trackExecutionSuccess(executionSuccessMetadata),
|
||||
TelemetryEvents.EXECUTION_SUCCESS
|
||||
],
|
||||
[
|
||||
'trackAuth',
|
||||
(p) => p.trackAuth(authMetadata),
|
||||
@@ -422,27 +408,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('trackWorkflowExecution forwards the latest trigger_source from trackRunButton', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackRunButton({ trigger_source: 'keybinding' })
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({ trigger_source: 'keybinding' })
|
||||
)
|
||||
|
||||
mockMixpanel.track.mockClear()
|
||||
provider.trackWorkflowExecution()
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({ trigger_source: 'unknown' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MixpanelTelemetryProvider — topup delegation', () => {
|
||||
|
||||
@@ -18,10 +18,7 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionTriggerSource,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -92,7 +89,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
private mixpanel: OverridedMixpanel | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
|
||||
constructor() {
|
||||
@@ -300,7 +296,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
@@ -420,24 +415,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = getExecutionContext()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
|
||||
this.lastTriggerSource = undefined
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
@@ -102,7 +99,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pendingFirstAuthAt = new Map<string, string>()
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
private desktopEntryProps: DesktopEntryProps | null = null
|
||||
|
||||
@@ -400,7 +396,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
@@ -532,24 +527,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = getExecutionContext()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
|
||||
this.lastTriggerSource = undefined
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { name, previewUrl } = defineProps<{
|
||||
@@ -63,5 +63,5 @@ const imageOptions = computed(() => ({
|
||||
src: normalizedPreviewUrl.value ?? ''
|
||||
}))
|
||||
|
||||
const { isReady, isLoading, error } = useImage(imageOptions)
|
||||
const { isReady, isLoading, error } = useImageQuiet(imageOptions)
|
||||
</script>
|
||||
|
||||
@@ -38,6 +38,15 @@ describe('widgetStore', () => {
|
||||
store.registerCustomWidgets({ INT: override })
|
||||
expect(store.widgets.get('INT')).toBe(ComfyWidgets.INT)
|
||||
})
|
||||
|
||||
it('does not throw when an extension returns null/undefined widgets', () => {
|
||||
const store = useWidgetStore()
|
||||
// Regression: a misbehaving extension can resolve getCustomWidgets() to
|
||||
// nullish, which must not break app init. The `!` casts deliberately
|
||||
// violate the non-null parameter type to simulate that untrusted input.
|
||||
expect(() => store.registerCustomWidgets(undefined!)).not.toThrow()
|
||||
expect(() => store.registerCustomWidgets(null!)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputIsWidget', () => {
|
||||
|
||||
@@ -22,6 +22,11 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
function registerCustomWidgets(
|
||||
newWidgets: Record<string, ComfyWidgetConstructor>
|
||||
) {
|
||||
// Extensions are untrusted code: `getCustomWidgets` is typed to return
|
||||
// `Record<string, ...>`, but in practice an extension can resolve it to
|
||||
// null/undefined. Guard here so a single misbehaving custom node can't
|
||||
// throw "Cannot convert undefined or null to object" and break app init.
|
||||
if (!newWidgets) return
|
||||
for (const [type, widget] of Object.entries(newWidgets)) {
|
||||
customWidgets.value.set(type, widget)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user