Compare commits

..

2 Commits

Author SHA1 Message Date
dante01yoon
cf45762ed2 chore: trigger CI 2026-04-01 10:31:16 +09:00
dante01yoon
6f62f87a0b test(assets): add E2E tests for sort functionality 2026-04-01 10:11:07 +09:00
29 changed files with 395 additions and 429 deletions

View File

@@ -14,12 +14,9 @@ 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 { ConfirmDialog } from '@e2e/fixtures/components/ConfirmDialog'
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
@@ -36,6 +33,7 @@ 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 { QueueHelper } from '@e2e/fixtures/helpers/QueueHelper'
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
@@ -119,6 +117,48 @@ class ComfyMenu {
}
}
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
class ConfirmDialog {
public readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
public readonly confirm: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
export class ComfyPage {
public readonly url: string
// All canvas position operations are based on default view of canvas.
@@ -142,8 +182,6 @@ export class ComfyPage {
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly templatesDialog: TemplatesDialog
public readonly mediaLightbox: MediaLightbox
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
@@ -162,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[] = []
@@ -191,8 +230,6 @@ export class ComfyPage {
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.templatesDialog = new TemplatesDialog(page)
this.mediaLightbox = new MediaLightbox(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
@@ -211,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() {

View File

@@ -1,44 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
export class ConfirmDialog {
public readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
public readonly confirm: Locator
public readonly save: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
this.save = this.root.getByRole('button', { name: 'Save' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for this confirm dialog to close (not all dialogs — another
// dialog like save-as may open immediately after).
await this.root.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}

View File

@@ -1,11 +0,0 @@
import type { Locator, Page } from '@playwright/test'
export class MediaLightbox {
public readonly root: Locator
public readonly closeButton: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.closeButton = this.root.getByLabel('Close')
}
}

View File

@@ -227,6 +227,14 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByText('Oldest first')
}
get sortLongestFirst() {
return this.page.getByText('Generation time (longest first)')
}
get sortFastestFirst() {
return this.page.getByText('Generation time (fastest first)')
}
// --- Asset cards ---
get assetCards() {

View File

@@ -1,19 +0,0 @@
import type { Locator, Page } from '@playwright/test'
export class TemplatesDialog {
public readonly root: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
}
filterByHeading(name: string): Locator {
return this.root.filter({
has: this.page.getByRole('heading', { name, exact: true })
})
}
getCombobox(name: RegExp | string): Locator {
return this.root.getByRole('combobox', { name })
}
}

View 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
}
}
}

View File

@@ -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}`)
}

View File

@@ -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)
})
}
)

View File

@@ -18,13 +18,15 @@ test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
.catch(() => {})
}, longFilename)
const { root, confirm, reject } = comfyPage.confirmDialog
await expect(root).toBeVisible()
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(confirm).toBeVisible()
await expect(confirm).toBeInViewport()
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
await expect(confirmButton).toBeVisible()
await expect(confirmButton).toBeInViewport()
await expect(reject).toBeVisible()
await expect(reject).toBeInViewport()
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await expect(cancelButton).toBeVisible()
await expect(cancelButton).toBeInViewport()
})
})

View File

@@ -41,28 +41,30 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
await assetCard.hover()
await assetCard.getByLabel('Zoom in').click()
const { root } = comfyPage.mediaLightbox
await expect(root).toBeVisible()
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()
return { gallery }
}
test('opens gallery and shows dialog with close button', async ({
comfyPage
}) => {
await runAndOpenGallery(comfyPage)
await expect(comfyPage.mediaLightbox.closeButton).toBeVisible()
const { gallery } = await runAndOpenGallery(comfyPage)
await expect(gallery.getByLabel('Close')).toBeVisible()
})
test('closes gallery on Escape key', async ({ comfyPage }) => {
await runAndOpenGallery(comfyPage)
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
await runAndOpenGallery(comfyPage)
const { gallery } = await runAndOpenGallery(comfyPage)
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
await gallery.getByLabel('Close').click()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
})

View File

@@ -667,3 +667,114 @@ test.describe('Assets sidebar - settings menu', () => {
await expect(tab.gridViewOption).toBeVisible()
})
})
// ==========================================================================
// 11. Sort functionality (cloud-only — sort options require DISTRIBUTION=cloud)
// ==========================================================================
// Sort options are guarded by isCloud (compile-time). The cloud CI project
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
// supports authenticated comfyPage setup.
test.describe('Assets sidebar - sort', () => {
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
const SORT_JOBS: RawJobListItem[] = [
createMockJob({
id: 'sort-oldest',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'oldest_asset.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'sort-middle',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2003,
preview_output: {
filename: 'middle_asset.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
}
}),
createMockJob({
id: 'sort-newest',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3020,
preview_output: {
filename: 'newest_asset.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
}
})
]
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SORT_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Assets display in newest-first order by default', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(3)
const firstCard = tab.assetCards.first()
await expect(firstCard).toContainText('newest_asset')
})
test('Sort by oldest first reverses order', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(3)
await tab.openSettingsMenu()
await tab.sortOldestFirst.click()
await expect(tab.assetCards.first()).toContainText('oldest_asset')
await expect(tab.assetCards.last()).toContainText('newest_asset')
})
test('Sort by longest execution duration', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(3)
await tab.openSettingsMenu()
await tab.sortLongestFirst.click()
// Durations: newest=20s, oldest=10s, middle=3s
await expect(tab.assetCards.first()).toContainText('newest_asset')
await expect(tab.assetCards.last()).toContainText('middle_asset')
})
test('Sort by fastest execution duration', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(3)
await tab.openSettingsMenu()
await tab.sortFastestFirst.click()
// Durations: middle=3s, oldest=10s, newest=20s
await expect(tab.assetCards.first()).toContainText('middle_asset')
await expect(tab.assetCards.last()).toContainText('newest_asset')
})
})

View File

@@ -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)
}
}
})
})

View File

@@ -116,7 +116,9 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const dialog = comfyPage.templatesDialog.filterByHeading('Modèles')
const dialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.page.getByRole('heading', { name: 'Modèles', exact: true })
})
await expect(dialog).toBeVisible()
// Validate that French-localized strings from the templates index are rendered
@@ -218,7 +220,8 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const sortBySelect = comfyPage.templatesDialog.getCombobox(/Sort/)
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect

View File

@@ -460,8 +460,11 @@ test.describe('Workflow Persistence', () => {
.getWorkflowTab('Unsaved Workflow')
.click({ button: 'middle' })
// Click "Save" in the dirty close dialog
await comfyPage.confirmDialog.click('save')
// 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()

View File

@@ -1,4 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -15,7 +15,7 @@ describe('getDomWidgetZIndex', () => {
first.order = 0
second.order = 1
const nodes = fromPartial<{ _nodes: LGraphNode[] }>(graph)._nodes
const nodes = fromAny<{ _nodes: LGraphNode[] }, unknown>(graph)._nodes
nodes.splice(nodes.indexOf(first), 1)
nodes.push(first)

View File

@@ -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>

View File

@@ -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"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-2">
<div class="space-y-4">
<label>{{ $t('load3d.lightIntensity') }}</label>
<Slider

View File

@@ -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"

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -31,11 +31,12 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
fromPartial<
fromAny<
Array<{
name: string
_widget?: IBaseWidget
}>
}>,
unknown
>([aliasInput, exactInput]),
targetWidget
)
@@ -50,7 +51,9 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
fromPartial<Array<{ name: string; _widget?: IBaseWidget }>>([aliasInput]),
fromAny<Array<{ name: string; _widget?: IBaseWidget }>, unknown>([
aliasInput
]),
targetWidget
)
@@ -67,11 +70,12 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
fromPartial<
fromAny<
Array<{
name: string
_widget?: IBaseWidget
}>
}>,
unknown
>([firstAliasInput, secondAliasInput]),
targetWidget
)

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
@@ -293,7 +293,7 @@ describe(createPromotedWidgetView, () => {
value: 'initial',
options: {}
} satisfies Pick<IBaseWidget, 'name' | 'type' | 'value' | 'options'>
const fallbackWidget = fromPartial<IBaseWidget>(fallbackWidgetShape)
const fallbackWidget = fromAny<IBaseWidget, unknown>(fallbackWidgetShape)
innerNode.widgets = [fallbackWidget]
const widgetValueStore = useWidgetValueStore()

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -30,7 +30,7 @@ function widget(
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
return fromAny<IBaseWidget, unknown>({ name: 'widget', ...overrides })
}
describe('isPreviewPseudoWidget', () => {

View File

@@ -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()
})
})
})

View File

@@ -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({

View File

@@ -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 {

View File

@@ -32,7 +32,7 @@ vi.mock('@/scripts/app', () => ({
}))
const createMockNode = (overrides: Record<string, unknown> = {}): LGraphNode =>
fromAny<LGraphNode, unknown>({
fromAny<LGraphNode, Record<string, unknown>>({
id: 1,
type: 'TestNode',
...overrides

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -20,7 +20,7 @@ function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph {
nodes: []
} satisfies MockSubgraph
return fromPartial<Subgraph>(mockSubgraph)
return fromAny<Subgraph, unknown>(mockSubgraph)
}
vi.mock('@/scripts/app', () => {

View File

@@ -1,4 +1,4 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -50,7 +50,7 @@ describe('getWidgetDefaultValue', () => {
})
function makeWidget(overrides: Record<string, unknown> = {}): IBaseWidget {
return fromPartial<IBaseWidget>({
return fromAny<IBaseWidget, unknown>({
name: 'myWidget',
type: 'number',
value: 0,