Compare commits

..

1 Commits

Author SHA1 Message Date
bigcat88
fff5e4bd30 dev: /Combo-RemoteOptions [ci skip] 2026-03-31 16:45:09 +03:00
33 changed files with 552 additions and 847 deletions

View File

@@ -7,21 +7,18 @@ import { BuilderFooterHelper } from './BuilderFooterHelper'
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
import { BuilderSelectHelper } from './BuilderSelectHelper'
import { BuilderStepsHelper } from './BuilderStepsHelper'
import { MobileAppHelper } from './MobileAppHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly mobile: MobileAppHelper
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.mobile = new MobileAppHelper(comfyPage)
}
private get page(): Page {
@@ -86,10 +83,6 @@ export class AppModeHelper {
return this.page.locator('[data-testid="linear-widgets"]')
}
get centerPanel(): Locator {
return this.page.getByTestId(TestIds.linear.centerPanel)
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").

View File

@@ -11,10 +11,6 @@ export class BuilderSelectHelper {
return this.comfyPage.page
}
get selectedItems(): Locator {
return this.page.getByTestId(TestIds.builder.ioItem)
}
/**
* Get the actions menu trigger for a builder IoItem (input-select sidebar).
* @param title The widget title shown in the IoItem.

View File

@@ -1,41 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class MobileAppHelper {
constructor(private readonly comfyPage: ComfyPage) {}
get view(): Locator {
return this.page.getByTestId(TestIds.linear.mobile)
}
get navigation(): Locator {
return this.page.getByRole('tablist').filter({ hasText: 'Run' })
}
get workflows() {
return this.view.getByTestId(TestIds.linear.mobileWorkflows)
}
get actionmenu() {
return this.view.getByTestId(TestIds.linear.mobileActionMenu)
}
get navigationTabs() {
return this.navigation.getByRole('tab')
}
get contentPanel() {
return this.page.getByRole('tabpanel')
}
async switchWorkflow(workflowName: string) {
await this.workflows.click()
await this.page.getByRole('menu').getByText(workflowName).click()
}
async navigateTab(name: 'run' | 'outputs' | 'assets') {
await this.navigation.getByRole('tab', { name }).click()
}
private get page(): Page {
return this.comfyPage.page
}
async tap(locator: Locator, { count = 1 }: { count: number }) {
for (let i = 0; i < count; i++) await locator.tap()
}
}

View File

@@ -78,15 +78,6 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',
mobileNavigation: 'linear-mobile-navigation',
mobileActionMenu: 'linear-mobile-menu',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
widgetContainer: 'linear-widgets'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',

View File

@@ -32,10 +32,6 @@ export class VueNodeFixture {
return this.locator
}
get widgets(): Locator {
return this.locator.locator('.lg-node-widget')
}
async getTitle(): Promise<string> {
return (await this.title.textContent()) ?? ''
}

View File

@@ -1,82 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage }) => {
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel).toBeVisible()
//an app without an image input will load the workflow
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
await expect(centerPanel).not.toBeVisible()
//prep a load image
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png')
const loadImage = await comfyPage.vueNodes.getNodeLocator('12')
await expect(loadImage).toBeVisible()
await comfyPage.appMode.enterAppModeWithInputs([['12', 'image']])
await expect(centerPanel).toBeVisible()
//an app with an image input will upload the image to the input
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
await expect(centerPanel).toBeVisible()
//an app with an image input can load from a uri-source
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png')
await expect(centerPanel).toBeVisible()
})
test('Widget Interaction', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([
['4', 'seed'],
['4', 'sampler_name'],
['6', 'text']
])
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
await expect(mobile.view).toBeVisible()
await expect(mobile.navigation).toBeVisible()
await mobile.navigateTab('assets')
await expect(mobile.contentPanel).toContainClass('left-[200vw]')
const buttons = await mobile.navigationTabs.all()
await buttons[0].dragTo(buttons[2], { steps: 5 })
await expect(mobile.contentPanel).toContainClass('left-[100vw]')
await mobile.navigateTab('run')
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
const steps = comfyPage.page.getByRole('spinbutton')
await expect(steps).toHaveValue('20')
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'increment' }),
{ count: 5 }
)
await expect(steps).toHaveValue('25')
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'decrement' }),
{ count: 3 }
)
await expect(steps).toHaveValue('22')
})
test('workflow selection', async ({ comfyPage }) => {
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
for (const name of widgetNames)
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
const widgets = comfyPage.appMode.linearWidgets
await comfyPage.appMode.mobile.navigateTab('run')
for (let i = 0; i < widgetNames.length; i++) {
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
}
})
})
})

View File

@@ -1,115 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { VueNodeFixture } from '../fixtures/utils/vueNodeFixtures'
test.describe('App mode builder selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setFlag('linear_toggle_enabled', true)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Can independently select inputs of same name', async ({
comfyPage
}) => {
const items = comfyPage.appMode.select.selectedItems
await comfyPage.vueNodes.selectNodes(['6', '7'])
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
const prompts = await comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.locator('.lg-node-widget')
const count = await prompts.count()
for (let i = 0; i < count; i++) {
await expect(prompts.nth(i)).toBeVisible()
await prompts.nth(i).click()
await expect(items).toHaveCount(i + 1)
}
})
test('Can drag and drop inputs', async ({ comfyPage }) => {
const items = comfyPage.appMode.select.selectedItems
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getNodeLocator('3')
for (const widget of await ksampler.locator('.lg-node-widget').all())
await widget.click()
await items.first().dragTo(items.last(), { steps: 5 })
await expect(items.first()).toContainText('steps')
await items.last().dragTo(items.first(), { steps: 5 })
//dragTo doesn't cross the center point, so denoise is moved to position 2
await expect(items.nth(1)).toContainText('denoise')
})
test('Can select outputs', async ({ comfyPage }) => {
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToOutputs()
await comfyPage.nodeOps
.getNodeRefById('9')
.then((ref) => ref.centerOnNode())
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
await saveImage.click()
const items = comfyPage.appMode.select.selectedItems
await expect(items).toHaveCount(1)
})
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
const items = comfyPage.appMode.select.selectedItems
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.vueNodes
.getFixtureByTitle('Load Checkpoint')
.then((node) => node.widgets.click())
await expect.soft(items).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
for (const locator of await comfyPage.vueNodes.getNodeByTitle('Note').all())
await new VueNodeFixture(locator).widgets.click({ force: true })
await expect(items).toHaveCount(0)
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(comfyPage.searchBox.input).toHaveCount(0)
//space toggles panning mode, canvas should remain readOnly after pressing
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(comfyPage.searchBox.input).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.header.dblclick({ force: true })
expect(ksampler.titleInput).not.toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
})

View File

@@ -1,528 +0,0 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
const BYPASS_CLASS = /before:bg-bypass\/60/
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
await comfyPage.nextFrame()
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
await header.click()
await header.click({ button: 'right' })
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
return menu
}
async function openMultiNodeContextMenu(
comfyPage: ComfyPage,
titles: string[]
) {
// deselectAll via evaluate — clearSelection() clicks at a fixed position
// which can hit nodes or the toolbar overlay
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
await comfyPage.nextFrame()
for (const title of titles) {
const header = comfyPage.vueNodes
.getNodeByTitle(title)
.locator('.lg-node-header')
await header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstHeader = comfyPage.vueNodes
.getNodeByTitle(titles[0])
.locator('.lg-node-header')
const box = await firstHeader.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
box.y + box.height / 2,
{ button: 'right' }
)
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
return menu
}
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: nodeTitle })
.getByTestId('node-inner-wrapper')
}
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
return refs[0]
}
test.describe('Vue Node Context Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.vueNodes.waitForNodes()
})
test.describe('Single Node Actions', () => {
test('should rename node via context menu', async ({ comfyPage }) => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Rename')
const titleInput = comfyPage.page.locator(
'.node-title-editor input[type="text"]'
)
await titleInput.waitFor({ state: 'visible' })
await titleInput.fill('My Renamed Sampler')
await titleInput.press('Enter')
await comfyPage.nextFrame()
const renamedNode =
comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler')
await expect(renamedNode).toBeVisible()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openContextMenu(comfyPage, 'Load Checkpoint')
await clickExactMenuItem(comfyPage, 'Copy')
// Internal clipboard paste (menu Copy uses canvas clipboard, not OS)
await comfyPage.page.evaluate(() => {
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
})
test('should duplicate node via context menu', async ({ comfyPage }) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openContextMenu(comfyPage, 'Load Checkpoint')
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
})
test('should pin and unpin node via context menu', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
// Pin via context menu
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Pin')
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
expect(await nodeRef.isPinned()).toBe(true)
// Verify drag blocked
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
const posBeforeDrag = await header.boundingBox()
if (!posBeforeDrag) throw new Error('Header not found')
await comfyPage.canvasOps.dragAndDrop(
{ x: posBeforeDrag.x + 10, y: posBeforeDrag.y + 10 },
{ x: posBeforeDrag.x + 256, y: posBeforeDrag.y + 256 }
)
const posAfterDrag = await header.boundingBox()
expect(posAfterDrag).toEqual(posBeforeDrag)
// Unpin via context menu
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Unpin')
await expect(pinIndicator).not.toBeVisible()
expect(await nodeRef.isPinned()).toBe(false)
})
test('should bypass node and remove bypass via context menu', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
expect(await nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
expect(await nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
})
test('should minimize and expand node via context menu', async ({
comfyPage
}) => {
const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(fixture.body).toBeVisible()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Minimize Node')
await expect(fixture.body).not.toBeVisible()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Expand Node')
await expect(fixture.body).toBeVisible()
})
test('should convert node to subgraph via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('KSampler')
).not.toBeVisible()
})
})
test.describe('Image Node Actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes(1)
})
test('should copy image to clipboard via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
await clickExactMenuItem(comfyPage, 'Copy Image')
// Verify the clipboard contains an image
const hasImage = await comfyPage.page.evaluate(async () => {
const items = await navigator.clipboard.read()
return items.some((item) =>
item.types.some((t) => t.startsWith('image/'))
)
})
expect(hasImage).toBe(true)
})
test('should paste image to LoadImage node via context menu', async ({
comfyPage
}) => {
// Capture the original image src from the node's preview
const imagePreview = comfyPage.page.locator('.image-preview img')
const originalSrc = await imagePreview.getAttribute('src')
// Write a test image into the browser clipboard
await comfyPage.page.evaluate(async () => {
const resp = await fetch('/api/view?filename=example.png&type=input')
const blob = await resp.blob()
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob })
])
})
// Right-click and select Paste Image
await openContextMenu(comfyPage, 'Load Image')
await clickExactMenuItem(comfyPage, 'Paste Image')
// Verify the image preview src changed
await expect(imagePreview).not.toHaveAttribute('src', originalSrc!)
})
test('should open image in new tab via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
const popupPromise = comfyPage.page.waitForEvent('popup')
await clickExactMenuItem(comfyPage, 'Open Image')
const popup = await popupPromise
expect(popup.url()).toContain('/api/view')
expect(popup.url()).toContain('filename=')
await popup.close()
})
test('should download image via Save Image context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
const downloadPromise = comfyPage.page.waitForEvent('download')
await clickExactMenuItem(comfyPage, 'Save Image')
const download = await downloadPromise
expect(download.suggestedFilename()).toBeTruthy()
})
})
test.describe('Subgraph Actions', () => {
test('should convert to subgraph and unpack back', async ({
comfyPage
}) => {
// Convert KSampler to subgraph
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('KSampler')
).not.toBeVisible()
// Unpack the subgraph
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Unpack Subgraph')
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
).not.toBeVisible()
})
test('should open properties panel via Edit Subgraph Widgets', async ({
comfyPage
}) => {
// Convert to subgraph first
await openContextMenu(comfyPage, 'Empty Latent Image')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
await comfyPage.nextFrame()
// Right-click subgraph and edit widgets
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Edit Subgraph Widgets')
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('should add subgraph to library and find in node library', async ({
comfyPage
}) => {
// Convert to subgraph first
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
await comfyPage.nextFrame()
// Add to library
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Add Subgraph to Library')
// Fill the blueprint name
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
// Open node library sidebar and search for the blueprint
await comfyPage.page.getByRole('button', { name: 'Node Library' }).click()
await comfyPage.nextFrame()
const searchBox = comfyPage.page.getByRole('combobox', {
name: 'Search'
})
await searchBox.waitFor({ state: 'visible' })
await searchBox.fill('TestBlueprint')
await comfyPage.nextFrame()
await expect(comfyPage.page.getByText('TestBlueprint')).toBeVisible()
})
})
test.describe('Multi-Node Actions', () => {
const nodeTitles = ['Load Checkpoint', 'KSampler']
test('should batch rename selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Rename')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('MyNode')
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 1')).toBeVisible()
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 2')).toBeVisible()
})
test('should copy and paste selected nodes via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Copy')
await comfyPage.page.evaluate(() => {
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
})
test('should duplicate selected nodes via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
})
test('should pin and unpin selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Pin')
for (const title of nodeTitles) {
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
}
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Unpin')
for (const title of nodeTitles) {
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).not.toBeVisible()
}
})
test('should bypass and remove bypass on selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Bypass')
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)
}
})
test('should minimize and expand selected nodes via context menu', async ({
comfyPage
}) => {
const fixture1 =
await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const fixture2 = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(fixture1.body).toBeVisible()
await expect(fixture2.body).toBeVisible()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Minimize Node')
await expect(fixture1.body).not.toBeVisible()
await expect(fixture2.body).not.toBeVisible()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Expand Node')
await expect(fixture1.body).toBeVisible()
await expect(fixture2.body).toBeVisible()
})
test('should frame selected nodes via context menu', async ({
comfyPage
}) => {
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
)
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Frame Nodes')
const newGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
)
expect(newGroupCount).toBe(initialGroupCount + 1)
})
test('should convert to group node via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
'workflow>TestGroupNode'
)
expect(groupNodes.length).toBe(1)
})
test('should convert selected nodes to subgraph via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount - nodeTitles.length + 1
)
})
})
})

View File

@@ -363,7 +363,7 @@ Test your feature flags with different combinations:
### Example Test
```typescript
// Example from a colocated unit test
// In tests-ui/tests/api.featureFlags.test.ts
it('should handle preview metadata based on feature flag', () => {
// Mock server supports feature
api.serverFeatureFlags = { supports_preview_metadata: true }

View File

@@ -17,7 +17,7 @@ This guide covers patterns and examples for testing Pinia stores in the ComfyUI
Basic setup for testing Pinia stores:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -51,7 +51,7 @@ describe('useWorkflowStore', () => {
Testing store state changes:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
it('should create a temporary workflow with a unique path', () => {
const workflow = store.createTemporary()
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
@@ -72,7 +72,7 @@ it('should create a temporary workflow not clashing with persisted workflows', a
Testing store actions:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('openWorkflow', () => {
it('should load and open a temporary workflow', async () => {
// Create a test workflow
@@ -115,7 +115,7 @@ describe('openWorkflow', () => {
Testing store getters:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/modelStore.test.ts
describe('getters', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -162,7 +162,7 @@ describe('getters', () => {
Mocking API and other dependencies:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
@@ -205,7 +205,7 @@ describe('syncWorkflows', () => {
Testing store watchers and reactive behavior:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { nextTick } from 'vue'
describe('Subgraphs', () => {
@@ -253,7 +253,7 @@ describe('Subgraphs', () => {
Testing store integration with other parts of the application:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')

View File

@@ -18,7 +18,7 @@ This guide covers patterns and examples for unit testing utilities, composables,
Testing Vue composables requires handling reactivity correctly:
```typescript
// Example from a colocated composable unit test
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useServerLogs } from '@/composables/useServerLogs'
@@ -59,7 +59,7 @@ describe('useServerLogs', () => {
Testing LiteGraph-related functionality:
```typescript
// Example from a colocated LiteGraph unit test
// Example from: tests-ui/tests/litegraph.test.ts
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph'
import { describe, expect, it } from 'vitest'
@@ -93,7 +93,7 @@ describe('LGraph', () => {
Testing with ComfyUI workflow files:
```typescript
// Example from a colocated workflow unit test
// Example from: tests-ui/tests/comfyWorkflow.test.ts
import { describe, expect, it } from 'vitest'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
@@ -125,7 +125,7 @@ describe('workflow validation', () => {
Mocking the ComfyUI API object:
```typescript
// Example from a colocated composable unit test
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
import { describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
@@ -183,7 +183,7 @@ describe('Function using debounce', () => {
When you need to test real debounce/throttle behavior:
```typescript
// Example from a colocated composable unit test
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('debounced function', () => {
@@ -223,7 +223,7 @@ describe('debounced function', () => {
Creating mock node definitions for testing:
```typescript
// Example from a colocated schema unit test
// Example from: tests-ui/tests/apiTypes.test.ts
import { describe, expect, it } from 'vitest'
import {
type ComfyNodeDef,

View File

@@ -230,6 +230,15 @@ export default defineConfig([
]
}
},
{
files: ['tests-ui/**/*'],
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false }
]
}
},
{
files: ['**/*.spec.ts'],
ignores: ['browser_tests/tests/**/*.spec.ts'],

View File

@@ -139,6 +139,43 @@ describe('useFeatureFlags', () => {
})
})
describe('linearToggleEnabled', () => {
it('should return true when isNightly is true', () => {
vi.mocked(distributionTypes).isNightly = true
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
})
it('should check remote config and server feature when isNightly is false', () => {
vi.mocked(distributionTypes).isNightly = false
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.LINEAR_TOGGLE_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
false
)
})
it('should return false when isNightly is false and flag is disabled', () => {
vi.mocked(distributionTypes).isNightly = false
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(false)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -19,6 +19,7 @@ export enum ServerFeatureFlag {
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
@@ -83,6 +84,15 @@ export function useFeatureFlags() {
false
)
},
get linearToggleEnabled() {
if (isNightly) return true
return resolveFlag(
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
remoteConfig.value.linear_toggle_enabled,
false
)
},
/**
* Whether team workspaces feature is enabled.
* IMPORTANT: Returns false until authenticated remote config is loaded.

View File

@@ -36,6 +36,10 @@ const mockSubgraphStore = vi.hoisted(() => ({
isSubgraphBlueprint: vi.fn(() => false)
}))
const mockMenuItemStore = vi.hoisted(() => ({
hasSeenLinear: false
}))
const mockAppModeStore = vi.hoisted(() => ({
enterBuilder: vi.fn(),
pruneLinearData: vi.fn(
@@ -74,6 +78,10 @@ vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: vi.fn(() => mockSubgraphStore)
}))
vi.mock('@/stores/menuItemStore', () => ({
useMenuItemStore: vi.fn(() => mockMenuItemStore)
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: vi.fn(() => mockAppModeStore)
}))
@@ -108,6 +116,7 @@ describe('useWorkflowActionsMenu', () => {
vi.clearAllMocks()
mockBookmarkStore.isBookmarked.mockReturnValue(false)
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false)
mockMenuItemStore.hasSeenLinear = false
mockFeatureFlags.flags.linearToggleEnabled = false
mockAppModeStore.selectedInputs.length = 0
mockAppModeStore.selectedOutputs.length = 0
@@ -152,13 +161,43 @@ describe('useWorkflowActionsMenu', () => {
expect(labels).not.toContain('breadcrumbsMenu.deleteWorkflow')
})
it('shows app mode items', () => {
it('shows app mode items when linearToggleEnabled flag is set', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
})
it('shows app mode items when user has seen linear mode', () => {
mockMenuItemStore.hasSeenLinear = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
})
it('hides app mode items when conditions not met', () => {
mockMenuItemStore.hasSeenLinear = false
mockFeatureFlags.flags.linearToggleEnabled = false
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('hides app mode items when not root', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: false })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('shows "go to workflow mode" when in linear mode', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {

View File

@@ -13,6 +13,7 @@ import {
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useAppModeStore } from '@/stores/appModeStore'
import type {
@@ -51,6 +52,7 @@ export function useWorkflowActionsMenu(
const bookmarkStore = useWorkflowBookmarkStore()
const commandStore = useCommandStore()
const subgraphStore = useSubgraphStore()
const menuItemStore = useMenuItemStore()
const { flags } = useFeatureFlags()
const appModeStore = useAppModeStore()
const { enterBuilder, pruneLinearData } = appModeStore
@@ -96,6 +98,8 @@ export function useWorkflowActionsMenu(
const workflowMode =
workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
const isLinearMode = workflowMode === 'app'
const showAppModeItems =
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
const toggleLinear = async () => {
@@ -202,7 +206,7 @@ export function useWorkflowActionsMenu(
label: t('breadcrumbsMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
command: toggleLinear,
visible: isRoot && !isLinearMode,
visible: showAppModeItems && !isLinearMode,
prependSeparator: true,
isNew: true
})
@@ -241,7 +245,7 @@ export function useWorkflowActionsMenu(
await ensureWorkflowActive(targetWorkflow.value)
enterBuilder()
},
visible: isRoot,
visible: showAppModeItems,
isNew: true
})

View File

@@ -154,24 +154,20 @@ const menuEntries = computed<MenuItem[]>(() => [
])
</script>
<template>
<section
class="absolute flex size-full flex-col bg-secondary-background"
data-testid="linear-mobile"
>
<section class="absolute flex size-full flex-col bg-secondary-background">
<header
class="flex h-16 w-full items-center gap-3 border-b border-border-subtle bg-base-background px-4 py-3"
>
<DropdownMenu :entries="menuEntries" />
<DropdownMenu
:entries="workflowsEntries"
class="max-h-[40vh] w-(--reka-dropdown-menu-content-available-width) overflow-y-auto"
class="max-h-[40vh] w-(--reka-dropdown-menu-content-available-width)"
:collision-padding="20"
>
<template #button>
<!--TODO: Use button here? Probably too much work to destyle-->
<div
class="flex h-10 grow items-center gap-2 rounded-sm bg-secondary-background p-2"
data-testid="linear-mobile-workflows"
>
<i
class="icon-[lucide--panels-top-left] shrink-0 bg-primary-background"
@@ -195,17 +191,11 @@ const menuEntries = computed<MenuItem[]>(() => [
"
:style="{ translate }"
>
<div
class="absolute h-full w-screen overflow-y-auto contain-size"
role="tabpanel"
:aria-hidden="activeIndex !== 0"
>
<div class="absolute h-full w-screen overflow-y-auto contain-size">
<LinearControls mobile @navigate-outputs="activeIndex = 1" />
</div>
<div
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"
role="tabpanel"
:aria-hidden="activeIndex !== 1"
>
<MobileError
v-if="executionErrorStore.isErrorOverlayOpen"
@@ -215,23 +205,18 @@ const menuEntries = computed<MenuItem[]>(() => [
</div>
<AssetsSidebarTab
class="absolute top-0 left-[200vw] h-full w-screen bg-base-background"
role="tabpanel"
:aria-hidden="activeIndex !== 2"
/>
</div>
</div>
<div
ref="sliderPaneRef"
class="flex h-22 w-full items-center justify-around gap-4 bg-secondary-background p-4"
role="tablist"
>
<Button
v-for="([label, icon], index) in tabs"
:key="label"
:variant="index === activeIndex ? 'secondary' : 'muted-textonly'"
class="h-14 grow flex-col"
role="tab"
:aria-selected="index === activeIndex"
@click="onClick(index)"
>
<div class="relative size-4">

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FilterOption } from '@/platform/assets/types/filterTypes'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
import { AssetKindKey } from './form/dropdown/types'
import {
buildSearchText,
extractFilterValues,
getByPath,
mapToDropdownItem
} from '../utils/resolveItemSchema'
import { fetchRemoteRoute } from '../utils/resolveRemoteRoute'
const props = defineProps<{
modelValue?: string
widget: SimplifiedWidget<string | undefined>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
const comboSpec = computed(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
return props.widget.spec
}
return undefined
})
const remoteConfig = computed(() => comboSpec.value?.remote!)
const itemSchema = computed(() => remoteConfig.value?.item_schema!)
const rawItems = ref<unknown[]>([])
const loading = ref(false)
async function fetchItems() {
loading.value = true
try {
const res = await fetchRemoteRoute(remoteConfig.value.route, {
params: remoteConfig.value.query_params,
timeout: remoteConfig.value.timeout ?? 30000
})
const data = remoteConfig.value.response_key
? res.data[remoteConfig.value.response_key]
: res.data
rawItems.value = Array.isArray(data) ? data : []
} catch (err) {
console.error('RichComboWidget: fetch error', err)
} finally {
loading.value = false
}
}
onMounted(() => {
void fetchItems()
})
const assetKind = computed(() => {
const pt = itemSchema.value.preview_type ?? 'image'
return pt as 'image' | 'video' | 'audio'
})
provide(AssetKindKey, assetKind)
const items = computed<FormDropdownItem[]>(() =>
rawItems.value.map((raw) => mapToDropdownItem(raw, itemSchema.value))
)
const searchIndex = computed(() => {
const schema = itemSchema.value
const fields = schema.search_fields ?? [schema.label_field]
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
index.set(id, buildSearchText(raw, fields))
}
return index
})
const filterOptions = computed<FilterOption[]>(() => {
const schema = itemSchema.value
if (!schema.filter_field) return []
const values = extractFilterValues(rawItems.value, schema.filter_field)
return [
{ name: 'All', value: 'all' },
...values.map((v) => ({ name: v, value: v }))
]
})
const filterSelected = ref('all')
const layoutMode = ref<LayoutMode>('list')
const selectedSet = ref<Set<string>>(new Set())
const filteredItems = computed<FormDropdownItem[]>(() => {
const schema = itemSchema.value
if (filterSelected.value === 'all' || !schema.filter_field) {
return items.value
}
const filterField = schema.filter_field
return rawItems.value
.filter(
(raw) =>
String(getByPath(raw, filterField) ?? '') === filterSelected.value
)
.map((raw) => mapToDropdownItem(raw, schema))
})
async function searcher(
query: string,
searchItems: FormDropdownItem[],
_onCleanup: (cleanupFn: () => void) => void
): Promise<FormDropdownItem[]> {
if (!query.trim()) return searchItems
const q = query.toLowerCase()
return searchItems.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
}
watch(
[() => props.modelValue, items],
([val]) => {
selectedSet.value.clear()
if (val) {
const item = items.value.find((i) => i.id === val)
if (item) selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
function handleRefresh() {
void fetchItems()
}
function handleSelection(selected: Set<string>) {
const id = selected.values().next().value
if (id) {
emit('update:modelValue', id)
}
}
</script>
<template>
<div class="flex w-full items-center gap-1">
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
:items="filteredItems"
:placeholder="loading ? 'Loading...' : t('widgets.uploadSelect.placeholder')"
:multiple="false"
:filter-options="[]"
:show-sort="false"
:show-layout-switcher="false"
:searcher="searcher"
class="flex-1"
@update:selected="handleSelection"
/>
<button
v-if="remoteConfig?.refresh_button !== false"
class="flex size-7 shrink-0 items-center justify-center rounded text-secondary hover:bg-component-node-widget-background-hovered"
title="Refresh"
@pointerdown.stop
@click.stop="handleRefresh"
>
<i
:class="[
'icon-[lucide--refresh-cw] size-3.5',
loading && 'animate-spin'
]"
/>
</button>
</div>
</template>

View File

@@ -1,6 +1,11 @@
<template>
<RichComboWidget
v-if="hasItemSchema"
v-model="modelValue"
:widget
/>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-else-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -24,6 +29,7 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -53,6 +59,10 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasItemSchema = computed(
() => !!comboSpec.value?.remote?.item_schema
)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -34,6 +34,8 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -61,6 +63,8 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -232,6 +236,8 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher="showLayoutSwitcher"
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -20,6 +20,8 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -31,6 +33,8 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -112,6 +116,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher="showLayoutSwitcher"
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -145,6 +151,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -18,8 +18,13 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
defineProps<{
const {
showSort = true,
showLayoutSwitcher = true
} = defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -114,6 +119,7 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
variant="textonly"
size="icon"
@@ -132,6 +138,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -309,6 +316,7 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -12,6 +12,7 @@ interface Props {
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}
@@ -27,11 +28,31 @@ const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isAudio = computed(() => assetKind?.value === 'audio')
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
if (!audioRef.value) return
if (isPlayingAudio.value) {
audioRef.value.pause()
isPlayingAudio.value = false
} else {
void audioRef.value.play()
isPlayingAudio.value = true
}
}
function handleAudioEnded() {
isPlayingAudio.value = false
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -107,6 +128,25 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<div
v-else-if="previewUrl && isAudio"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-tr from-violet-500 via-purple-500 to-fuchsia-400"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@ended="handleAudioEnded"
/>
<i
:class="
isPlayingAudio
? 'icon-[lucide--pause] size-5 text-white'
: 'icon-[lucide--play] size-5 text-white'
"
/>
</div>
<img
v-else-if="previewUrl"
:src="previewUrl"
@@ -144,6 +184,13 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,7 +12,9 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Preview image/video URL */
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean

View File

@@ -214,7 +214,9 @@ const addComboWidget = (
}
)
if (inputSpec.remote) {
if (inputSpec.remote && !inputSpec.remote.item_schema) {
// Skip useRemoteWidget when item_schema is present —
// RichComboWidget handles its own data fetching and rendering.
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}

View File

@@ -2,10 +2,12 @@ import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
import {
getRemoteAuthHeaders,
resolveRoute
} from '../utils/resolveRemoteRoute'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -21,17 +23,6 @@ interface CacheEntry<T> {
failed?: boolean
}
async function getAuthHeaders() {
if (isCloud) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
return {
...(authHeader && { headers: authHeader })
}
}
return {}
}
const dataCache = new Map<string, CacheEntry<unknown>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
@@ -73,9 +64,10 @@ const fetchData = async (
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const authHeaders = await getAuthHeaders()
const url = resolveRoute(route)
const authHeaders = await getRemoteAuthHeaders(route)
const res = await axios.get(route, {
const res = await axios.get(url, {
params: query_params,
signal: controller.signal,
timeout,

View File

@@ -0,0 +1,70 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
/** Traverse an object by dot-path, treating numeric segments as array indices */
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
/** Resolve a label — either dot-path or template with {field.path} placeholders */
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
/** Map a raw API object to a FormDropdownItem using the item_schema */
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema
): FormDropdownItem {
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
}
}
/** Extract items array from full API response using response_key */
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : []
}
/** Build search text for an item from the specified search fields */
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}
/** Extract unique filter values from items */
export function extractFilterValues(
items: unknown[],
filterField: string
): string[] {
const values = new Set<string>()
for (const item of items) {
const value = getByPath(item, filterField)
if (value != null) values.add(String(value))
}
return Array.from(values).sort()
}

View File

@@ -0,0 +1,58 @@
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useAuthStore } from '@/stores/authStore'
/**
* Check if a route is a comfy-api proxy route.
* These routes need the comfy-api base URL prepended and always require auth.
*/
function isProxyRoute(route: string): boolean {
return route.startsWith('/proxy/')
}
/**
* Resolve a RemoteOptions route to a full URL.
* - "/proxy/..." routes → prepend getComfyApiBaseUrl()
* - Everything else → use as-is
*/
export function resolveRoute(route: string): string {
if (isProxyRoute(route)) {
return getComfyApiBaseUrl() + route
}
return route
}
/**
* Get auth headers for a remote request.
* - "/proxy/..." routes → ALWAYS inject auth (comfy-api requires it)
* - Other routes → only inject auth in cloud mode
*/
export async function getRemoteAuthHeaders(
route: string
): Promise<Record<string, any>> {
if (isProxyRoute(route)) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
if (authHeader) {
return { headers: authHeader }
}
}
return {}
}
/**
* Convenience: make an authenticated GET request to a remote route.
*/
export async function fetchRemoteRoute(
route: string,
options: {
params?: Record<string, string>
timeout?: number
signal?: AbortSignal
} = {}
) {
const url = resolveRoute(route)
const authHeaders = await getRemoteAuthHeaders(route)
return axios.get(url, { ...options, ...authHeaders })
}

View File

@@ -5,6 +5,16 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional(),
filter_field: z.string().optional()
})
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -13,7 +23,8 @@ const zRemoteWidgetConfig = z.object({
refresh_button: z.boolean().optional(),
control_after_refresh: z.enum(['first', 'last']).optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
max_retries: z.number().gte(0).optional(),
item_schema: zRemoteItemSchema.optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
@@ -354,6 +365,7 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>

View File

@@ -1,16 +1,26 @@
import { whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { ComfyExtension } from '@/types/comfy'
import { useCommandStore } from './commandStore'
export const useMenuItemStore = defineStore('menuItem', () => {
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const menuItems = ref<MenuItem[]>([])
const menuItemHasActiveStateChildren = ref<Record<string, boolean>>({})
const hasSeenLinear = ref(false)
whenever(
() => canvasStore.linearMode,
() => (hasSeenLinear.value = true),
{ immediate: true, once: true }
)
const registerMenuGroup = (path: string[], items: MenuItem[]) => {
let currentLevel = menuItems.value
@@ -103,6 +113,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
loadExtensionMenuCommands,
registerCoreMenuCommands,
menuItemHasActiveStateChildren,
hasSeenLinear,
commandIdToMenuItem
}
})

View File

@@ -149,7 +149,6 @@ function dragDrop(e: DragEvent) {
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
data-testid="linear-center-panel"
:size="CENTER_PANEL_SIZE"
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
@drop="dragDrop"

View File

@@ -26,7 +26,8 @@
],
"@/utils/networkUtil": [
"./packages/shared-frontend-utils/src/networkUtil.ts"
]
],
"@tests-ui/*": ["./tests-ui/*"]
},
"typeRoots": ["src/types", "node_modules/@types", "./node_modules"],
"types": [
@@ -48,6 +49,8 @@
"src/types/**/*.d.ts",
"playwright.config.ts",
"playwright.i18n.config.ts",
"tests-ui/**/*",
"vite.config.mts",
"vitest.config.ts"
// "vitest.setup.ts",

View File

@@ -161,6 +161,7 @@ export default defineConfig({
ignored: [
'./browser_tests/**',
'./node_modules/**',
'./tests-ui/**',
'.eslintcache',
'.oxlintrc.json',
'*.config.{ts,mts}',