mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-01 10:09:08 +00:00
Compare commits
1 Commits
test/setti
...
dev/remote
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff5e4bd30 |
@@ -119,7 +119,7 @@ When writing new tests, follow these patterns:
|
||||
|
||||
```typescript
|
||||
// Import the test fixture
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
// Set up test environment if needed
|
||||
@@ -148,12 +148,6 @@ Always check for existing helpers and fixtures before implementing new ones:
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
### Import Conventions
|
||||
|
||||
- Prefer `@e2e/*` for imports within `browser_tests/`
|
||||
- Continue using `@/*` for imports from `src/`
|
||||
- Avoid introducing new deep relative imports within `browser_tests/` when the alias is available
|
||||
|
||||
### Key Testing Patterns
|
||||
|
||||
1. **Focus elements explicitly**:
|
||||
|
||||
@@ -2,41 +2,42 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { sleep } from './utils/timing'
|
||||
import { comfyExpect } from './utils/customMatchers'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
|
||||
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
|
||||
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
|
||||
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
|
||||
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyMouse } from './ComfyMouse'
|
||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import { QueuePanel } from './components/QueuePanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
|
||||
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
|
||||
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
|
||||
import { WorkflowHelper } from '@e2e/fixtures/helpers/WorkflowHelper'
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { AssetsHelper } from './helpers/AssetsHelper'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
import { SettingsHelper } from './helpers/SettingsHelper'
|
||||
import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import { assetPath } from './utils/paths'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
@@ -199,6 +200,7 @@ export class ComfyPage {
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -246,6 +248,7 @@ export class ComfyPage {
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
|
||||
@@ -30,10 +30,6 @@ export class BuilderFooterHelper {
|
||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
||||
}
|
||||
|
||||
get saveGroup(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveGroup)
|
||||
}
|
||||
|
||||
get saveAsButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
}
|
||||
|
||||
79
browser_tests/fixtures/helpers/QueueHelper.ts
Normal file
79
browser_tests/fixtures/helpers/QueueHelper.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export class QueueHelper {
|
||||
private queueRouteHandler: ((route: Route) => void) | null = null
|
||||
private historyRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Mock the /api/queue endpoint to return specific queue state.
|
||||
*/
|
||||
async mockQueueState(
|
||||
running: number = 0,
|
||||
pending: number = 0
|
||||
): Promise<void> {
|
||||
this.queueRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
queue_running: Array.from({ length: running }, (_, i) => [
|
||||
i,
|
||||
`running-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
]),
|
||||
queue_pending: Array.from({ length: pending }, (_, i) => [
|
||||
i,
|
||||
`pending-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
])
|
||||
})
|
||||
})
|
||||
await this.page.route('**/api/queue', this.queueRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the /api/history endpoint with completed/failed job entries.
|
||||
*/
|
||||
async mockHistory(
|
||||
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
|
||||
): Promise<void> {
|
||||
const history: Record<string, unknown> = {}
|
||||
for (const job of jobs) {
|
||||
history[job.promptId] = {
|
||||
prompt: [0, job.promptId, {}, {}, []],
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: job.status === 'success' ? 'success' : 'error',
|
||||
completed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
this.historyRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(history)
|
||||
})
|
||||
await this.page.route('**/api/history**', this.historyRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all route mocks set by this helper.
|
||||
*/
|
||||
async clearMocks(): Promise<void> {
|
||||
if (this.queueRouteHandler) {
|
||||
await this.page.unroute('**/api/queue', this.queueRouteHandler)
|
||||
this.queueRouteHandler = null
|
||||
}
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute('**/api/history**', this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,6 @@ export const TestIds = {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
saveAsButton: 'builder-save-as-button',
|
||||
saveGroup: 'builder-save-group',
|
||||
saveAsChevron: 'builder-save-as-chevron',
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
export interface NodeSlotData {
|
||||
nodeId: string
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect slot center offsets relative to the parent node element.
|
||||
* Returns `null` when the node element is not found.
|
||||
*/
|
||||
export async function measureNodeSlotOffsets(
|
||||
page: Page,
|
||||
nodeId: string
|
||||
): Promise<NodeSlotData | null> {
|
||||
return page.evaluate((id) => {
|
||||
const nodeEl = document.querySelector(`[data-node-id="${id}"]`)
|
||||
if (!nodeEl || !(nodeEl instanceof HTMLElement)) return null
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
slots.push({
|
||||
key: (slotEl as HTMLElement).dataset.slotKey ?? 'unknown',
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: id,
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
}
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that every slot falls within the node dimensions (± `margin` px).
|
||||
*/
|
||||
export function expectSlotsWithinBounds(
|
||||
data: NodeSlotData,
|
||||
margin: number,
|
||||
label?: string
|
||||
) {
|
||||
const prefix = label ? `${label}: ` : ''
|
||||
|
||||
for (const slot of data.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`${prefix}Slot ${slot.key} X=${slot.offsetX} outside width=${data.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-margin)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`${prefix}Slot ${slot.key} X=${slot.offsetX} outside width=${data.nodeW}`
|
||||
).toBeLessThanOrEqual(data.nodeW + margin)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`${prefix}Slot ${slot.key} Y=${slot.offsetY} outside height=${data.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-margin)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`${prefix}Slot ${slot.key} Y=${slot.offsetY} outside height=${data.nodeH}`
|
||||
).toBeLessThanOrEqual(data.nodeH + margin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for slots, measure, and assert within bounds — single-node convenience.
|
||||
*/
|
||||
export async function assertNodeSlotsWithinBounds(
|
||||
page: Page,
|
||||
nodeId: string,
|
||||
margin: number = 20
|
||||
) {
|
||||
await page
|
||||
.locator(`[data-node-id="${nodeId}"] [data-slot-key]`)
|
||||
.first()
|
||||
.waitFor()
|
||||
|
||||
const data = await measureNodeSlotOffsets(page, nodeId)
|
||||
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
|
||||
expectSlotsWithinBounds(data!, margin, `Node ${nodeId}`)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
@@ -189,41 +189,6 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save button width is consistent across all states', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
// State 1: Disabled "Save as" (no outputs selected)
|
||||
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
expect(disabledBox).toBeTruthy()
|
||||
|
||||
// Select I/O to enable the button
|
||||
await appMode.steps.goToInputs()
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await appMode.select.selectInputWidget(ksampler)
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
// State 2: Enabled "Save as" (unsaved, has outputs)
|
||||
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
expect(enabledBox).toBeTruthy()
|
||||
expect(enabledBox!.width).toBe(disabledBox!.width)
|
||||
|
||||
// Save the workflow to transition to the Save + chevron state
|
||||
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// State 3: Save + chevron button group (saved workflow)
|
||||
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
|
||||
expect(saveButtonGroupBox).toBeTruthy()
|
||||
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { assertNodeSlotsWithinBounds } from '../fixtures/utils/slotBoundsUtil'
|
||||
|
||||
const NODE_ID = '3'
|
||||
const NODE_TITLE = 'KSampler'
|
||||
|
||||
test.describe(
|
||||
'Collapsed node link positions',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('link endpoints stay within collapsed node bounds', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
|
||||
test('links follow collapsed node after drag', async ({ comfyPage }) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2 + 200,
|
||||
box!.y + box!.height / 2 + 100,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
|
||||
test('links recover correct positions after expand', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,141 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
test('About panel shows version badges', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
await dialog.goToAboutPanel()
|
||||
|
||||
const aboutPanel = comfyPage.page.getByTestId('about-panel')
|
||||
await expect(aboutPanel).toBeVisible()
|
||||
await expect(aboutPanel.locator('.about-badge').first()).toBeVisible()
|
||||
await expect(aboutPanel).toContainText('ComfyUI_frontend')
|
||||
})
|
||||
|
||||
test('Toggling a boolean setting through UI persists the value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const settingId = 'Comfy.Validation.Workflows'
|
||||
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
|
||||
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
await dialog.searchBox.fill('Validate workflows')
|
||||
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
|
||||
await expect(settingRow).toBeVisible()
|
||||
|
||||
await settingRow.locator('.p-toggleswitch').click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
})
|
||||
|
||||
test('Can be closed via close button', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
await expect(dialog.root).toBeVisible()
|
||||
|
||||
await dialog.close()
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Escape key closes dialog', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
await expect(dialog.root).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Search filters settings list', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
const settingItems = dialog.root.locator('[data-setting-id]')
|
||||
const countBeforeSearch = await settingItems.count()
|
||||
|
||||
await dialog.searchBox.fill('Validate workflows')
|
||||
await expect
|
||||
.poll(() => settingItems.count())
|
||||
.toBeLessThan(countBeforeSearch)
|
||||
})
|
||||
|
||||
test('Search can be cleared to restore all settings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
const settingItems = dialog.root.locator('[data-setting-id]')
|
||||
const countBeforeSearch = await settingItems.count()
|
||||
|
||||
await dialog.searchBox.fill('Validate workflows')
|
||||
await expect
|
||||
.poll(() => settingItems.count())
|
||||
.toBeLessThan(countBeforeSearch)
|
||||
|
||||
await dialog.searchBox.clear()
|
||||
await expect.poll(() => settingItems.count()).toBe(countBeforeSearch)
|
||||
})
|
||||
|
||||
test('Category navigation changes content area', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
const firstCategory = dialog.categories.first()
|
||||
const firstCategoryName = await firstCategory.textContent()
|
||||
await firstCategory.click()
|
||||
const firstContent = await dialog.contentArea.textContent()
|
||||
|
||||
// Find a different category to click
|
||||
const categoryCount = await dialog.categories.count()
|
||||
for (let i = 1; i < categoryCount; i++) {
|
||||
const cat = dialog.categories.nth(i)
|
||||
const catName = await cat.textContent()
|
||||
if (catName !== firstCategoryName) {
|
||||
await cat.click()
|
||||
await expect
|
||||
.poll(() => dialog.contentArea.textContent())
|
||||
.not.toBe(firstContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Dropdown setting can be changed and persists', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const settingId = 'Comfy.UseNewMenu'
|
||||
const initialValue = await comfyPage.settings.getSetting<string>(settingId)
|
||||
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
await dialog.searchBox.fill('Use new menu')
|
||||
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
|
||||
await expect(settingRow).toBeVisible()
|
||||
|
||||
// Click the PrimeVue Select to open the dropdown
|
||||
await settingRow.locator('.p-select').click()
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay')
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
// Pick the option that is not the current value
|
||||
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
|
||||
await overlay
|
||||
.locator(`.p-select-option-label:text-is("${targetValue}")`)
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<string>(settingId))
|
||||
.toBe(targetValue)
|
||||
|
||||
// Restore original value
|
||||
await comfyPage.settings.setSetting(settingId, initialValue)
|
||||
})
|
||||
})
|
||||
@@ -527,27 +527,20 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
// Multi-select: click first, then Ctrl/Cmd+click second
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
// Right-click on a selected card (retry to let grid layout settle)
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
await expect(async () => {
|
||||
await cards.first().click({ button: 'right' })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
}).toPass({ intervals: [300], timeout: 5000 })
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
|
||||
@@ -7,10 +7,6 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import {
|
||||
expectSlotsWithinBounds,
|
||||
measureNodeSlotOffsets
|
||||
} from '../../fixtures/utils/slotBoundsUtil'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
@@ -23,6 +19,20 @@ const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -594,19 +604,71 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const nodeIds = await comfyPage.page.evaluate(() =>
|
||||
window
|
||||
.app!.graph._nodes.filter((n) => !!n.isSubgraphNode?.())
|
||||
.map((n) => String(n.id))
|
||||
)
|
||||
expect(nodeIds.length).toBeGreaterThan(0)
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const data = await measureNodeSlotOffsets(comfyPage.page, nodeId)
|
||||
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
|
||||
expectSlotsWithinBounds(data!, SLOT_BOUNDS_MARGIN, `Node ${nodeId}`)
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -323,174 +323,6 @@ test.describe('Workflow Persistence', () => {
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Closing an inactive tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B: duplicate and save
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameB)
|
||||
|
||||
// Add a Note node in B to mark it as modified
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(nodeCountA + 1)
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive B tab via middle-click — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
|
||||
button: 'middle'
|
||||
})
|
||||
|
||||
// Click "Save" in the dirty close dialog
|
||||
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have the extra Note node we added, not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Closing an inactive unsaved tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B as an unsaved workflow with a Note node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(1)
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
// Switch to A via topbar tab (making unsaved B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive unsaved B tab — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar
|
||||
.getWorkflowTab('Unsaved Workflow')
|
||||
.click({ button: 'middle' })
|
||||
|
||||
// Click "Save" in the dirty close dialog (scoped to dialog)
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
|
||||
// Fill in the filename dialog
|
||||
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
|
||||
await saveDialog.waitFor({ state: 'visible' })
|
||||
await saveDialog.fill(nameB)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have 1 node (the Note), not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.11",
|
||||
"version": "1.43.10",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -140,7 +140,6 @@
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/user-event": "catalog:",
|
||||
"@testing-library/vue": "catalog:",
|
||||
"@total-typescript/shoehorn": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/jsdom": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -135,9 +135,6 @@ catalogs:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^2.27.2
|
||||
version: 2.27.2
|
||||
'@total-typescript/shoehorn':
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
'@types/fs-extra':
|
||||
specifier: ^11.0.4
|
||||
version: 11.0.4
|
||||
@@ -654,9 +651,6 @@ importers:
|
||||
'@testing-library/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 8.1.0(@vue/compiler-sfc@3.5.28)(vue@3.5.13(typescript@5.9.3))
|
||||
'@total-typescript/shoehorn':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.2
|
||||
'@types/fs-extra':
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.4
|
||||
@@ -4280,9 +4274,6 @@ packages:
|
||||
'@tmcp/auth':
|
||||
optional: true
|
||||
|
||||
'@total-typescript/shoehorn@0.1.2':
|
||||
resolution: {integrity: sha512-p7nNZbOZIofpDNyP0u1BctFbjxD44Qc+oO5jufgQdFdGIXJLc33QRloJpq7k5T59CTgLWfQSUxsuqLcmeurYRw==}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3':
|
||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||
|
||||
@@ -13317,8 +13308,6 @@ snapshots:
|
||||
esm-env: 1.2.2
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
|
||||
'@total-typescript/shoehorn@0.1.2': {}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
|
||||
@@ -46,7 +46,6 @@ catalog:
|
||||
'@tiptap/extension-table-row': ^2.27.2
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@tiptap/starter-kit': ^2.27.2
|
||||
'@total-typescript/shoehorn': ^0.1.2
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -44,12 +43,12 @@ describe('downloadUtil', () => {
|
||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||
revokeObjectURLSpy.mockClear().mockImplementation(() => {})
|
||||
// Create a mock anchor element
|
||||
mockLink = fromPartial<HTMLAnchorElement>({
|
||||
mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
style: { display: '' }
|
||||
})
|
||||
} as unknown as HTMLAnchorElement
|
||||
|
||||
// Spy on DOM methods
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockLink)
|
||||
@@ -173,14 +172,12 @@ describe('downloadUtil', () => {
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
@@ -201,13 +198,11 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: vi.fn()
|
||||
})
|
||||
)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: vi.fn()
|
||||
} as Partial<Response> as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
@@ -229,14 +224,12 @@ describe('downloadUtil', () => {
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
|
||||
}
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
@@ -263,14 +256,12 @@ describe('downloadUtil', () => {
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
|
||||
)
|
||||
}
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
@@ -291,14 +282,12 @@ describe('downloadUtil', () => {
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl, 'my-fallback.png')
|
||||
|
||||
@@ -339,13 +328,11 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
})
|
||||
)
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
@@ -359,13 +346,11 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
})
|
||||
)
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
@@ -379,10 +364,11 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({ ok: false, status: 404 })
|
||||
)
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
@@ -395,13 +381,11 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
})
|
||||
)
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
|
||||
@@ -33,91 +33,76 @@
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
<div class="relative min-w-24">
|
||||
<!--
|
||||
Invisible sizers: both labels rendered with matching button padding
|
||||
so the container's intrinsic width equals the wider label.
|
||||
height:0 + overflow:hidden keeps them invisible without affecting height.
|
||||
-->
|
||||
<div class="max-h-0 overflow-y-hidden" aria-hidden="true">
|
||||
<div class="px-4 py-2 text-sm">{{ t('g.save') }}</div>
|
||||
<div class="px-4 py-2 text-sm">{{ t('builderToolbar.saveAs') }}</div>
|
||||
</div>
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
class="w-full"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:class="disabledSaveClasses"
|
||||
data-testid="builder-save-as-button"
|
||||
>
|
||||
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</ConnectOutputPopover>
|
||||
<ButtonGroup
|
||||
v-else-if="isSaved"
|
||||
data-testid="builder-save-group"
|
||||
class="w-full rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
:disabled="!isModified"
|
||||
class="flex-1"
|
||||
:class="isModified ? activeSaveClasses : disabledSaveClasses"
|
||||
data-testid="builder-save-button"
|
||||
@click="save()"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
</Button>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
size="lg"
|
||||
:aria-label="t('builderToolbar.saveAs')"
|
||||
data-save-chevron
|
||||
data-testid="builder-save-as-chevron"
|
||||
class="w-6 rounded-l-none border-l border-border-default px-0"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
|
||||
>
|
||||
<DropdownMenuItem as-child @select="saveAs()">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full justify-start font-normal"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</ButtonGroup>
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<Button
|
||||
v-else
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:class="activeSaveClasses"
|
||||
:class="cn('w-24', disabledSaveClasses)"
|
||||
data-testid="builder-save-as-button"
|
||||
@click="saveAs()"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</div>
|
||||
</ConnectOutputPopover>
|
||||
<ButtonGroup
|
||||
v-else-if="isSaved"
|
||||
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
:disabled="!isModified"
|
||||
class="flex-1"
|
||||
:class="isModified ? activeSaveClasses : disabledSaveClasses"
|
||||
data-testid="builder-save-button"
|
||||
@click="save()"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
</Button>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
size="lg"
|
||||
:aria-label="t('builderToolbar.saveAs')"
|
||||
data-save-chevron
|
||||
data-testid="builder-save-as-chevron"
|
||||
class="w-6 rounded-l-none border-l border-border-default px-0"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
|
||||
>
|
||||
<DropdownMenuItem as-child @select="saveAs()">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full justify-start font-normal"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
v-else
|
||||
size="lg"
|
||||
:class="activeSaveClasses"
|
||||
data-testid="builder-save-as-button"
|
||||
@click="saveAs()"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -141,6 +126,8 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
|
||||
import { setWorkflowDefaultView } from './builderViewOptions'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -11,6 +9,7 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
type TestWidget = BaseDOMWidget<object | string>
|
||||
|
||||
@@ -29,7 +28,7 @@ function createNode(
|
||||
}
|
||||
|
||||
function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
|
||||
return fromPartial<TestWidget>({
|
||||
return {
|
||||
id,
|
||||
node,
|
||||
name: 'test_widget',
|
||||
@@ -41,16 +40,16 @@ function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
|
||||
computedHeight: 40,
|
||||
margin: 10,
|
||||
isVisible: () => true
|
||||
})
|
||||
} as unknown as TestWidget
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
return fromPartial<LGraphCanvas>({
|
||||
return {
|
||||
graph,
|
||||
low_quality: false,
|
||||
read_only: false,
|
||||
isNodeVisible: vi.fn(() => true)
|
||||
})
|
||||
} as unknown as LGraphCanvas
|
||||
}
|
||||
|
||||
function drawFrame(canvas: LGraphCanvas) {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import type { DomWidgetState } from '@/stores/domWidgetStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import DomWidget from './DomWidget.vue'
|
||||
|
||||
const mockUpdatePosition = vi.fn()
|
||||
@@ -63,7 +63,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
}
|
||||
})
|
||||
|
||||
const widget = fromPartial<BaseDOMWidget<object | string>>({
|
||||
const widget = {
|
||||
id: 'dom-widget-id',
|
||||
name: 'test_widget',
|
||||
type: 'custom',
|
||||
@@ -71,7 +71,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
options: {},
|
||||
node,
|
||||
computedDisabled: false
|
||||
})
|
||||
} as unknown as BaseDOMWidget<object | string>
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { getDomWidgetZIndex } from './domWidgetZIndex'
|
||||
|
||||
describe('getDomWidgetZIndex', () => {
|
||||
@@ -15,7 +15,7 @@ describe('getDomWidgetZIndex', () => {
|
||||
first.order = 0
|
||||
second.order = 1
|
||||
|
||||
const nodes = fromPartial<{ _nodes: LGraphNode[] }>(graph)._nodes
|
||||
const nodes = (graph as unknown as { _nodes: LGraphNode[] })._nodes
|
||||
nodes.splice(nodes.indexOf(first), 1)
|
||||
nodes.push(first)
|
||||
|
||||
|
||||
@@ -197,15 +197,4 @@ onBeforeUnmount(() => {
|
||||
:deep(.p-panel-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.p-slider) {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:deep(.p-slider-handle) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -4px;
|
||||
margin-left: -7px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.viewer.cameraType') }}</label>
|
||||
<div class="space-y-4">
|
||||
<label>
|
||||
{{ t('load3d.viewer.cameraType') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
@@ -11,7 +13,7 @@
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="flex flex-col gap-2">
|
||||
<div v-if="showFOVButton" class="space-y-4">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="space-y-4">
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<label>{{ $t('load3d.upDirection') }}</label>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideMaterialMode" class="flex flex-col gap-2">
|
||||
<div v-if="!hideMaterialMode">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="!hasBackgroundImage" class="flex flex-col gap-2">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<label>
|
||||
{{ $t('load3d.backgroundColor') }}
|
||||
</label>
|
||||
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
|
||||
<input v-model="backgroundColor" type="color" class="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -160,7 +159,7 @@ describe('swapNodeGroups computed', () => {
|
||||
|
||||
it('excludes string nodeType entries', async () => {
|
||||
const swap = getSwapNodeGroups([
|
||||
fromAny<MissingNodeType, unknown>('StringGroupNode'),
|
||||
'StringGroupNode' as unknown as MissingNodeType,
|
||||
makeMissingNodeType('OldNode', {
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -216,7 +215,7 @@ describe('useErrorGroups', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
fromAny<MissingNodeType, unknown>('StringGroupNode')
|
||||
'StringGroupNode' as unknown as MissingNodeType
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { Slots } from 'vue'
|
||||
@@ -11,6 +10,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||
@@ -93,13 +93,13 @@ describe('WidgetActions', () => {
|
||||
}
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
return {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [200, 100]
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
|
||||
@@ -216,17 +216,17 @@ describe('WidgetActions', () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
|
||||
const parentSubgraphNode = {
|
||||
id: 4,
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [300, 150]
|
||||
})
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
} as unknown as SubgraphNode
|
||||
const node = {
|
||||
id: 4,
|
||||
type: 'SubgraphNode',
|
||||
rootGraph: { id: 'graph-test' }
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
const widget = {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -73,13 +72,13 @@ const i18n = createI18n({
|
||||
})
|
||||
|
||||
function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
return {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
isSubgraphNode: () => false,
|
||||
graph: { rootGraph: { id: 'test-graph-id' } },
|
||||
...overrides
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
@@ -129,7 +128,7 @@ function createMockPromotedWidgetView(
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
|
||||
return new MockPromotedWidgetView() as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
function mountWidgetItem(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { useDomClipping } from './useDomClipping'
|
||||
@@ -9,7 +8,7 @@ function createMockElement(rect: {
|
||||
width: number
|
||||
height: number
|
||||
}): HTMLElement {
|
||||
return fromPartial<HTMLElement>({
|
||||
return {
|
||||
getBoundingClientRect: vi.fn(
|
||||
() =>
|
||||
({
|
||||
@@ -21,7 +20,7 @@ function createMockElement(rect: {
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
)
|
||||
})
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
function createMockCanvas(rect: {
|
||||
@@ -30,7 +29,7 @@ function createMockCanvas(rect: {
|
||||
width: number
|
||||
height: number
|
||||
}): HTMLCanvasElement {
|
||||
return fromPartial<HTMLCanvasElement>({
|
||||
return {
|
||||
getBoundingClientRect: vi.fn(
|
||||
() =>
|
||||
({
|
||||
@@ -42,7 +41,7 @@ function createMockCanvas(rect: {
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
)
|
||||
})
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
describe('useDomClipping', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
@@ -195,7 +194,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
|
||||
fromAny<LGraph, unknown>(undefined)
|
||||
undefined as unknown as LGraph
|
||||
)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
createMockLGraphNode,
|
||||
createMockLGraphGroup
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import { useGraphHierarchy } from './useGraphHierarchy'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore')
|
||||
@@ -36,10 +36,7 @@ describe('useGraphHierarchy', () => {
|
||||
mockNode = createMockNode()
|
||||
mockGroups = []
|
||||
|
||||
mockCanvasStore = fromAny<
|
||||
Partial<ReturnType<typeof useCanvasStore>>,
|
||||
unknown
|
||||
>({
|
||||
mockCanvasStore = {
|
||||
canvas: {
|
||||
graph: {
|
||||
groups: mockGroups
|
||||
@@ -54,7 +51,7 @@ describe('useGraphHierarchy', () => {
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {}
|
||||
})
|
||||
} as unknown as Partial<ReturnType<typeof useCanvasStore>>
|
||||
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
mockCanvasStore as ReturnType<typeof useCanvasStore>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
@@ -12,10 +11,10 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
@@ -278,20 +277,18 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
fromAny<
|
||||
{
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceNodeId = '9999'
|
||||
fromAny<
|
||||
{
|
||||
}
|
||||
).sourceNodeId = '9999'
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceWidgetName = 'stale_widget'
|
||||
}
|
||||
).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
@@ -112,11 +112,9 @@ describe('useImageMenuOptions', () => {
|
||||
getType: vi.fn().mockResolvedValue(mockBlob)
|
||||
}
|
||||
|
||||
mockClipboard(
|
||||
fromPartial<Clipboard>({
|
||||
read: vi.fn().mockResolvedValue([mockClipboardItem])
|
||||
})
|
||||
)
|
||||
mockClipboard({
|
||||
read: vi.fn().mockResolvedValue([mockClipboardItem])
|
||||
} as unknown as Clipboard)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
@@ -133,7 +131,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('handles missing clipboard API gracefully', async () => {
|
||||
const node = createImageNode()
|
||||
mockClipboard(fromPartial<Clipboard>({ read: undefined }))
|
||||
mockClipboard({ read: undefined } as unknown as Clipboard)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
@@ -150,11 +148,9 @@ describe('useImageMenuOptions', () => {
|
||||
getType: vi.fn()
|
||||
}
|
||||
|
||||
mockClipboard(
|
||||
fromPartial<Clipboard>({
|
||||
read: vi.fn().mockResolvedValue([mockClipboardItem])
|
||||
})
|
||||
)
|
||||
mockClipboard({
|
||||
read: vi.fn().mockResolvedValue([mockClipboardItem])
|
||||
} as unknown as Clipboard)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useMaskEditorSaver } from './useMaskEditorSaver'
|
||||
|
||||
@@ -22,7 +21,7 @@ vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
}))
|
||||
|
||||
function createMockCtx(): CanvasRenderingContext2D {
|
||||
return fromPartial<CanvasRenderingContext2D>({
|
||||
return {
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({
|
||||
data: new Uint8ClampedArray(4 * 4 * 4),
|
||||
@@ -31,11 +30,11 @@ function createMockCtx(): CanvasRenderingContext2D {
|
||||
})),
|
||||
putImageData: vi.fn(),
|
||||
globalCompositeOperation: 'source-over'
|
||||
})
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
return fromPartial<HTMLCanvasElement>({
|
||||
return {
|
||||
width: 4,
|
||||
height: 4,
|
||||
getContext: vi.fn(() => createMockCtx()),
|
||||
@@ -43,7 +42,7 @@ function createMockCanvas(): HTMLCanvasElement {
|
||||
cb(new Blob(['x'], { type: 'image/png' }))
|
||||
}),
|
||||
toDataURL: vi.fn(() => 'data:image/png;base64,mock')
|
||||
})
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
const mockEditorStore: Record<string, HTMLCanvasElement | null> = {
|
||||
@@ -97,7 +96,7 @@ describe('useMaskEditorSaver', () => {
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
|
||||
mockNode = fromAny<LGraphNode, unknown>({
|
||||
mockNode = {
|
||||
id: 42,
|
||||
type: 'LoadImage',
|
||||
images: [],
|
||||
@@ -108,7 +107,7 @@ describe('useMaskEditorSaver', () => {
|
||||
widgets_values: ['original.png [input]'],
|
||||
properties: { image: 'original.png [input]' },
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
|
||||
mockDataStore.sourceNode = mockNode
|
||||
mockDataStore.inputData = {
|
||||
@@ -136,7 +135,7 @@ describe('useMaskEditorSaver', () => {
|
||||
vi.spyOn(document, 'createElement').mockImplementation(
|
||||
(tagName: string, options?: ElementCreationOptions) => {
|
||||
if (tagName === 'canvas')
|
||||
return fromAny<HTMLCanvasElement, unknown>(createMockCanvas())
|
||||
return createMockCanvas() as unknown as HTMLCanvasElement
|
||||
return originalCreateElement(tagName, options)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -45,12 +44,12 @@ vi.mock('@/stores/assetsStore', () => ({
|
||||
}))
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
return {
|
||||
isUploading: false,
|
||||
imgs: [new Image()],
|
||||
graph: { setDirtyCanvas: vi.fn() },
|
||||
size: [300, 400]
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createFile(name = 'test.png'): File {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import { useNodePreviewAndDrag } from './useNodePreviewAndDrag'
|
||||
|
||||
const mockStartDrag = vi.fn()
|
||||
@@ -72,9 +72,9 @@ describe('useNodePreviewAndDrag', () => {
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
const mockEvent = fromPartial<MouseEvent>({
|
||||
const mockEvent = {
|
||||
currentTarget: mockElement
|
||||
})
|
||||
} as Partial<MouseEvent> as MouseEvent
|
||||
result.handleMouseEnter(mockEvent)
|
||||
|
||||
expect(result.isHovered.value).toBe(true)
|
||||
@@ -85,9 +85,9 @@ describe('useNodePreviewAndDrag', () => {
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
const mockElement = document.createElement('div')
|
||||
const mockEvent = fromPartial<MouseEvent>({
|
||||
const mockEvent = {
|
||||
currentTarget: mockElement
|
||||
})
|
||||
} as Partial<MouseEvent> as MouseEvent
|
||||
result.handleMouseEnter(mockEvent)
|
||||
|
||||
expect(result.isHovered.value).toBe(false)
|
||||
@@ -116,9 +116,9 @@ describe('useNodePreviewAndDrag', () => {
|
||||
setData: vi.fn(),
|
||||
setDragImage: vi.fn()
|
||||
}
|
||||
const mockEvent = fromAny<DragEvent, unknown>({
|
||||
const mockEvent = {
|
||||
dataTransfer: mockDataTransfer
|
||||
})
|
||||
} as unknown as DragEvent
|
||||
|
||||
result.handleDragStart(mockEvent)
|
||||
|
||||
@@ -151,10 +151,10 @@ describe('useNodePreviewAndDrag', () => {
|
||||
|
||||
result.isDragging.value = true
|
||||
|
||||
const mockEvent = fromPartial<DragEvent>({
|
||||
const mockEvent = {
|
||||
clientX: 100,
|
||||
clientY: 200
|
||||
})
|
||||
} as Partial<DragEvent> as DragEvent
|
||||
|
||||
result.handleDragEnd(mockEvent)
|
||||
|
||||
@@ -168,11 +168,11 @@ describe('useNodePreviewAndDrag', () => {
|
||||
|
||||
result.isDragging.value = true
|
||||
|
||||
const mockEvent = fromPartial<DragEvent>({
|
||||
const mockEvent = {
|
||||
dataTransfer: { dropEffect: 'none' },
|
||||
clientX: 300,
|
||||
clientY: 400
|
||||
})
|
||||
} as Partial<DragEvent> as DragEvent
|
||||
|
||||
result.handleDragEnd(mockEvent)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -80,10 +79,10 @@ describe('useServerLogs', () => {
|
||||
|
||||
// Simulate receiving a log event
|
||||
const mockEvent = new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
detail: {
|
||||
type: 'logs',
|
||||
entries: [{ m: 'Log message 1' }, { m: 'Log message 2' }]
|
||||
})
|
||||
} as unknown as LogsWsMessage
|
||||
}) as CustomEvent<LogsWsMessage>
|
||||
|
||||
eventCallback(mockEvent)
|
||||
@@ -104,14 +103,14 @@ describe('useServerLogs', () => {
|
||||
) => void
|
||||
|
||||
const mockEvent = new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
detail: {
|
||||
type: 'logs',
|
||||
entries: [
|
||||
{ m: 'Log message 1 dont remove me' },
|
||||
{ m: 'remove me' },
|
||||
{ m: '' }
|
||||
]
|
||||
})
|
||||
} as unknown as LogsWsMessage
|
||||
}) as CustomEvent<LogsWsMessage>
|
||||
|
||||
eventCallback(mockEvent)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -81,12 +80,10 @@ describe('useWaveAudioPlayer', () => {
|
||||
|
||||
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined)
|
||||
globalThis.AudioContext = fromAny<typeof AudioContext, unknown>(
|
||||
class {
|
||||
decodeAudioData = mockDecodeAudioData
|
||||
close = mockClose
|
||||
}
|
||||
)
|
||||
globalThis.AudioContext = class {
|
||||
decodeAudioData = mockDecodeAudioData
|
||||
close = mockClose
|
||||
} as unknown as typeof AudioContext
|
||||
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { matchPromotedInput } from './matchPromotedInput'
|
||||
|
||||
type MockInput = {
|
||||
@@ -31,12 +31,10 @@ describe(matchPromotedInput, () => {
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
fromPartial<
|
||||
Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>
|
||||
>([aliasInput, exactInput]),
|
||||
[aliasInput, exactInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
@@ -50,7 +48,7 @@ describe(matchPromotedInput, () => {
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
fromPartial<Array<{ name: string; _widget?: IBaseWidget }>>([aliasInput]),
|
||||
[aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
@@ -67,12 +65,10 @@ describe(matchPromotedInput, () => {
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
fromPartial<
|
||||
Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>
|
||||
>([firstAliasInput, secondAliasInput]),
|
||||
[firstAliasInput, secondAliasInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
// Barrel import must come first to avoid circular dependency
|
||||
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
||||
@@ -98,12 +97,11 @@ function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] {
|
||||
}
|
||||
|
||||
function callSyncPromotions(node: SubgraphNode) {
|
||||
fromAny<
|
||||
{
|
||||
;(
|
||||
node as unknown as {
|
||||
_syncPromotions: () => void
|
||||
},
|
||||
unknown
|
||||
>(node)._syncPromotions()
|
||||
}
|
||||
)._syncPromotions()
|
||||
}
|
||||
|
||||
describe(createPromotedWidgetView, () => {
|
||||
@@ -158,9 +156,7 @@ describe(createPromotedWidgetView, () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
||||
// node is defined via Object.defineProperty at runtime but not on the TS interface
|
||||
expect(fromAny<Record<string, unknown>, unknown>(view).node).toBe(
|
||||
subgraphNode
|
||||
)
|
||||
expect((view as unknown as Record<string, unknown>).node).toBe(subgraphNode)
|
||||
})
|
||||
|
||||
test('serialize is false', () => {
|
||||
@@ -293,7 +289,7 @@ describe(createPromotedWidgetView, () => {
|
||||
value: 'initial',
|
||||
options: {}
|
||||
} satisfies Pick<IBaseWidget, 'name' | 'type' | 'value' | 'options'>
|
||||
const fallbackWidget = fromPartial<IBaseWidget>(fallbackWidgetShape)
|
||||
const fallbackWidget = fallbackWidgetShape as unknown as IBaseWidget
|
||||
innerNode.widgets = [fallbackWidget]
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
@@ -402,13 +398,13 @@ describe(createPromotedWidgetView, () => {
|
||||
subgraphNode.pos = [10, 20]
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const mouse = vi.fn(() => true)
|
||||
const legacyWidget = fromAny<IBaseWidget, unknown>({
|
||||
const legacyWidget = {
|
||||
name: 'legacyMouse',
|
||||
type: 'mystery-legacy',
|
||||
value: 'val',
|
||||
options: {},
|
||||
mouse
|
||||
})
|
||||
} as unknown as IBaseWidget
|
||||
innerNode.widgets = [legacyWidget]
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
@@ -1452,20 +1448,17 @@ describe('widgets getter caching', () => {
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
fromAny<
|
||||
{
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
unknown
|
||||
>(subgraphNode),
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
@@ -1485,20 +1478,17 @@ describe('widgets getter caching', () => {
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
fromAny<
|
||||
{
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
unknown
|
||||
>(subgraphNode),
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
@@ -1532,14 +1522,9 @@ describe('widgets getter caching', () => {
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
const resolveSpy = vi.spyOn(
|
||||
fromAny<
|
||||
{
|
||||
_resolveLinkedPromotionBySubgraphInput: (
|
||||
...args: unknown[]
|
||||
) => unknown
|
||||
},
|
||||
unknown
|
||||
>(subgraphNode),
|
||||
subgraphNode as unknown as {
|
||||
_resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown
|
||||
},
|
||||
'_resolveLinkedPromotionBySubgraphInput'
|
||||
)
|
||||
|
||||
@@ -1938,34 +1923,32 @@ function createFakeCanvasContext() {
|
||||
|
||||
function createInspectableCanvasContext(fillText = vi.fn()) {
|
||||
const fallback = vi.fn()
|
||||
return fromAny<CanvasRenderingContext2D, unknown>(
|
||||
new Proxy(
|
||||
{
|
||||
fillText,
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
measureText: (text: string) => ({ width: text.length * 8 }),
|
||||
fillStyle: '#fff',
|
||||
strokeStyle: '#fff',
|
||||
textAlign: 'left',
|
||||
globalAlpha: 1,
|
||||
lineWidth: 1
|
||||
} as Record<string, unknown>,
|
||||
{
|
||||
get(target, key) {
|
||||
if (typeof key === 'string' && key in target)
|
||||
return target[key as keyof typeof target]
|
||||
return fallback
|
||||
}
|
||||
return new Proxy(
|
||||
{
|
||||
fillText,
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
measureText: (text: string) => ({ width: text.length * 8 }),
|
||||
fillStyle: '#fff',
|
||||
strokeStyle: '#fff',
|
||||
textAlign: 'left',
|
||||
globalAlpha: 1,
|
||||
lineWidth: 1
|
||||
} as Record<string, unknown>,
|
||||
{
|
||||
get(target, key) {
|
||||
if (typeof key === 'string' && key in target)
|
||||
return target[key as keyof typeof target]
|
||||
return fallback
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
) as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createTwoLevelNestedSubgraph() {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
@@ -30,7 +29,7 @@ function widget(
|
||||
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
|
||||
>
|
||||
): IBaseWidget {
|
||||
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
|
||||
return { name: 'widget', ...overrides } as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
@@ -102,14 +101,14 @@ describe('resolveSubgraphInputLink', () => {
|
||||
vi.spyOn(subgraph, 'getLink').mockImplementation((linkId) => {
|
||||
if (typeof linkId !== 'number') return originalGetLink(linkId)
|
||||
if (linkId === stale.linkId) {
|
||||
return fromPartial<ReturnType<typeof subgraph.getLink>>({
|
||||
return {
|
||||
resolve: () => ({
|
||||
inputNode: {
|
||||
inputs: undefined,
|
||||
getWidgetFromSlot: () => ({ name: 'ignored' })
|
||||
}
|
||||
})
|
||||
})
|
||||
} as unknown as ReturnType<typeof subgraph.getLink>
|
||||
}
|
||||
|
||||
return originalGetLink(linkId)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
@@ -73,8 +72,8 @@ describe('MatchType during configure', () => {
|
||||
const link2Id = switchNode.inputs[1].link!
|
||||
|
||||
const outputTypeBefore = switchNode.outputs[0].type
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
;(
|
||||
app as unknown as { configuringGraphLevel: number }
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
try {
|
||||
@@ -93,8 +92,8 @@ describe('MatchType during configure', () => {
|
||||
expect(graph.links[link2Id]).toBeDefined()
|
||||
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
|
||||
} finally {
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
;(
|
||||
app as unknown as { configuringGraphLevel: number }
|
||||
).configuringGraphLevel = 0
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
@@ -60,7 +60,7 @@ function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
|
||||
el.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(fromAny<CanvasRenderingContext2D, unknown>(ctx))
|
||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
||||
el.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
* and basic I/O management.
|
||||
*/
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { createUuidv4, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
assertSubgraphStructure,
|
||||
@@ -48,7 +48,7 @@ describe('Subgraph Construction', () => {
|
||||
it('should require a root graph', () => {
|
||||
const subgraphData = createTestSubgraphData()
|
||||
const createWithoutRoot = () =>
|
||||
new Subgraph(fromAny<LGraph, unknown>(null), subgraphData)
|
||||
new Subgraph(null as unknown as LGraph, subgraphData)
|
||||
|
||||
expect(createWithoutRoot).toThrow('Root graph is required')
|
||||
})
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
* Tests for SubgraphNode instances including construction,
|
||||
* IO synchronization, and edge cases.
|
||||
*/
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -933,17 +933,14 @@ describe('SubgraphNode promotion view keys', () => {
|
||||
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const nodeWithKeyBuilder = fromAny<
|
||||
{
|
||||
_makePromotionViewKey: (
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
inputName?: string
|
||||
) => string
|
||||
},
|
||||
unknown
|
||||
>(subgraphNode)
|
||||
const nodeWithKeyBuilder = subgraphNode as unknown as {
|
||||
_makePromotionViewKey: (
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
inputName?: string
|
||||
) => string
|
||||
}
|
||||
|
||||
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createBitmapCache } from './svgBitmapCache'
|
||||
@@ -26,9 +25,9 @@ describe('createBitmapCache', () => {
|
||||
)
|
||||
}
|
||||
|
||||
const stubContext = fromPartial<CanvasRenderingContext2D>({
|
||||
const stubContext = {
|
||||
drawImage: vi.fn()
|
||||
})
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
it('returns the SVG when image is not yet complete', () => {
|
||||
const svg = mockSvg({ complete: false, naturalWidth: 0 })
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { cachedMeasureText, clearTextMeasureCache } from './textMeasureCache'
|
||||
|
||||
function createMockCtx(font = '12px sans-serif'): CanvasRenderingContext2D {
|
||||
return fromPartial<CanvasRenderingContext2D>({
|
||||
return {
|
||||
font,
|
||||
measureText: vi.fn((text: string) => ({ width: text.length * 7 }))
|
||||
})
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
describe('textMeasureCache', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -168,7 +167,7 @@ describe('BaseWidget store integration', () => {
|
||||
const defaultValue = 'You are an expert image-generation engine.'
|
||||
const widget = createTestWidget(node, {
|
||||
name: 'system_prompt',
|
||||
value: fromAny<number, unknown>(undefined)
|
||||
value: undefined as unknown as number
|
||||
})
|
||||
|
||||
// Simulate what addDOMWidget does: override value with getter/setter
|
||||
|
||||
@@ -798,7 +798,7 @@
|
||||
}
|
||||
},
|
||||
"CaseConverter": {
|
||||
"display_name": "Text Case Converter",
|
||||
"display_name": "Case Converter",
|
||||
"inputs": {
|
||||
"string": {
|
||||
"name": "string"
|
||||
@@ -12840,7 +12840,7 @@
|
||||
}
|
||||
},
|
||||
"RegexExtract": {
|
||||
"display_name": "Text Extract Substring",
|
||||
"display_name": "Regex Extract",
|
||||
"inputs": {
|
||||
"string": {
|
||||
"name": "string"
|
||||
@@ -12871,7 +12871,7 @@
|
||||
}
|
||||
},
|
||||
"RegexMatch": {
|
||||
"display_name": "Text Match",
|
||||
"display_name": "Regex Match",
|
||||
"inputs": {
|
||||
"string": {
|
||||
"name": "string"
|
||||
@@ -12897,7 +12897,7 @@
|
||||
}
|
||||
},
|
||||
"RegexReplace": {
|
||||
"display_name": "Text Replace (Regex)",
|
||||
"display_name": "Regex Replace",
|
||||
"description": "Find and replace text using regex patterns.",
|
||||
"inputs": {
|
||||
"string": {
|
||||
@@ -15220,7 +15220,7 @@
|
||||
}
|
||||
},
|
||||
"StringCompare": {
|
||||
"display_name": "Text Compare",
|
||||
"display_name": "Compare",
|
||||
"inputs": {
|
||||
"string_a": {
|
||||
"name": "string_a"
|
||||
@@ -15242,7 +15242,7 @@
|
||||
}
|
||||
},
|
||||
"StringConcatenate": {
|
||||
"display_name": "Text Concatenate",
|
||||
"display_name": "Concatenate",
|
||||
"inputs": {
|
||||
"string_a": {
|
||||
"name": "string_a"
|
||||
@@ -15261,7 +15261,7 @@
|
||||
}
|
||||
},
|
||||
"StringContains": {
|
||||
"display_name": "Text Contains",
|
||||
"display_name": "Contains",
|
||||
"inputs": {
|
||||
"string": {
|
||||
"name": "string"
|
||||
@@ -15281,7 +15281,7 @@
|
||||
}
|
||||
},
|
||||
"StringLength": {
|
||||
"display_name": "Text Length",
|
||||
"display_name": "Length",
|
||||
"inputs": {
|
||||
"string": {
|
||||
"name": "string"
|
||||
@@ -15295,7 +15295,7 @@
|
||||
}
|
||||
},
|
||||
"StringReplace": {
|
||||
"display_name": "Text Replace",
|
||||
"display_name": "Replace",
|
||||
"inputs": {
|
||||
"string": {
|
||||
"name": "string"
|
||||
@@ -15314,7 +15314,7 @@
|
||||
}
|
||||
},
|
||||
"StringSubstring": {
|
||||
"display_name": "Text Substring",
|
||||
"display_name": "Substring",
|
||||
"inputs": {
|
||||
"string": {
|
||||
"name": "string"
|
||||
@@ -15333,7 +15333,7 @@
|
||||
}
|
||||
},
|
||||
"StringTrim": {
|
||||
"display_name": "Text Trim",
|
||||
"display_name": "Trim",
|
||||
"inputs": {
|
||||
"string": {
|
||||
"name": "string"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { useMediaAssetActions } from './useMediaAssetActions'
|
||||
|
||||
// Use vi.hoisted to create a mutable reference for isCloud
|
||||
@@ -77,12 +77,10 @@ vi.mock('@/platform/workflow/core/services/workflowActionsService', () => ({
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({
|
||||
addNodeOnGraph: vi.fn().mockReturnValue(
|
||||
fromAny<LGraphNode, unknown>({
|
||||
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
})
|
||||
),
|
||||
addNodeOnGraph: vi.fn().mockReturnValue({
|
||||
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
} as unknown as LGraphNode),
|
||||
getCanvasCenter: vi.fn().mockReturnValue([100, 100])
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
scanAllModelCandidates,
|
||||
isModelFileName,
|
||||
@@ -16,6 +9,12 @@ import {
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
@@ -31,32 +30,32 @@ function makeComboWidget(
|
||||
value: string | number,
|
||||
options: string[] = []
|
||||
): IComboWidget {
|
||||
return fromAny<IComboWidget, unknown>({
|
||||
return {
|
||||
type: 'combo',
|
||||
name,
|
||||
value,
|
||||
options: { values: options }
|
||||
})
|
||||
} as unknown as IComboWidget
|
||||
}
|
||||
|
||||
/** Helper: create an asset widget mock (Cloud combo replacement) */
|
||||
function makeAssetWidget(name: string, value: string): IBaseWidget {
|
||||
return fromAny<IBaseWidget, unknown>({
|
||||
return {
|
||||
type: 'asset',
|
||||
name,
|
||||
value,
|
||||
options: {}
|
||||
})
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
/** Helper: create a non-combo widget mock */
|
||||
function makeOtherWidget(name: string, value: unknown): IBaseWidget {
|
||||
return fromAny<IBaseWidget, unknown>({
|
||||
return {
|
||||
type: 'number',
|
||||
name,
|
||||
value,
|
||||
options: {}
|
||||
})
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
/** Helper: create a mock LGraphNode with configured widgets */
|
||||
@@ -66,17 +65,17 @@ function makeNode(
|
||||
widgets: IBaseWidget[] = [],
|
||||
executionId?: string
|
||||
): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
widgets,
|
||||
_testExecutionId: executionId
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
/** Helper: create a mock LGraph containing given nodes */
|
||||
function makeGraph(nodes: LGraphNode[]): LGraph {
|
||||
return fromAny<LGraph, unknown>({ _testNodes: nodes })
|
||||
return { _testNodes: nodes } as unknown as LGraph
|
||||
}
|
||||
|
||||
const noAssetSupport = () => false
|
||||
@@ -391,13 +390,13 @@ describe('scanAllModelCandidates', () => {
|
||||
})
|
||||
|
||||
it('skips subgraph container nodes whose promoted widgets are already scanned via interior nodes', () => {
|
||||
const containerNode = fromAny<LGraphNode, unknown>({
|
||||
const containerNode = {
|
||||
id: 65,
|
||||
type: 'abc-def-uuid',
|
||||
widgets: [makeComboWidget('ckpt_name', 'model.safetensors', [])],
|
||||
isSubgraphNode: () => true,
|
||||
_testExecutionId: '65'
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const interiorNode = makeNode(
|
||||
42,
|
||||
@@ -438,7 +437,7 @@ const alwaysInstalled = async () => true
|
||||
describe('enrichWithEmbeddedMetadata', () => {
|
||||
it('enriches existing candidate with url and directory from embedded metadata', async () => {
|
||||
const candidates = [makeCandidate('model_a.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
@@ -468,7 +467,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
hash_type: 'sha256'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
@@ -488,7 +487,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
url: 'https://existing.com'
|
||||
})
|
||||
]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
@@ -516,7 +515,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
directory: 'new_dir'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
@@ -531,7 +530,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
|
||||
it('does not mutate the original candidates array', async () => {
|
||||
const candidates = [makeCandidate('model_a.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
@@ -559,7 +558,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const originalUrl = candidates[0].url
|
||||
await enrichWithEmbeddedMetadata(candidates, graphData, alwaysMissing)
|
||||
@@ -569,7 +568,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
|
||||
it('adds new candidate for embedded model not found by COMBO scan', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
@@ -597,7 +596,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
@@ -612,7 +611,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
|
||||
it('does not add candidate when model is already installed', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
@@ -628,7 +627,7 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
@@ -663,7 +662,7 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
// OSS path: candidates start empty, enrichWithEmbeddedMetadata adds
|
||||
// missing embedded models so the dialog can show them.
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
@@ -707,7 +706,7 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
directory: 'loras'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
@@ -727,7 +726,7 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
// When isAssetSupported is omitted (OSS), unmatched embedded models
|
||||
// should have isMissing=true (not undefined), enabling the dialog.
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
@@ -755,7 +754,7 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
@@ -770,7 +769,7 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
|
||||
it('enrichWithEmbeddedMetadata correctly filters for dialog: only isMissing=true with url', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
@@ -803,7 +802,7 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const selectiveInstallCheck = async (name: string) =>
|
||||
name === 'installed_model.safetensors'
|
||||
@@ -822,7 +821,7 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
|
||||
it('enrichWithEmbeddedMetadata with isAssetSupported leaves isMissing undefined for asset-supported models (Cloud path)', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
const graphData = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
@@ -850,7 +849,7 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { getCnrIdFromNode, getCnrIdFromProperties } from './cnrIdUtil'
|
||||
|
||||
describe('getCnrIdFromProperties', () => {
|
||||
@@ -40,28 +40,28 @@ describe('getCnrIdFromProperties', () => {
|
||||
|
||||
describe('getCnrIdFromNode', () => {
|
||||
it('returns cnr_id from node properties', () => {
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
const node = {
|
||||
properties: { cnr_id: 'node-pack' }
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
expect(getCnrIdFromNode(node)).toBe('node-pack')
|
||||
})
|
||||
|
||||
it('returns aux_id when cnr_id is absent', () => {
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
const node = {
|
||||
properties: { aux_id: 'node-aux-pack' }
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
expect(getCnrIdFromNode(node)).toBe('node-aux-pack')
|
||||
})
|
||||
|
||||
it('prefers cnr_id over aux_id in node properties', () => {
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
const node = {
|
||||
properties: { cnr_id: 'primary', aux_id: 'secondary' }
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
expect(getCnrIdFromNode(node)).toBe('primary')
|
||||
})
|
||||
|
||||
it('returns undefined when node has no cnr_id or aux_id', () => {
|
||||
const node = fromAny<LGraphNode, unknown>({ properties: {} })
|
||||
const node = { properties: {} } as unknown as LGraphNode
|
||||
expect(getCnrIdFromNode(node)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -185,9 +184,9 @@ describe('SwapNodeGroupRow', () => {
|
||||
const wrapper = mountRow({
|
||||
group: makeGroup({
|
||||
// Intentionally omits nodeId to test graceful handling of incomplete node data
|
||||
nodeTypes: fromAny<MissingNodeType[], unknown>([
|
||||
nodeTypes: [
|
||||
{ type: 'NoIdNode', isReplaceable: true }
|
||||
])
|
||||
] as unknown as MissingNodeType[]
|
||||
})
|
||||
})
|
||||
await expand(wrapper)
|
||||
@@ -235,7 +234,7 @@ describe('SwapNodeGroupRow', () => {
|
||||
const wrapper = mountRow({
|
||||
group: makeGroup({
|
||||
// Intentionally uses a plain string entry to test legacy node type handling
|
||||
nodeTypes: fromAny<MissingNodeType[], unknown>(['StringType'])
|
||||
nodeTypes: ['StringType'] as unknown as MissingNodeType[]
|
||||
})
|
||||
})
|
||||
await wrapper.get('button[aria-label="Expand"]').trigger('click')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -59,16 +58,16 @@ function mockNode(
|
||||
type: string,
|
||||
overrides: Partial<LGraphNode> = {}
|
||||
): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
last_serialization: { type },
|
||||
...overrides
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function mockGraph(): LGraph {
|
||||
return fromAny<LGraph, unknown>({})
|
||||
return {} as unknown as LGraph
|
||||
}
|
||||
|
||||
function getMissingNodesError(
|
||||
@@ -217,9 +216,9 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
it('uses last_serialization.type over node.type', () => {
|
||||
const node = mockNode(1, 'LiveType')
|
||||
node.last_serialization = fromPartial<LGraphNode['last_serialization']>({
|
||||
node.last_serialization = {
|
||||
type: 'OriginalType'
|
||||
})
|
||||
} as unknown as LGraphNode['last_serialization']
|
||||
vi.mocked(collectAllNodes).mockReturnValue([node])
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeReplacement } from './types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: {
|
||||
@@ -80,13 +79,13 @@ function createMockGraph(
|
||||
links: ReturnType<typeof createMockLink>[] = []
|
||||
): LGraph {
|
||||
const linksMap = new Map(links.map((l) => [l.id, l]))
|
||||
return fromAny<LGraph, unknown>({
|
||||
return {
|
||||
_nodes: nodes,
|
||||
_nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])),
|
||||
links: linksMap,
|
||||
updateExecutionOrder: vi.fn(),
|
||||
setDirtyCanvas: vi.fn()
|
||||
})
|
||||
} as unknown as LGraph
|
||||
}
|
||||
|
||||
function createPlaceholderNode(
|
||||
@@ -96,7 +95,7 @@ function createPlaceholderNode(
|
||||
outputs: { name: string; links: number[] | null }[] = [],
|
||||
graph?: LGraph
|
||||
): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
pos: [100, 200],
|
||||
@@ -132,7 +131,7 @@ function createPlaceholderNode(
|
||||
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
|
||||
widgets_values: []
|
||||
}))
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createNewNode(
|
||||
@@ -140,7 +139,7 @@ function createNewNode(
|
||||
outputs: { name: string; links: number[] | null }[] = [],
|
||||
widgets: { name: string; value: unknown }[] = []
|
||||
): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
return {
|
||||
id: 0,
|
||||
type: '',
|
||||
pos: [0, 0],
|
||||
@@ -154,7 +153,7 @@ function createNewNode(
|
||||
widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })),
|
||||
configure: vi.fn(),
|
||||
serialize: vi.fn()
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -757,10 +756,8 @@ describe('useNodeReplacement', () => {
|
||||
|
||||
it('should exclude nodes without last_serialization', () => {
|
||||
const freshNode = createPlaceholderNode(1, 'OldNode')
|
||||
freshNode.last_serialization = fromAny<
|
||||
LGraphNode['last_serialization'],
|
||||
unknown
|
||||
>(undefined)
|
||||
freshNode.last_serialization =
|
||||
undefined as unknown as LGraphNode['last_serialization']
|
||||
const graph = createMockGraph([freshNode])
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
@@ -783,7 +780,7 @@ describe('useNodeReplacement', () => {
|
||||
|
||||
it('should fall back to node.type when last_serialization.type is undefined', () => {
|
||||
const node = createPlaceholderNode(1, 'FallbackType')
|
||||
node.last_serialization!.type = fromAny<string, unknown>(undefined)
|
||||
node.last_serialization!.type = undefined as unknown as string
|
||||
node.type = 'FallbackType'
|
||||
const graph = createMockGraph([node])
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
@@ -812,7 +809,7 @@ describe('useNodeReplacement', () => {
|
||||
// targetTypes still holds the original unsanitized name "OldNode&Special",
|
||||
// so the predicate must fall back to checking sanitizeNodeName(originalType).
|
||||
const node = createPlaceholderNode(1, 'OldNodeSpecial')
|
||||
node.last_serialization!.type = fromAny<string, unknown>(undefined)
|
||||
node.last_serialization!.type = undefined as unknown as string
|
||||
// Simulate what sanitizeNodeName does to '&' in the live type
|
||||
node.type = 'OldNodeSpecial' // '&' already stripped by sanitizeNodeName
|
||||
const graph = createMockGraph([node])
|
||||
|
||||
@@ -139,7 +139,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
workflow.changeTracker?.checkState()
|
||||
await saveWorkflow(workflow)
|
||||
} else {
|
||||
let target: ComfyWorkflow
|
||||
@@ -156,7 +156,7 @@ export const useWorkflowService = () => {
|
||||
app.rootGraph.extra.linearMode = isApp
|
||||
target.initialMode = isApp ? 'app' : 'graph'
|
||||
}
|
||||
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
|
||||
target.changeTracker?.checkState()
|
||||
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
workflow.changeTracker?.checkState()
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const expectedPath =
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
|
||||
|
||||
const mockGetSharedWorkflow = vi.fn()
|
||||
|
||||
@@ -52,9 +51,9 @@ function makePayload(
|
||||
name: 'Test Workflow',
|
||||
listed: true,
|
||||
publishedAt: new Date('2026-02-20T00:00:00Z'),
|
||||
workflowJson: fromPartial<SharedWorkflowPayload['workflowJson']>({
|
||||
workflowJson: {
|
||||
nodes: []
|
||||
}),
|
||||
} as unknown as SharedWorkflowPayload['workflowJson'],
|
||||
assets: [],
|
||||
...overrides
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
clearPreservedQuery: vi.fn(),
|
||||
@@ -108,9 +107,9 @@ function makePayload(
|
||||
name: 'Test Workflow',
|
||||
listed: true,
|
||||
publishedAt: new Date('2026-02-20T00:00:00Z'),
|
||||
workflowJson: fromPartial<SharedWorkflowPayload['workflowJson']>({
|
||||
workflowJson: {
|
||||
nodes: []
|
||||
}),
|
||||
} as unknown as SharedWorkflowPayload['workflowJson'],
|
||||
assets: [],
|
||||
...overrides
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import fs from 'fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -296,33 +295,29 @@ describe('flattenWorkflowNodes', () => {
|
||||
})
|
||||
|
||||
it('includes subgraph nodes with prefixed IDs', () => {
|
||||
const result = flattenWorkflowNodes(
|
||||
fromPartial<ComfyWorkflowJSON>({
|
||||
nodes: [node(5, 'def-A')],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')])
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
const result = flattenWorkflowNodes({
|
||||
nodes: [node(5, 'def-A')],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')])
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON)
|
||||
|
||||
expect(result).toHaveLength(3) // 1 root + 2 subgraph
|
||||
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20'])
|
||||
})
|
||||
|
||||
it('prefixes nested subgraph nodes with full execution path', () => {
|
||||
const result = flattenWorkflowNodes(
|
||||
fromPartial<ComfyWorkflowJSON>({
|
||||
nodes: [node(5, 'def-A')],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
subgraphDef('def-A', [node(10, 'def-B')]),
|
||||
subgraphDef('def-B', [node(3, 'Leaf')])
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
const result = flattenWorkflowNodes({
|
||||
nodes: [node(5, 'def-A')],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
subgraphDef('def-A', [node(10, 'def-B')]),
|
||||
subgraphDef('def-B', [node(3, 'Leaf')])
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON)
|
||||
|
||||
// root:5, def-A inner: 5:10, def-B inner: 5:10:3
|
||||
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3'])
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCreateWorkspaceUrlLoader } from './useCreateWorkspaceUrlLoader'
|
||||
@@ -120,7 +119,7 @@ describe('useCreateWorkspaceUrlLoader', () => {
|
||||
|
||||
it('ignores non-string param', async () => {
|
||||
mockRouteQuery.value = {
|
||||
create_workspace: fromAny<string, unknown>(['array'])
|
||||
create_workspace: ['array'] as unknown as string
|
||||
}
|
||||
|
||||
const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useInviteUrlLoader } from './useInviteUrlLoader'
|
||||
@@ -225,9 +224,7 @@ describe('useInviteUrlLoader', () => {
|
||||
})
|
||||
|
||||
it('ignores non-string invite param', async () => {
|
||||
mockRouteQuery.value = {
|
||||
invite: fromAny<string, unknown>(['array', 'value'])
|
||||
}
|
||||
mockRouteQuery.value = { invite: ['array', 'value'] as unknown as string }
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DragAndScale } from '@/lib/litegraph/src/DragAndScale'
|
||||
|
||||
import {
|
||||
AutoPanController,
|
||||
calculateEdgePanSpeed
|
||||
@@ -74,7 +74,7 @@ describe('AutoPanController', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
mockCanvas = fromPartial<HTMLCanvasElement>({
|
||||
mockCanvas = {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
@@ -86,9 +86,12 @@ describe('AutoPanController', () => {
|
||||
y: 0,
|
||||
toJSON: () => {}
|
||||
})
|
||||
})
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
mockDs = fromPartial<DragAndScale>({ offset: [0, 0], scale: 1 })
|
||||
mockDs = {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
} as unknown as DragAndScale
|
||||
|
||||
onPanMock = vi.fn<(dx: number, dy: number) => void>()
|
||||
controller = new AutoPanController({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
@@ -85,12 +84,10 @@ describe(flattenNodeOutput, () => {
|
||||
})
|
||||
|
||||
it('flattens non-standard output keys with ResultItem-like values', () => {
|
||||
const output = makeOutput(
|
||||
fromPartial<NodeExecutionOutput>({
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
)
|
||||
const output = makeOutput({
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
} as unknown as Partial<NodeExecutionOutput>)
|
||||
|
||||
const result = flattenNodeOutput(['10', output])
|
||||
|
||||
@@ -112,10 +109,10 @@ describe(flattenNodeOutput, () => {
|
||||
})
|
||||
|
||||
it('excludes non-ResultItem array items', () => {
|
||||
const output = fromPartial<NodeExecutionOutput>({
|
||||
const output = {
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
custom_data: [{ randomKey: 123 }]
|
||||
})
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = flattenNodeOutput(['1', output])
|
||||
|
||||
@@ -124,12 +121,12 @@ describe(flattenNodeOutput, () => {
|
||||
})
|
||||
|
||||
it('accepts items with filename but no subfolder', () => {
|
||||
const output = fromPartial<NodeExecutionOutput>({
|
||||
const output = {
|
||||
images: [
|
||||
{ filename: 'valid.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'no-subfolder.png' }
|
||||
]
|
||||
})
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = flattenNodeOutput(['1', output])
|
||||
|
||||
@@ -140,12 +137,12 @@ describe(flattenNodeOutput, () => {
|
||||
})
|
||||
|
||||
it('excludes items missing filename', () => {
|
||||
const output = fromPartial<NodeExecutionOutput>({
|
||||
const output = {
|
||||
images: [
|
||||
{ filename: 'valid.png', subfolder: '', type: 'output' },
|
||||
{ subfolder: '', type: 'output' }
|
||||
]
|
||||
})
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = flattenNodeOutput(['1', output])
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -9,10 +8,11 @@ import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
@@ -79,8 +79,8 @@ describe('NodeWidgets', () => {
|
||||
}
|
||||
|
||||
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
|
||||
fromAny<{ processedWidgets: unknown[] }, unknown>(
|
||||
wrapper.vm
|
||||
(
|
||||
wrapper.vm as unknown as { processedWidgets: unknown[] }
|
||||
).processedWidgets.map(
|
||||
(entry) =>
|
||||
(
|
||||
|
||||
@@ -19,27 +19,11 @@ import {
|
||||
} from './useSlotElementTracking'
|
||||
|
||||
const mockGraph = vi.hoisted(() => ({ _nodes: [] as unknown[] }))
|
||||
const mockCanvasState = vi.hoisted(() => ({
|
||||
canvas: {} as object | null
|
||||
}))
|
||||
const mockClientPosToCanvasPos = vi.hoisted(() =>
|
||||
vi.fn(([x, y]: [number, number]) => [x * 0.5, y * 0.5] as [number, number])
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: mockGraph, setDirty: vi.fn() } }
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasState
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useSharedCanvasPositionConversion: () => ({
|
||||
clientPosToCanvasPos: mockClientPosToCanvasPos
|
||||
})
|
||||
}))
|
||||
|
||||
const NODE_ID = 'test-node'
|
||||
const SLOT_INDEX = 0
|
||||
|
||||
@@ -61,10 +45,9 @@ function createWrapperComponent(type: 'input' | 'output') {
|
||||
})
|
||||
}
|
||||
|
||||
function createSlotElement(collapsed = false): HTMLElement {
|
||||
function createSlotElement(): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
container.dataset.nodeId = NODE_ID
|
||||
if (collapsed) container.dataset.collapsed = ''
|
||||
container.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -130,8 +113,6 @@ describe('useSlotElementTracking', () => {
|
||||
actor: 'test'
|
||||
})
|
||||
mockGraph._nodes = [{ id: 1 }]
|
||||
mockCanvasState.canvas = {}
|
||||
mockClientPosToCanvasPos.mockClear()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -270,57 +251,4 @@ describe('useSlotElementTracking', () => {
|
||||
|
||||
expect(batchUpdateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('collapsed node slot sync', () => {
|
||||
function registerCollapsedSlot() {
|
||||
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
|
||||
const slotEl = createSlotElement(true)
|
||||
|
||||
const registryStore = useNodeSlotRegistryStore()
|
||||
const node = registryStore.ensureNode(NODE_ID)
|
||||
node.slots.set(slotKey, {
|
||||
el: slotEl,
|
||||
index: SLOT_INDEX,
|
||||
type: 'input',
|
||||
cachedOffset: { x: 50, y: 60 }
|
||||
})
|
||||
|
||||
return { slotKey, node }
|
||||
}
|
||||
|
||||
it('uses clientPosToCanvasPos for collapsed nodes', () => {
|
||||
const { slotKey } = registerCollapsedSlot()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
// Slot element center: (10 + 10/2, 30 + 10/2) = (15, 35)
|
||||
const screenCenter: [number, number] = [15, 35]
|
||||
expect(mockClientPosToCanvasPos).toHaveBeenCalledWith(screenCenter)
|
||||
|
||||
// Mock returns x*0.5, y*0.5
|
||||
const layout = layoutStore.getSlotLayout(slotKey)
|
||||
expect(layout).not.toBeNull()
|
||||
expect(layout!.position.x).toBe(screenCenter[0] * 0.5)
|
||||
expect(layout!.position.y).toBe(screenCenter[1] * 0.5)
|
||||
})
|
||||
|
||||
it('clears cachedOffset for collapsed nodes', () => {
|
||||
const { slotKey, node } = registerCollapsedSlot()
|
||||
const entry = node.slots.get(slotKey)!
|
||||
expect(entry.cachedOffset).toBeDefined()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
expect(entry.cachedOffset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defers sync when canvas is not initialized', () => {
|
||||
mockCanvasState.canvas = null
|
||||
registerCollapsedSlot()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
expect(mockClientPosToCanvasPos).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -136,26 +134,11 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
|
||||
.value?.el.closest('[data-node-id]')
|
||||
const nodeEl = closestNode instanceof HTMLElement ? closestNode : null
|
||||
const nodeRect = nodeEl?.getBoundingClientRect()
|
||||
|
||||
// Collapsed nodes preserve expanded size in layoutStore, so DOM-relative
|
||||
// scale derivation breaks. Fall back to clientPosToCanvasPos instead.
|
||||
const isCollapsed = nodeEl?.dataset.collapsed != null
|
||||
const effectiveScale =
|
||||
!isCollapsed && nodeRect && nodeLayout.size.width > 0
|
||||
nodeRect && nodeLayout.size.width > 0
|
||||
? nodeRect.width / nodeLayout.size.width
|
||||
: 0
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const conv =
|
||||
isCollapsed && canvasStore.canvas
|
||||
? useSharedCanvasPositionConversion()
|
||||
: null
|
||||
|
||||
if (isCollapsed && !conv) {
|
||||
scheduleSlotLayoutSync(nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
const batch: Array<{ key: string; layout: SlotLayout }> = []
|
||||
|
||||
for (const [slotKey, entry] of node.slots) {
|
||||
@@ -172,30 +155,22 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
|
||||
rect.top + rect.height / 2
|
||||
]
|
||||
|
||||
let centerCanvas: { x: number; y: number }
|
||||
if (!nodeRect || effectiveScale <= 0) continue
|
||||
|
||||
if (conv) {
|
||||
const [cx, cy] = conv.clientPosToCanvasPos(screenCenter)
|
||||
centerCanvas = { x: cx, y: cy }
|
||||
entry.cachedOffset = undefined
|
||||
} else {
|
||||
if (!nodeRect || effectiveScale <= 0) continue
|
||||
// DOM-relative measurement: compute offset from the node element's
|
||||
// top-left corner in canvas units. The node element is rendered at
|
||||
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
|
||||
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
|
||||
entry.cachedOffset = {
|
||||
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
|
||||
y:
|
||||
(screenCenter[1] - nodeRect.top) / effectiveScale -
|
||||
LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
// DOM-relative measurement: compute offset from the node element's
|
||||
// top-left corner in canvas units. The node element is rendered at
|
||||
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
|
||||
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
|
||||
entry.cachedOffset = {
|
||||
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
|
||||
y:
|
||||
(screenCenter[1] - nodeRect.top) / effectiveScale -
|
||||
LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
centerCanvas = {
|
||||
x: nodeLayout.position.x + entry.cachedOffset.x,
|
||||
y: nodeLayout.position.y + entry.cachedOffset.y
|
||||
}
|
||||
const centerCanvas = {
|
||||
x: nodeLayout.position.x + entry.cachedOffset.x,
|
||||
y: nodeLayout.position.y + entry.cachedOffset.y
|
||||
}
|
||||
|
||||
const nextLayout = createSlotLayout({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
const {
|
||||
capturedOnPan,
|
||||
@@ -206,7 +205,7 @@ function pointerEvent(
|
||||
clientY: number,
|
||||
pointerId = 1
|
||||
): PointerEvent {
|
||||
return fromPartial<PointerEvent>({
|
||||
return {
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
@@ -218,7 +217,7 @@ function pointerEvent(
|
||||
target: document.createElement('div'),
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
})
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
function startDrag() {
|
||||
|
||||
@@ -15,8 +15,8 @@ import { useDocumentVisibility } from '@vueuse/core'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphExtra } from '@/lib/litegraph/src/LGraph'
|
||||
@@ -36,7 +35,7 @@ function createMockGraph(
|
||||
): Partial<LGraph> {
|
||||
const graph: Partial<LGraph> = {
|
||||
id: crypto.randomUUID(),
|
||||
nodes: fromAny<LGraph['nodes'], unknown>(nodes),
|
||||
nodes: nodes as unknown as LGraph['nodes'],
|
||||
groups: [],
|
||||
reroutes: new Map() as LGraph['reroutes'],
|
||||
extra
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
// TODO: Simplify test setup — use real layoutStore + createTestingPinia instead
|
||||
// of manually mocking every dependency. See https://github.com/Comfy-Org/ComfyUI_frontend/issues/10765
|
||||
const testState = vi.hoisted(() => {
|
||||
// Imports are unavailable inside vi.hoisted() so shoehorn's fromAny cannot
|
||||
// be used here. This local identity function serves the same purpose
|
||||
// (runtime no-op cast) until the test is rewritten to use real stores.
|
||||
const placeholder = <T>(v: unknown): T => v as T
|
||||
return {
|
||||
selectedNodeIds: placeholder<Ref<Set<string>>>(null),
|
||||
selectedItems: placeholder<Ref<unknown[]>>(null),
|
||||
selectedNodeIds: null as unknown as Ref<Set<string>>,
|
||||
selectedItems: null as unknown as Ref<unknown[]>,
|
||||
nodeLayouts: new Map<string, Pick<NodeLayout, 'position' | 'size'>>(),
|
||||
mutationFns: {
|
||||
setSource: vi.fn(),
|
||||
@@ -122,7 +114,12 @@ function pointerEvent(clientX: number, clientY: number): PointerEvent {
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
return fromPartial<PointerEvent>({ clientX, clientY, target, pointerId: 1 })
|
||||
return {
|
||||
clientX,
|
||||
clientY,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
describe('useNodeDrag', () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import DisplayCarousel from './DisplayCarousel.vue'
|
||||
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
@@ -124,10 +124,7 @@ describe('DisplayCarousel Single Mode', () => {
|
||||
|
||||
it('handles null value gracefully', () => {
|
||||
const widget = createGalleriaWidget([])
|
||||
const wrapper = mountComponent(
|
||||
widget,
|
||||
fromAny<GalleryValue, unknown>(null)
|
||||
)
|
||||
const wrapper = mountComponent(widget, null as unknown as GalleryValue)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
@@ -136,7 +133,7 @@ describe('DisplayCarousel Single Mode', () => {
|
||||
const widget = createGalleriaWidget([])
|
||||
const wrapper = mountComponent(
|
||||
widget,
|
||||
fromAny<GalleryValue, unknown>(undefined)
|
||||
undefined as unknown as GalleryValue
|
||||
)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
@@ -341,7 +338,7 @@ describe('DisplayCarousel Grid Mode', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('grid mode has no overlay icons', async () => {
|
||||
it('switches back to single mode via toggle button', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
@@ -350,69 +347,19 @@ describe('DisplayCarousel Grid Mode', () => {
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Grid mode should have no toggle/back button
|
||||
expect(wrapper.find('[aria-label="Switch to single view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('always uses undo-2 icon for grid toggle button', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Show controls
|
||||
// Focus the grid container to reveal toggle
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
const toggleBtn = wrapper.find('[aria-label="Switch to grid view"]')
|
||||
expect(toggleBtn.find('i').classes()).toContain('icon-[lucide--undo-2]')
|
||||
// Switch back to single
|
||||
const singleToggle = wrapper.find('[aria-label="Switch to single view"]')
|
||||
expect(singleToggle.exists()).toBe(true)
|
||||
|
||||
// Switch to grid and back
|
||||
await toggleBtn.trigger('click')
|
||||
await singleToggle.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const gridButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((btn) => btn.find('img').exists())
|
||||
await gridButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Icon should still be undo-2
|
||||
const toggleBtnAfter = wrapper.find('[aria-label="Switch to grid view"]')
|
||||
expect(toggleBtnAfter.find('i').classes()).toContain(
|
||||
'icon-[lucide--undo-2]'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows grid button in single mode after selecting from grid', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Switch to grid
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Click first grid image to go back to single mode
|
||||
const gridButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((btn) => btn.find('img').exists())
|
||||
await gridButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Hover to reveal controls
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Should still show grid view button (same icon always)
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
// Should be back in single mode with main image
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clicking grid image switches to single mode focused on that image', async () => {
|
||||
@@ -454,8 +401,8 @@ describe('DisplayCarousel Grid Mode', () => {
|
||||
await wrapper.setProps({ modelValue: [TEST_IMAGES_SMALL[0]] })
|
||||
await nextTick()
|
||||
|
||||
// Should revert to single mode (single image, no grid button)
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
// Should revert to single mode (no grid toggle visible)
|
||||
expect(wrapper.find('[aria-label="Switch to single view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
:aria-label="t('g.switchToGridView')"
|
||||
@click="switchToGrid"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2] size-4" />
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Action Buttons (hover, top-right) -->
|
||||
@@ -142,19 +142,41 @@
|
||||
ref="gridContainerEl"
|
||||
class="relative h-72 overflow-x-hidden overflow-y-auto rounded-sm bg-component-node-background"
|
||||
tabindex="0"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@focusin="isFocused = true"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<!-- Toggle to Single (hover, top-left) -->
|
||||
<button
|
||||
v-if="showControls"
|
||||
:class="toggleButtonClass"
|
||||
class="absolute top-2 left-2 z-10"
|
||||
:aria-label="t('g.switchToSingleView')"
|
||||
@click="switchToSingle"
|
||||
>
|
||||
<i class="icon-[lucide--square] size-4" />
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap content-start gap-1">
|
||||
<button
|
||||
v-for="(item, index) in galleryImages"
|
||||
:key="getItemSrc(item)"
|
||||
class="size-14 shrink-0 cursor-pointer overflow-hidden border-0 p-0"
|
||||
:aria-label="getItemAlt(item, index)"
|
||||
@mouseenter="hoveredGridIndex = index"
|
||||
@mouseleave="hoveredGridIndex = -1"
|
||||
@click="selectFromGrid(index)"
|
||||
>
|
||||
<img
|
||||
:src="getItemThumbnail(item)"
|
||||
:alt="getItemAlt(item, index)"
|
||||
class="size-full object-cover"
|
||||
:class="
|
||||
cn(
|
||||
'size-full object-cover transition-opacity',
|
||||
hoveredGridIndex === index && 'opacity-50'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -207,6 +229,7 @@ const activeIndex = ref(0)
|
||||
const displayMode = ref<DisplayMode>('single')
|
||||
const isHovered = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const hoveredGridIndex = ref(-1)
|
||||
const imageDimensions = ref<string | null>(null)
|
||||
const thumbnailRefs = ref<(HTMLElement | null)[]>([])
|
||||
const imageContainerEl = ref<HTMLDivElement>()
|
||||
@@ -336,6 +359,11 @@ function switchToGrid() {
|
||||
displayMode.value = 'grid'
|
||||
}
|
||||
|
||||
function switchToSingle() {
|
||||
isHovered.value = false
|
||||
displayMode.value = 'single'
|
||||
}
|
||||
|
||||
function selectFromGrid(index: number) {
|
||||
activeIndex.value = index
|
||||
imageDimensions.value = null
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -10,9 +9,10 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
const mockCheckState = vi.hoisted(() => vi.fn())
|
||||
@@ -121,20 +121,18 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
modelValue: string | undefined,
|
||||
assetKind: 'image' | 'video' | 'audio' = 'image'
|
||||
): VueWrapper<WidgetSelectDropdownInstance> => {
|
||||
return fromAny<VueWrapper<WidgetSelectDropdownInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind,
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind,
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
}) as unknown as VueWrapper<WidgetSelectDropdownInstance>
|
||||
}
|
||||
|
||||
describe('when custom labels are not provided', () => {
|
||||
@@ -260,7 +258,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
it('falls back to original value when label mapping returns undefined', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (value === 'hash789.png') {
|
||||
return fromAny<string, unknown>(undefined)
|
||||
return undefined as unknown as string
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
@@ -367,7 +365,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
|
||||
it('does not create a fallback item when modelValue is undefined', () => {
|
||||
const widget = createSelectDropdownWidget(
|
||||
fromAny<string, unknown>(undefined),
|
||||
undefined as unknown as string,
|
||||
{
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
}
|
||||
@@ -417,20 +415,18 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<CloudModeInstance> => {
|
||||
return fromAny<VueWrapper<CloudModeInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'model',
|
||||
isAssetMode: true,
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'model',
|
||||
isAssetMode: true,
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
}) as unknown as VueWrapper<CloudModeInstance>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -553,12 +549,10 @@ describe('WidgetSelectDropdown multi-output jobs', () => {
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<MultiOutputInstance> {
|
||||
return fromAny<VueWrapper<MultiOutputInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: { widget, modelValue, assetKind: 'image' as const },
|
||||
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
|
||||
})
|
||||
)
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: { widget, modelValue, assetKind: 'image' as const },
|
||||
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
|
||||
}) as unknown as VueWrapper<MultiOutputInstance>
|
||||
}
|
||||
|
||||
const defaultWidget = () =>
|
||||
@@ -750,20 +744,18 @@ describe('WidgetSelectDropdown undo tracking', () => {
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<UndoTrackingInstance> => {
|
||||
return fromAny<VueWrapper<UndoTrackingInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
}) as unknown as VueWrapper<UndoTrackingInstance>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref, shallowRef } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type WidgetValueStoreStub = {
|
||||
_widgetMap: Map<string, { value: unknown }>
|
||||
}
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const mockRendererFactory = vi.hoisted(() => {
|
||||
const init = vi.fn(() => true)
|
||||
@@ -103,7 +99,7 @@ vi.mock('@/utils/objectUrlUtil', () => ({
|
||||
|
||||
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
const graph = { id: 'test-graph-id', rootGraph: { id: 'test-graph-id' } }
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
return {
|
||||
id: 1,
|
||||
type: 'GLSLShader',
|
||||
inputs: [],
|
||||
@@ -111,7 +107,7 @@ function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
getInputNode: vi.fn(() => null),
|
||||
isSubgraphNode: () => false,
|
||||
...overrides
|
||||
})
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function wrapNode(
|
||||
@@ -181,9 +177,9 @@ describe('useGLSLPreview', () => {
|
||||
mockNodeOutputs[String(node.id)] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
|
||||
}
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
const store = useWidgetValueStore() as unknown as {
|
||||
_widgetMap: Map<string, { value: unknown }>
|
||||
}
|
||||
store._widgetMap.set('fragment_shader', {
|
||||
value: 'void main() {}'
|
||||
})
|
||||
@@ -245,9 +241,9 @@ describe('useGLSLPreview', () => {
|
||||
mockNodeOutputs[String(node.id)] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
|
||||
}
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
const store = useWidgetValueStore() as unknown as {
|
||||
_widgetMap: Map<string, { value: unknown }>
|
||||
}
|
||||
store._widgetMap.set('fragment_shader', {
|
||||
value: 'void main() {}'
|
||||
})
|
||||
@@ -303,9 +299,9 @@ describe('useGLSLPreview', () => {
|
||||
})
|
||||
|
||||
it('skips render when shader source is unavailable', async () => {
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
const store = useWidgetValueStore() as unknown as {
|
||||
_widgetMap: Map<string, { value: unknown }>
|
||||
}
|
||||
store._widgetMap.delete('fragment_shader')
|
||||
|
||||
const node = createMockNode()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -196,27 +195,25 @@ describe('appModeStore', () => {
|
||||
outputs: number[]
|
||||
) {
|
||||
const workflow = createBuilderWorkflow('app')
|
||||
workflow.changeTracker = createMockChangeTracker(
|
||||
fromPartial<Partial<ChangeTracker>>({
|
||||
activeState: {
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 0.4,
|
||||
extra: { linearData: { inputs, outputs } }
|
||||
}
|
||||
})
|
||||
)
|
||||
workflow.changeTracker = createMockChangeTracker({
|
||||
activeState: {
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 0.4,
|
||||
extra: { linearData: { inputs, outputs } }
|
||||
}
|
||||
} as unknown as Partial<ChangeTracker>)
|
||||
return workflow
|
||||
}
|
||||
|
||||
it('removes inputs referencing deleted nodes on load', async () => {
|
||||
const node1 = mockNode(1)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
@@ -232,7 +229,7 @@ describe('appModeStore', () => {
|
||||
it('keeps inputs for existing nodes even if widget is missing', async () => {
|
||||
const node1 = mockNode(1)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
@@ -251,7 +248,7 @@ describe('appModeStore', () => {
|
||||
it('removes outputs referencing deleted nodes on load', async () => {
|
||||
const node1 = mockNode(1)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
store.loadSelections({ outputs: [1, 99] })
|
||||
@@ -274,7 +271,7 @@ describe('appModeStore', () => {
|
||||
|
||||
// After graph configures, nodes become resolvable
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
;(app.rootGraph.events as EventTarget).dispatchEvent(
|
||||
new Event('configured')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -392,9 +391,9 @@ describe('clearAllErrors', () => {
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
missingNodesStore.setMissingNodeTypes(
|
||||
fromAny<MissingNodeType[], unknown>([{ type: 'MissingNode', hint: '' }])
|
||||
)
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
{ type: 'MissingNode', hint: '' }
|
||||
] as unknown as MissingNodeType[])
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
executionErrorStore.clearAllErrors()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -32,11 +31,11 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
const createMockNode = (overrides: Record<string, unknown> = {}): LGraphNode =>
|
||||
fromAny<LGraphNode, unknown>({
|
||||
({
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
...overrides
|
||||
})
|
||||
}) as Partial<LGraphNode> as LGraphNode
|
||||
|
||||
const createMockOutputs = (
|
||||
images?: ExecutedWsMessage['output']['images']
|
||||
@@ -624,7 +623,7 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
|
||||
it('should return early for null node', () => {
|
||||
const store = useNodeOutputStore()
|
||||
|
||||
store.setNodeOutputs(fromAny<LGraphNode, unknown>(null), 'test.png')
|
||||
store.setNodeOutputs(null as unknown as LGraphNode, 'test.png')
|
||||
|
||||
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -9,8 +8,8 @@ import type {
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import * as jobOutputCache from '@/services/jobOutputCache'
|
||||
import { TaskItemImpl } from '@/stores/queueStore'
|
||||
import * as jobOutputCache from '@/services/jobOutputCache'
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: vi.fn(() => ({
|
||||
@@ -77,13 +76,13 @@ describe('TaskItemImpl.loadWorkflow - workflow fetching', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockFetchApi = vi.fn()
|
||||
mockApp = fromPartial<ComfyApp>({
|
||||
mockApp = {
|
||||
loadGraphData: vi.fn(),
|
||||
nodeOutputs: {},
|
||||
api: {
|
||||
fetchApi: mockFetchApi
|
||||
}
|
||||
})
|
||||
} as unknown as ComfyApp
|
||||
})
|
||||
|
||||
it('should fetch workflow from API for history tasks', async () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
@@ -109,10 +108,10 @@ describe(parseNodeOutput, () => {
|
||||
})
|
||||
|
||||
it('excludes non-ResultItem array items', () => {
|
||||
const output = fromPartial<NodeExecutionOutput>({
|
||||
const output = {
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
custom_data: [{ randomKey: 123 }]
|
||||
})
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
@@ -121,12 +120,12 @@ describe(parseNodeOutput, () => {
|
||||
})
|
||||
|
||||
it('accepts items with filename but no subfolder', () => {
|
||||
const output = fromPartial<NodeExecutionOutput>({
|
||||
const output = {
|
||||
images: [
|
||||
{ filename: 'valid.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'no-subfolder.png' }
|
||||
]
|
||||
})
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
@@ -137,12 +136,12 @@ describe(parseNodeOutput, () => {
|
||||
})
|
||||
|
||||
it('excludes items missing filename', () => {
|
||||
const output = fromPartial<NodeExecutionOutput>({
|
||||
const output = {
|
||||
images: [
|
||||
{ filename: 'valid.png', subfolder: '', type: 'output' },
|
||||
{ subfolder: '', type: 'output' }
|
||||
]
|
||||
})
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
|
||||
|
||||
type MockSubgraph = Pick<Subgraph, 'id' | 'rootGraph' | '_nodes' | 'nodes'>
|
||||
|
||||
function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph {
|
||||
@@ -20,7 +20,7 @@ function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph {
|
||||
nodes: []
|
||||
} satisfies MockSubgraph
|
||||
|
||||
return fromPartial<Subgraph>(mockSubgraph)
|
||||
return mockSubgraph as unknown as Subgraph
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user