Merge latest main into bl-selective-snapshot-update

Resolved conflicts by:
- Keeping our selective snapshot update logic in workflows
- Accepting main's Vue node refactoring changes
This commit is contained in:
bymyself
2025-10-14 11:03:41 -07:00
17 changed files with 139 additions and 167 deletions

View File

@@ -3,6 +3,8 @@
*/
import type { Locator, Page } from '@playwright/test'
import { VueNodeFixture } from './utils/vueNodeFixtures'
export class VueNodeHelpers {
constructor(private page: Page) {}
@@ -106,6 +108,24 @@ export class VueNodeHelpers {
await this.page.keyboard.press('Backspace')
}
/**
* Return a DOM-focused VueNodeFixture for the first node matching the title.
* Resolves the node id up front so subsequent interactions survive title changes.
*/
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
const node = this.getNodeByTitle(title).first()
await node.waitFor({ state: 'visible' })
const nodeId = await node.evaluate((el) => el.getAttribute('data-node-id'))
if (!nodeId) {
throw new Error(
`Vue node titled "${title}" is missing its data-node-id attribute`
)
}
return new VueNodeFixture(this.getNodeLocator(nodeId))
}
/**
* Wait for Vue nodes to be rendered
*/

View File

@@ -1,131 +1,66 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { NodeReference } from './litegraphUtils'
/**
* VueNodeFixture provides Vue-specific testing utilities for interacting with
* Vue node components. It bridges the gap between litegraph node references
* and Vue UI components.
*/
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
constructor(
private readonly nodeRef: NodeReference,
private readonly page: Page
) {}
constructor(private readonly locator: Locator) {}
/**
* Get the node's header element using data-testid
*/
async getHeader(): Promise<Locator> {
const nodeId = this.nodeRef.id
return this.page.locator(`[data-testid="node-header-${nodeId}"]`)
get header(): Locator {
return this.locator.locator('[data-testid^="node-header-"]')
}
/**
* Get the node's title element
*/
async getTitleElement(): Promise<Locator> {
const header = await this.getHeader()
return header.locator('[data-testid="node-title"]')
get title(): Locator {
return this.locator.locator('[data-testid="node-title"]')
}
get titleInput(): Locator {
return this.locator.locator('[data-testid="node-title-input"]')
}
get body(): Locator {
return this.locator.locator('[data-testid^="node-body-"]')
}
get collapseButton(): Locator {
return this.locator.locator('[data-testid="node-collapse-button"]')
}
get collapseIcon(): Locator {
return this.collapseButton.locator('i')
}
get root(): Locator {
return this.locator
}
/**
* Get the current title text
*/
async getTitle(): Promise<string> {
const titleElement = await this.getTitleElement()
return (await titleElement.textContent()) || ''
return (await this.title.textContent()) ?? ''
}
/**
* Set a new title by double-clicking and entering text
*/
async setTitle(newTitle: string): Promise<void> {
const titleElement = await this.getTitleElement()
await titleElement.dblclick()
const input = (await this.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill(newTitle)
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await expect(input).toBeVisible()
await input.fill(value)
await input.press('Enter')
}
/**
* Cancel title editing
*/
async cancelTitleEdit(): Promise<void> {
const titleElement = await this.getTitleElement()
await titleElement.dblclick()
const input = (await this.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await this.header.dblclick()
const input = this.titleInput
await expect(input).toBeVisible()
await input.press('Escape')
}
/**
* Check if the title is currently being edited
*/
async isEditingTitle(): Promise<boolean> {
const header = await this.getHeader()
const input = header.locator('[data-testid="node-title-input"]')
return await input.isVisible()
}
/**
* Get the collapse/expand button
*/
async getCollapseButton(): Promise<Locator> {
const header = await this.getHeader()
return header.locator('[data-testid="node-collapse-button"]')
}
/**
* Toggle the node's collapsed state
*/
async toggleCollapse(): Promise<void> {
const button = await this.getCollapseButton()
await button.click()
await this.collapseButton.click()
}
/**
* Get the collapse icon element
*/
async getCollapseIcon(): Promise<Locator> {
const button = await this.getCollapseButton()
return button.locator('i')
}
/**
* Get the collapse icon's CSS classes
*/
async getCollapseIconClass(): Promise<string> {
const icon = await this.getCollapseIcon()
return (await icon.getAttribute('class')) || ''
return (await this.collapseIcon.getAttribute('class')) ?? ''
}
/**
* Check if the collapse button is visible
*/
async isCollapseButtonVisible(): Promise<boolean> {
const button = await this.getCollapseButton()
return await button.isVisible()
}
/**
* Get the node's body/content element
*/
async getBody(): Promise<Locator> {
const nodeId = this.nodeRef.id
return this.page.locator(`[data-testid="node-body-${nodeId}"]`)
}
/**
* Check if the node body is visible (not collapsed)
*/
async isBodyVisible(): Promise<boolean> {
const body = await this.getBody()
return await body.isVisible()
boundingBox(): ReturnType<Locator['boundingBox']> {
return this.locator.boundingBox()
}
}

View File

@@ -2,70 +2,46 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Nodes Renaming', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('should display node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.header).toContainText('KSampler')
})
test('should allow title renaming by double clicking on the node header', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
await expect(vueNode.header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await vueNode.title.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await vueNode.titleInput.fill('This Should Be Cancelled')
await vueNode.titleInput.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
})
test('Double click node body does not trigger edit', async ({
comfyPage
}) => {
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const loadCheckpointNode = comfyPage.vueNodes
.getNodeByTitle('Load Checkpoint')
.first()
const nodeBbox = await loadCheckpointNode.boundingBox()
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()

View File

@@ -50,15 +50,23 @@ test.describe('Vue Node Selection', () => {
})
}
test('should select all nodes with ctrl+a', async ({ comfyPage }) => {
const initialCount = await comfyPage.vueNodes.getNodeCount()
expect(initialCount).toBeGreaterThan(0)
await comfyPage.canvas.press('Control+a')
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialCount)
})
test('should select pinned node without dragging', async ({ comfyPage }) => {
const PIN_HOTKEY = 'p'
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
// Select a node by clicking its title
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
// Pin it using the hotkey (as a user would)
await comfyPage.page.keyboard.press(PIN_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Node Collapse', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -10,43 +9,50 @@ test.describe('Vue Node Collapse', () => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('should allow collapsing node with collapse icon', async ({
comfyPage
}) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
// Initially should not be collapsed
expect(await node.isCollapsed()).toBe(false)
const body = await vueNode.getBody()
const body = vueNode.body
await expect(body).toBeVisible()
const expandedBoundingBox = await vueNode.boundingBox()
if (!expandedBoundingBox)
throw new Error('Failed to get node bounding box before collapse')
// Collapse the node
await vueNode.toggleCollapse()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.nextFrame()
// Verify node content is hidden
const collapsedSize = await node.getSize()
await expect(body).not.toBeVisible()
const collapsedBoundingBox = await vueNode.boundingBox()
if (!collapsedBoundingBox)
throw new Error('Failed to get node bounding box after collapse')
expect(collapsedBoundingBox.height).toBeLessThan(expandedBoundingBox.height)
// Expand again
await vueNode.toggleCollapse()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.nextFrame()
await expect(body).toBeVisible()
// Size should be restored
const expandedSize = await node.getSize()
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
const expandedBoundingBoxAfter = await vueNode.boundingBox()
if (!expandedBoundingBoxAfter)
throw new Error('Failed to get node bounding box after expand')
expect(expandedBoundingBoxAfter.height).toBeGreaterThanOrEqual(
collapsedBoundingBox.height
)
})
test('should show collapse/expand icon state', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
// Check initial expanded state icon
let iconClass = await vueNode.getCollapseIconClass()
@@ -66,9 +72,8 @@ test.describe('Vue Node Collapse', () => {
test('should preserve title when collapsing/expanding', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
// Set custom title
await vueNode.setTitle('Test Sampler')
@@ -83,7 +88,6 @@ test.describe('Vue Node Collapse', () => {
expect(await vueNode.getTitle()).toBe('Test Sampler')
// Verify title is still displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('Test Sampler')
await expect(vueNode.header).toContainText('Test Sampler')
})
})

View File

@@ -9,8 +9,14 @@
"downloadImage": "Download image",
"downloadVideo": "Download video",
"editOrMaskImage": "Edit or mask image",
"editImage": "Edit image",
"deleteImage": "Delete image",
"deleteAudioFile": "Delete audio file",
"removeImage": "Remove image",
"removeVideo": "Remove video",
"chart": "Chart",
"chartLowercase": "chart",
"file": "file",
"viewImageOfTotal": "View image {index} of {total}",
"viewVideoOfTotal": "View video {index} of {total}",
"imagePreview": "Image preview - Use arrow keys to navigate between images",

View File

@@ -3,7 +3,12 @@
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button v-bind="filteredProps" size="small" @click="handleClick" />
<Button
v-bind="filteredProps"
:aria-label="widget.name || widget.label"
size="small"
@click="handleClick"
/>
</div>
</template>

View File

@@ -3,7 +3,12 @@
<div
class="max-h-[48rem] rounded border border-gray-300 p-4 dark-theme:border-gray-600"
>
<Chart :type="chartType" :data="chartData" :options="chartOptions" />
<Chart
:type="chartType"
:data="chartData"
:options="chartOptions"
:aria-label="`${widget.name || $t('g.chart')} - ${chartType} ${$t('g.chartLowercase')}`"
/>
</div>
</div>
</template>

View File

@@ -10,6 +10,7 @@
v-model="localValue"
v-bind="filteredProps"
class="h-4 w-8 overflow-hidden !rounded-full border-none"
:aria-label="widget.name"
:pt="{
preview: '!w-full !h-full !border-none'
}"

View File

@@ -50,6 +50,7 @@
>
<!-- Edit button -->
<button
:aria-label="$t('g.editImage')"
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
style="background-color: #262729"
@click="handleEdit"
@@ -58,6 +59,7 @@
</button>
<!-- Delete button -->
<button
:aria-label="$t('g.deleteImage')"
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
style="background-color: #262729"
@click="clearFile"
@@ -128,6 +130,7 @@
<div class="flex gap-1">
<!-- Delete button -->
<button
:aria-label="$t('g.deleteAudioFile')"
class="flex h-8 w-8 items-center justify-center rounded border-none transition-all duration-150 hover:bg-[#262729] focus:outline-none"
@click="clearFile"
>
@@ -168,6 +171,7 @@
type="file"
class="hidden"
:accept="widget.options?.accept"
:aria-label="`${$t('g.upload')} ${widget.name || $t('g.file')}`"
:multiple="false"
@change="handleFileChange"
/>

View File

@@ -10,6 +10,7 @@
v-bind="filteredProps"
class="flex-grow text-xs"
:step="stepValue"
:aria-label="widget.name"
@update:model-value="updateLocalValue"
/>
<InputNumber
@@ -19,6 +20,7 @@
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
:aria-label="widget.name"
size="small"
pt:pc-input-text:root="min-w-full bg-transparent border-none text-center"
class="w-16"

View File

@@ -4,6 +4,7 @@
v-model="localValue"
v-bind="filteredProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs py-2 px-4')"
:aria-label="widget.name"
size="small"
@update:model-value="onChange"
/>

View File

@@ -5,6 +5,7 @@
:options="multiSelectOptions"
v-bind="combinedProps"
class="w-full text-xs"
:aria-label="widget.name"
size="small"
display="chip"
:pt="{

View File

@@ -5,6 +5,7 @@
:options="selectOptions"
v-bind="combinedProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:aria-label="widget.name"
size="small"
:pt="{
option: 'text-xs'

View File

@@ -5,6 +5,7 @@
v-bind="filteredProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs lod-toggle')"
:placeholder="placeholder || widget.name || ''"
:aria-label="widget.name"
size="small"
rows="3"
data-capture-wheel="true"

View File

@@ -3,6 +3,7 @@
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:aria-label="widget.name"
@update:model-value="onChange"
/>
</WidgetLayoutField>

View File

@@ -4,6 +4,7 @@
v-model="localValue"
v-bind="combinedProps"
class="w-full text-xs"
:aria-label="widget.name"
size="small"
@update:model-value="onChange"
/>