mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 22:21:51 +00:00
Compare commits
12 Commits
glary/test
...
glary/widg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4ad6ed92 | ||
|
|
86b6cab5e9 | ||
|
|
0aefef7c42 | ||
|
|
4c7729ee0b | ||
|
|
40083d593b | ||
|
|
7089a7d1a0 | ||
|
|
3b4811b00d | ||
|
|
b756545f59 | ||
|
|
da91bdc957 | ||
|
|
cf3006f82c | ||
|
|
be2d757c47 | ||
|
|
54f3127658 |
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -98,3 +98,50 @@ jobs:
|
||||
flags: e2e
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
path: coverage/html/
|
||||
retention-days: 30
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Download HTML report
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
path: coverage/html
|
||||
|
||||
- name: Upload to GitHub Pages
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
|
||||
with:
|
||||
path: coverage/html
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [400, 50],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Note",
|
||||
"pos": [50, 50],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["This is a reference note"],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [50, 250],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["# Markdown heading"],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
31
browser_tests/assets/widgets/combo_control_widget.json
Normal file
31
browser_tests/assets/widgets/combo_control_widget.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "DevToolsNodeWithComboControlWidget",
|
||||
"pos": [20, 50],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "DevToolsNodeWithComboControlWidget"
|
||||
},
|
||||
"widgets_values": ["Option A", "fixed", ""]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -449,6 +449,25 @@ export class SubgraphHelper {
|
||||
await this.comfyPage.contextMenu.waitForHidden()
|
||||
}
|
||||
|
||||
async getInnerControlWidgetLabels(): Promise<string[]> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n: { isSubgraphNode?: () => boolean }) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
) as { subgraph?: Subgraph } | undefined
|
||||
if (!subgraphNode?.subgraph) return []
|
||||
const innerNodes = Array.from(subgraphNode.subgraph.nodes.values())
|
||||
return innerNodes.flatMap((n: { widgets?: Array<{ label?: string }> }) =>
|
||||
(n.widgets ?? [])
|
||||
.filter((w: { label?: string }) =>
|
||||
(w.label ?? '').includes('control')
|
||||
)
|
||||
.map((w: { label?: string }) => w.label!)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async findSubgraphNodeId(): Promise<string> {
|
||||
const id = await this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
@@ -131,6 +131,38 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
expect(switched).toBe(true)
|
||||
})
|
||||
|
||||
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
|
||||
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
|
||||
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
|
||||
|
||||
try {
|
||||
await comfyPage.settings.setSetting(settingId, !initialValue)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
|
||||
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.LiteGraph!.middle_click_slot_add_default_node
|
||||
)
|
||||
)
|
||||
.toBe(!initialValue)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting(settingId, initialValue)
|
||||
}
|
||||
})
|
||||
|
||||
test('Dropdown setting can be changed and persists', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Note Node API Export', { tag: '@node' }, () => {
|
||||
test('excludes Note and MarkdownNote from API format export', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const classTypes = Object.values(apiWorkflow).map((n) => n.class_type)
|
||||
expect(classTypes, 'API output should not contain Note').not.toContain(
|
||||
'Note'
|
||||
)
|
||||
expect(
|
||||
classTypes,
|
||||
'API output should not contain MarkdownNote'
|
||||
).not.toContain('MarkdownNote')
|
||||
expect(
|
||||
Object.keys(apiWorkflow),
|
||||
'All-virtual workflow should produce empty API output'
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('preserves real nodes while filtering virtual ones', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const entries = Object.values(apiWorkflow)
|
||||
expect(entries, 'Exactly one real node in API output').toHaveLength(1)
|
||||
expect(entries[0].class_type).toBe('KSampler')
|
||||
})
|
||||
|
||||
test('standard workflow export still includes Note nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
const noteNodes = workflow.nodes.filter(
|
||||
(n) => n.type === 'Note' || n.type === 'MarkdownNote'
|
||||
)
|
||||
expect(
|
||||
noteNodes,
|
||||
'Standard export must preserve both Note and MarkdownNote'
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('no virtual node types leak through graphToPrompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
|
||||
|
||||
const virtualNodeCheck = await comfyPage.page.evaluate(async () => {
|
||||
const { output } = await window.app!.graphToPrompt()
|
||||
const virtualTypes = ['Note', 'MarkdownNote', 'Reroute', 'PrimitiveNode']
|
||||
const leaked: string[] = []
|
||||
for (const node of Object.values(output)) {
|
||||
if (virtualTypes.includes(node.class_type)) {
|
||||
leaked.push(node.class_type)
|
||||
}
|
||||
}
|
||||
return { leaked, totalNodes: Object.keys(output).length }
|
||||
})
|
||||
|
||||
expect(
|
||||
virtualNodeCheck.leaked,
|
||||
'No virtual node types should leak into API output'
|
||||
).toHaveLength(0)
|
||||
expect(virtualNodeCheck.totalNodes).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
251
browser_tests/tests/numberControlWidget.spec.ts
Normal file
251
browser_tests/tests/numberControlWidget.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can drag adjust value', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/seed_widget')
|
||||
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
const widget = await node.getWidget(0)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.widgetValue = undefined
|
||||
const widget = window.app!.graph!.nodes[0].widgets![0]
|
||||
widget.callback = (value: number) => {
|
||||
window.widgetValue = value
|
||||
}
|
||||
})
|
||||
await widget.dragHorizontal(50)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
|
||||
.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('WidgetControlMode setting', { tag: '@widget' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
})
|
||||
|
||||
test('Changing mode to "before" updates control widget labels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node?.widgets
|
||||
?.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label)
|
||||
}, ksampler.id)
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node?.widgets
|
||||
?.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label)
|
||||
}, ksampler.id)
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
|
||||
})
|
||||
|
||||
test('Changing mode back to "after" restores labels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node?.widgets
|
||||
?.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label)
|
||||
}, ksampler.id)
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
|
||||
})
|
||||
|
||||
test('Mode change updates control widgets across multiple nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('KSampler')
|
||||
node!.pos = [400, 30]
|
||||
window.app!.graph!.add(node!)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ksamplers = window.app!.graph!.nodes.filter(
|
||||
(n) => n.type === 'KSampler'
|
||||
)
|
||||
return (
|
||||
ksamplers.length === 2 &&
|
||||
ksamplers.every((n) => {
|
||||
const controlLabels = (n.widgets ?? [])
|
||||
.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label ?? '')
|
||||
return (
|
||||
controlLabels.length > 0 &&
|
||||
controlLabels.every((label) => label.includes('before'))
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Nodes without widgets are skipped without error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Reroute')
|
||||
if (node) {
|
||||
node.pos = [400, 30]
|
||||
window.app!.graph!.add(node)
|
||||
}
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node?.widgets
|
||||
?.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label)
|
||||
}, ksampler.id)
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
|
||||
})
|
||||
|
||||
test('Canvas is marked dirty after mode change', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const w = window as Window & { __canvasDirtied?: boolean }
|
||||
w.__canvasDirtied = false
|
||||
const origSetDirty = window.app!.canvas.setDirty.bind(window.app!.canvas)
|
||||
window.app!.canvas.setDirty = (
|
||||
...args: Parameters<typeof origSetDirty>
|
||||
) => {
|
||||
w.__canvasDirtied = true
|
||||
return origSetDirty(...args)
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() =>
|
||||
(window as Window & { __canvasDirtied?: boolean }).__canvasDirtied
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Mode change updates combo control widget labels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
await comfyPage.workflow.loadWorkflow('widgets/combo_control_widget')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
return (node?.widgets ?? [])
|
||||
.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label!)
|
||||
})
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
return (node?.widgets ?? [])
|
||||
.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label!)
|
||||
})
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
|
||||
})
|
||||
|
||||
test('Mode change propagates to linkedWidgets on control widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// linkedWidgets is only set on main widgets, never on control widgets
|
||||
// themselves. This covers the defensive code path (GraphCanvas.vue:360-362).
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
if (!node?.widgets) return
|
||||
const controlWidget = node.widgets.find((w) =>
|
||||
(w.label ?? '').includes('control')
|
||||
)
|
||||
if (!controlWidget) return
|
||||
const mockLinked = Object.create(null)
|
||||
mockLinked.name = 'mock_filter'
|
||||
mockLinked.label = 'control after generate'
|
||||
mockLinked.type = 'string'
|
||||
mockLinked.value = ''
|
||||
controlWidget.linkedWidgets = [mockLinked]
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
const controlWidget = node?.widgets?.find((w) =>
|
||||
(w.label ?? '').includes('control')
|
||||
)
|
||||
const linked = controlWidget?.linkedWidgets ?? []
|
||||
return [controlWidget?.label, ...linked.map((l) => l.label ?? '')]
|
||||
})
|
||||
)
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('before'),
|
||||
expect.stringContaining('before')
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -538,3 +538,30 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'WidgetControlMode in subgraphs',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
})
|
||||
|
||||
test('Mode change updates control widget labels inside subgraph nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getInnerControlWidgetLabels())
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getInnerControlWidgetLabels())
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
|
||||
test(
|
||||
'Empty state matches screenshot baseline',
|
||||
'Empty state matches the screenshot baseline',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -137,28 +137,6 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can drag adjust value', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/seed_widget')
|
||||
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
const widget = await node.getWidget(0)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.widgetValue = undefined
|
||||
const widget = window.app!.graph!.nodes[0].widgets![0]
|
||||
widget.callback = (value: number) => {
|
||||
window.widgetValue = value
|
||||
}
|
||||
})
|
||||
await widget.dragHorizontal(50)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
|
||||
.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Dynamic widget manipulation',
|
||||
{ tag: ['@screenshot', '@widget'] },
|
||||
|
||||
@@ -102,7 +102,6 @@
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "catalog:",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "catalog:",
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -267,9 +267,6 @@ catalogs:
|
||||
jsonata:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: ^0.7.3
|
||||
version: 0.7.3
|
||||
knip:
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1
|
||||
@@ -557,9 +554,6 @@ importers:
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: 'catalog:'
|
||||
version: 0.7.3
|
||||
loglevel:
|
||||
specifier: ^1.9.2
|
||||
version: 1.9.2
|
||||
@@ -1780,9 +1774,6 @@ packages:
|
||||
'@cyberalien/svg-utils@1.1.1':
|
||||
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
|
||||
|
||||
'@dmsnell/diff-match-patch@1.1.0':
|
||||
resolution: {integrity: sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==}
|
||||
|
||||
'@dual-bundle/import-meta-resolve@4.2.1':
|
||||
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
|
||||
|
||||
@@ -7269,11 +7260,6 @@ packages:
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
jsondiffpatch@0.7.3:
|
||||
resolution: {integrity: sha512-zd4dqFiXSYyant2WgSXAZ9+yYqilNVvragVNkNRn2IFZKgjyULNrKRznqN4Zon0MkLueCg+3QaPVCnDAVP20OQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
@@ -11239,8 +11225,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@dmsnell/diff-match-patch@1.1.0': {}
|
||||
|
||||
'@dual-bundle/import-meta-resolve@4.2.1': {}
|
||||
|
||||
'@emmetio/abbreviation@2.3.3':
|
||||
@@ -17140,10 +17124,6 @@ snapshots:
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
jsondiffpatch@0.7.3:
|
||||
dependencies:
|
||||
'@dmsnell/diff-match-patch': 1.1.0
|
||||
|
||||
jsonfile@6.2.0:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
|
||||
@@ -90,7 +90,6 @@ catalog:
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^6.3.1
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const TARGET = 80
|
||||
const MILESTONE_STEP = 5
|
||||
const MIN_DELTA = 0.05
|
||||
const BAR_WIDTH = 20
|
||||
|
||||
interface CoverageData {
|
||||
@@ -71,8 +72,9 @@ function formatPct(value: number): string {
|
||||
}
|
||||
|
||||
function formatDelta(delta: number): string {
|
||||
const sign = delta >= 0 ? '+' : ''
|
||||
return sign + delta.toFixed(1) + '%'
|
||||
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
|
||||
const sign = rounded >= 0 ? '+' : ''
|
||||
return sign + rounded.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function crossedMilestone(prev: number, curr: number): number | null {
|
||||
@@ -150,15 +152,18 @@ function main() {
|
||||
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
|
||||
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
|
||||
|
||||
const unitImproved =
|
||||
unitCurrent !== null &&
|
||||
unitBaseline !== null &&
|
||||
unitCurrent.percentage > unitBaseline.percentage
|
||||
const unitDelta =
|
||||
unitCurrent !== null && unitBaseline !== null
|
||||
? unitCurrent.percentage - unitBaseline.percentage
|
||||
: 0
|
||||
|
||||
const e2eImproved =
|
||||
e2eCurrent !== null &&
|
||||
e2eBaseline !== null &&
|
||||
e2eCurrent.percentage > e2eBaseline.percentage
|
||||
const e2eDelta =
|
||||
e2eCurrent !== null && e2eBaseline !== null
|
||||
? e2eCurrent.percentage - e2eBaseline.percentage
|
||||
: 0
|
||||
|
||||
const unitImproved = unitDelta >= MIN_DELTA
|
||||
const e2eImproved = e2eDelta >= MIN_DELTA
|
||||
|
||||
if (!unitImproved && !e2eImproved) {
|
||||
process.exit(0)
|
||||
@@ -172,12 +177,12 @@ function main() {
|
||||
)
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitCurrent && unitBaseline) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
|
||||
if (unitImproved) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
|
||||
}
|
||||
|
||||
if (e2eCurrent && e2eBaseline) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
|
||||
if (e2eImproved) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
|
||||
}
|
||||
|
||||
summaryLines.push('')
|
||||
|
||||
86
src/components/ui/button/Button.test.ts
Normal file
86
src/components/ui/button/Button.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Button from './Button.vue'
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders slot content inside a button by default', () => {
|
||||
render(Button, {
|
||||
slots: { default: 'Click me' }
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fires click events when enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(Button, {
|
||||
slots: { default: 'Click me' },
|
||||
attrs: { onClick }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Click me' }))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides slot content, shows a spinner, and disables the button while loading', () => {
|
||||
const { container } = render(Button, {
|
||||
props: { loading: true },
|
||||
slots: { default: 'Submit' }
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Submit')).not.toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue spinner icon has no accessible role
|
||||
expect(container.querySelector('.pi-spin')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not fire click when loading', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(Button, {
|
||||
props: { loading: true },
|
||||
attrs: { onClick }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the button when disabled prop is true', () => {
|
||||
render(Button, {
|
||||
props: { disabled: true },
|
||||
slots: { default: 'Nope' }
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Nope' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders as an anchor when as="a"', () => {
|
||||
const { container } = render(Button, {
|
||||
props: { as: 'a' },
|
||||
slots: { default: 'Link' }
|
||||
})
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access -- root element tag is the contract under test
|
||||
const root = container.firstElementChild
|
||||
expect(root?.tagName).toBe('A')
|
||||
})
|
||||
|
||||
it('applies variant classes through buttonVariants', () => {
|
||||
render(Button, {
|
||||
props: { variant: 'primary' },
|
||||
slots: { default: 'Primary' }
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Primary' })).toHaveClass(
|
||||
'bg-primary-background'
|
||||
)
|
||||
})
|
||||
})
|
||||
141
src/components/ui/slider/Slider.test.ts
Normal file
141
src/components/ui/slider/Slider.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import Slider from './Slider.vue'
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('Slider', () => {
|
||||
it('renders a single thumb with role="slider" for a single-value model', async () => {
|
||||
render(Slider, { props: { modelValue: [50] } })
|
||||
await flush()
|
||||
|
||||
const thumbs = screen.getAllByRole('slider')
|
||||
expect(thumbs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders one thumb per value for a range model', async () => {
|
||||
render(Slider, { props: { modelValue: [20, 50] } })
|
||||
await flush()
|
||||
|
||||
const thumbs = screen.getAllByRole('slider')
|
||||
expect(thumbs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('exposes min/max/step via ARIA on the thumb', async () => {
|
||||
render(Slider, {
|
||||
props: { modelValue: [10], min: 0, max: 200, step: 5 }
|
||||
})
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '200')
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '10')
|
||||
})
|
||||
|
||||
it('emits update:modelValue with an increased value on ArrowRight', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
|
||||
|
||||
render(Slider, {
|
||||
props: {
|
||||
modelValue: [50],
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
const latest = onUpdate.mock.calls.at(-1)?.[0]
|
||||
expect(latest?.[0]).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('emits update:modelValue with a decreased value on ArrowLeft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
|
||||
|
||||
render(Slider, {
|
||||
props: {
|
||||
modelValue: [50],
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
const latest = onUpdate.mock.calls.at(-1)?.[0]
|
||||
expect(latest?.[0]).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('respects step size when emitting updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
|
||||
|
||||
render(Slider, {
|
||||
props: {
|
||||
modelValue: [50],
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 10,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith([60])
|
||||
})
|
||||
|
||||
it('marks the root as disabled when disabled prop is set', async () => {
|
||||
const { container } = render(Slider, {
|
||||
props: { modelValue: [30], disabled: true }
|
||||
})
|
||||
await flush()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Reka exposes disabled state as a data attribute on the root
|
||||
const root = container.querySelector('[data-slot="slider"]')
|
||||
expect(root).toHaveAttribute('data-disabled')
|
||||
})
|
||||
|
||||
it('does not emit updates via keyboard when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(Slider, {
|
||||
props: {
|
||||
modelValue: [50],
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
71
src/components/ui/textarea/Textarea.test.ts
Normal file
71
src/components/ui/textarea/Textarea.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Textarea from './Textarea.vue'
|
||||
|
||||
describe('Textarea', () => {
|
||||
it('renders a textarea element', () => {
|
||||
render(Textarea)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInstanceOf(HTMLTextAreaElement)
|
||||
})
|
||||
|
||||
it('populates the textarea with the initial v-model value', () => {
|
||||
render(Textarea, { props: { modelValue: 'initial text' } })
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('initial text')
|
||||
})
|
||||
|
||||
it('emits update:modelValue as the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(value: string | number | undefined) => void>()
|
||||
|
||||
render(Textarea, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'hi')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
expect(onUpdate.mock.calls.at(-1)?.[0]).toBe('hi')
|
||||
})
|
||||
|
||||
it('forwards placeholder and rows attrs to the native textarea', () => {
|
||||
render(Textarea, {
|
||||
attrs: { placeholder: 'Write something', rows: 6 }
|
||||
})
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write something')
|
||||
expect(textarea).toHaveAttribute('rows', '6')
|
||||
})
|
||||
|
||||
it('does not accept typed input when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(Textarea, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
attrs: { disabled: true }
|
||||
})
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toBeDisabled()
|
||||
await user.type(textarea, 'blocked')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('forwards custom class alongside internal classes', () => {
|
||||
render(Textarea, { props: { class: 'custom-extra-class' } })
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveClass('custom-extra-class')
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,6 @@ app.registerExtension({
|
||||
suggestionsNumber: null,
|
||||
init(this: SlotDefaultsExtension) {
|
||||
LiteGraph.search_filter_enabled = true
|
||||
LiteGraph.middle_click_slot_add_default_node = true
|
||||
this.suggestionsNumber = app.ui.settings.addSetting({
|
||||
id: 'Comfy.NodeSuggestions.number',
|
||||
category: ['Comfy', 'Node Search Box', 'NodeSuggestions'],
|
||||
|
||||
@@ -484,4 +484,56 @@ describe('useMediaAssetActions', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - confirmation dialog item names', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockShowDialog.mockReset()
|
||||
})
|
||||
|
||||
it('should show user_metadata display names instead of hash filenames', () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
createMockAsset({
|
||||
id: 'asset-1',
|
||||
name: 'c885097ab185ced82f017bcbc98948918499f7480315fd5b928b5bb8d4951efc.png',
|
||||
user_metadata: { name: 'My Sunset Render' }
|
||||
}),
|
||||
createMockAsset({
|
||||
id: 'asset-2',
|
||||
name: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2.png',
|
||||
display_name: 'Portrait Variation'
|
||||
})
|
||||
]
|
||||
|
||||
void actions.deleteAssets(assets)
|
||||
|
||||
expect(mockShowDialog).toHaveBeenCalledTimes(1)
|
||||
const dialogProps = mockShowDialog.mock.calls[0][0].props as {
|
||||
itemList: string[]
|
||||
}
|
||||
expect(dialogProps.itemList).toEqual([
|
||||
'My Sunset Render',
|
||||
'Portrait Variation'
|
||||
])
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when no display name is available', () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-3',
|
||||
name: 'fallback-image.png'
|
||||
})
|
||||
|
||||
void actions.deleteAssets(asset)
|
||||
|
||||
const dialogProps = mockShowDialog.mock.calls[0][0].props as {
|
||||
itemList: string[]
|
||||
}
|
||||
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -595,7 +595,7 @@ export function useMediaAssetActions() {
|
||||
count: assetArray.length
|
||||
}),
|
||||
type: 'delete',
|
||||
itemList: assetArray.map((asset) => asset.name),
|
||||
itemList: assetArray.map((asset) => getAssetDisplayName(asset)),
|
||||
onConfirm: async () => {
|
||||
// Show loading overlay for all assets being deleted
|
||||
assetArray.forEach((asset) =>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<button
|
||||
v-for="(url, index) in imageUrls"
|
||||
:key="index"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
@@ -193,7 +193,7 @@ const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
|
||||
type ViewMode = 'gallery' | 'grid'
|
||||
|
||||
|
||||
@@ -19,12 +19,7 @@
|
||||
v-if="activeItem"
|
||||
:src="getItemSrc(activeItem)"
|
||||
:alt="getItemAlt(activeItem, activeIndex)"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full rounded-sm object-contain transition-opacity',
|
||||
showControls && 'opacity-50'
|
||||
)
|
||||
"
|
||||
class="h-auto w-full rounded-sm object-contain"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
@@ -238,7 +233,7 @@ const showNavButtons = computed(
|
||||
)
|
||||
|
||||
const actionButtonClass =
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors hover:bg-base-foreground/90'
|
||||
|
||||
const toggleButtonClass = actionButtonClass
|
||||
|
||||
|
||||
@@ -23,10 +23,6 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
function captureWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<string>) {
|
||||
const id =
|
||||
selectedItems.size > 0 ? selectedItems.values().next().value : undefined
|
||||
@@ -36,7 +32,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
: dropdownItems.value.find((item) => item.id === id)?.name
|
||||
|
||||
modelValue.value = name
|
||||
captureWorkflowState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
@@ -109,7 +105,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
captureWorkflowState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import * as jsondiffpatch from 'jsondiffpatch'
|
||||
import log from 'loglevel'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -20,14 +20,37 @@ function clone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
const logger = log.getLogger('ChangeTracker')
|
||||
// Change to debug for more verbose logging
|
||||
logger.setLevel('info')
|
||||
|
||||
function isActiveTracker(tracker: ChangeTracker): boolean {
|
||||
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
|
||||
}
|
||||
|
||||
const reportedInactiveCalls = new Set<string>()
|
||||
|
||||
/**
|
||||
* Report a ChangeTracker method being called on an inactive tracker —
|
||||
* a lifecycle violation that usually indicates stale extension state or
|
||||
* an incorrect call ordering. Reports once per method per workflow per
|
||||
* session so the signal is not drowned out by hot-path invocations while
|
||||
* still distinguishing between workflows.
|
||||
*/
|
||||
function reportInactiveTrackerCall(method: string, workflowPath: string) {
|
||||
const key = `${method}:${workflowPath}`
|
||||
if (reportedInactiveCalls.has(key)) return
|
||||
reportedInactiveCalls.add(key)
|
||||
|
||||
console.warn(`${method}() called on inactive tracker for: ${workflowPath}`)
|
||||
|
||||
if (isDesktop) {
|
||||
Sentry.captureMessage(
|
||||
`ChangeTracker.${method}() called on inactive tracker`,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { workflow: workflowPath }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ChangeTracker {
|
||||
static MAX_HISTORY = 50
|
||||
/**
|
||||
@@ -77,7 +100,6 @@ export class ChangeTracker {
|
||||
// Do not reset the state if we are restoring.
|
||||
if (this._restoringState) return
|
||||
|
||||
logger.debug('Reset State')
|
||||
if (state) this.activeState = clone(state)
|
||||
this.initialState = clone(this.activeState)
|
||||
}
|
||||
@@ -107,10 +129,7 @@ export class ChangeTracker {
|
||||
*/
|
||||
deactivate() {
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'deactivate() called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
reportInactiveTrackerCall('deactivate', this.workflow.path)
|
||||
return
|
||||
}
|
||||
if (!this._restoringState) this.captureCanvasState()
|
||||
@@ -165,13 +184,6 @@ export class ChangeTracker {
|
||||
this.initialState,
|
||||
this.activeState
|
||||
)
|
||||
if (logger.getLevel() <= logger.levels.DEBUG && workflow.isModified) {
|
||||
const diff = ChangeTracker.graphDiff(
|
||||
this.initialState,
|
||||
this.activeState
|
||||
)
|
||||
logger.debug('Graph diff:', diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,19 +193,18 @@ export class ChangeTracker {
|
||||
* Calling this on an inactive tracker would capture the wrong graph.
|
||||
*/
|
||||
captureCanvasState() {
|
||||
const isUndoRedoing = this._restoringState
|
||||
const isInsideChangeTransaction = this.changeCount > 0
|
||||
if (
|
||||
!app.graph ||
|
||||
this.changeCount ||
|
||||
this._restoringState ||
|
||||
isInsideChangeTransaction ||
|
||||
isUndoRedoing ||
|
||||
ChangeTracker.isLoadingGraph
|
||||
)
|
||||
return
|
||||
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'captureCanvasState called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
reportInactiveTrackerCall('captureCanvasState', this.workflow.path)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -207,7 +218,6 @@ export class ChangeTracker {
|
||||
if (this.undoQueue.length > ChangeTracker.MAX_HISTORY) {
|
||||
this.undoQueue.shift()
|
||||
}
|
||||
logger.debug('Diff detected. Undo queue length:', this.undoQueue.length)
|
||||
|
||||
this.activeState = currentState
|
||||
this.redoQueue.length = 0
|
||||
@@ -219,7 +229,7 @@ export class ChangeTracker {
|
||||
checkState() {
|
||||
if (!ChangeTracker._checkStateWarned) {
|
||||
ChangeTracker._checkStateWarned = true
|
||||
logger.warn(
|
||||
console.warn(
|
||||
'checkState() is deprecated — use captureCanvasState() instead.'
|
||||
)
|
||||
}
|
||||
@@ -248,22 +258,10 @@ export class ChangeTracker {
|
||||
|
||||
async undo() {
|
||||
await this.updateState(this.undoQueue, this.redoQueue)
|
||||
logger.debug(
|
||||
'Undo. Undo queue length:',
|
||||
this.undoQueue.length,
|
||||
'Redo queue length:',
|
||||
this.redoQueue.length
|
||||
)
|
||||
}
|
||||
|
||||
async redo() {
|
||||
await this.updateState(this.redoQueue, this.undoQueue)
|
||||
logger.debug(
|
||||
'Redo. Undo queue length:',
|
||||
this.undoQueue.length,
|
||||
'Redo queue length:',
|
||||
this.redoQueue.length
|
||||
)
|
||||
}
|
||||
|
||||
async undoRedo(e: KeyboardEvent) {
|
||||
@@ -337,7 +335,6 @@ export class ChangeTracker {
|
||||
|
||||
// If our active element is some type of input then handle changes after they're done
|
||||
if (ChangeTracker.bindInput(bindInputEl)) return
|
||||
logger.debug('captureCanvasState on keydown')
|
||||
changeTracker.captureCanvasState()
|
||||
})
|
||||
},
|
||||
@@ -347,25 +344,21 @@ export class ChangeTracker {
|
||||
window.addEventListener('keyup', () => {
|
||||
if (keyIgnored) {
|
||||
keyIgnored = false
|
||||
logger.debug('captureCanvasState on keyup')
|
||||
captureState()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle clicking DOM elements (e.g. widgets)
|
||||
window.addEventListener('mouseup', () => {
|
||||
logger.debug('captureCanvasState on mouseup')
|
||||
captureState()
|
||||
})
|
||||
|
||||
// Handle prompt queue event for dynamic widget changes
|
||||
api.addEventListener('promptQueued', () => {
|
||||
logger.debug('captureCanvasState on promptQueued')
|
||||
captureState()
|
||||
})
|
||||
|
||||
api.addEventListener('graphCleared', () => {
|
||||
logger.debug('captureCanvasState on graphCleared')
|
||||
captureState()
|
||||
})
|
||||
|
||||
@@ -373,7 +366,6 @@ export class ChangeTracker {
|
||||
const processMouseUp = LGraphCanvas.prototype.processMouseUp
|
||||
LGraphCanvas.prototype.processMouseUp = function (e) {
|
||||
const v = processMouseUp.apply(this, [e])
|
||||
logger.debug('captureCanvasState on processMouseUp')
|
||||
captureState()
|
||||
return v
|
||||
}
|
||||
@@ -390,7 +382,6 @@ export class ChangeTracker {
|
||||
callback(v)
|
||||
captureState()
|
||||
}
|
||||
logger.debug('captureCanvasState on prompt')
|
||||
return prompt.apply(this, [title, value, extendedCallback, event])
|
||||
}
|
||||
|
||||
@@ -398,7 +389,6 @@ export class ChangeTracker {
|
||||
const close = LiteGraph.ContextMenu.prototype.close
|
||||
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
|
||||
const v = close.apply(this, [e])
|
||||
logger.debug('captureCanvasState on contextMenuClose')
|
||||
captureState()
|
||||
return v
|
||||
}
|
||||
@@ -501,25 +491,4 @@ export class ChangeTracker {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static graphDiff(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
|
||||
function sortGraphNodes(graph: ComfyWorkflowJSON) {
|
||||
return {
|
||||
links: graph.links,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: graph.reroutes,
|
||||
groups: graph.groups,
|
||||
extra: graph.extra,
|
||||
definitions: graph.definitions,
|
||||
subgraphs: graph.subgraphs,
|
||||
nodes: graph.nodes.sort((a, b) => {
|
||||
if (typeof a.id === 'number' && typeof b.id === 'number') {
|
||||
return a.id - b.id
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
}
|
||||
return jsondiffpatch.diff(sortGraphNodes(a), sortGraphNodes(b))
|
||||
}
|
||||
}
|
||||
|
||||
43
src/services/litegraphService.test.ts
Normal file
43
src/services/litegraphService.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: undefined },
|
||||
ComfyApp: class {}
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
describe('useLitegraphService().getCanvasCenter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns origin when canvas is not yet initialised', () => {
|
||||
Reflect.set(app, 'canvas', undefined)
|
||||
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
expect(center).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('returns origin when canvas exists but ds.visible_area is missing', () => {
|
||||
Reflect.set(app, 'canvas', { ds: {} })
|
||||
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
expect(center).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('returns the visible-area centre once the canvas is ready', () => {
|
||||
Reflect.set(app, 'canvas', {
|
||||
ds: { visible_area: [10, 20, 200, 100] }
|
||||
})
|
||||
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
expect(center).toEqual([110, 70])
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { graphToPrompt } from './executionUtil'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('graphToPrompt', () => {
|
||||
it('excludes nodes with isVirtualNode from API output', async () => {
|
||||
const graph = new LGraph()
|
||||
const realNode = new LGraphNode('RealNode')
|
||||
realNode.comfyClass = 'KSampler'
|
||||
graph.add(realNode)
|
||||
|
||||
const virtualNode = new LGraphNode('VirtualNode')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.comfyClass = 'Note'
|
||||
graph.add(virtualNode)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(output[String(virtualNode.id)]).toBeUndefined()
|
||||
expect(output[String(realNode.id)]).toBeDefined()
|
||||
expect(output[String(realNode.id)].class_type).toBe('KSampler')
|
||||
})
|
||||
|
||||
it('produces empty output when all nodes are virtual', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const note = new LGraphNode('Note')
|
||||
note.isVirtualNode = true
|
||||
note.comfyClass = 'Note'
|
||||
graph.add(note)
|
||||
|
||||
const mdNote = new LGraphNode('MarkdownNote')
|
||||
mdNote.isVirtualNode = true
|
||||
mdNote.comfyClass = 'MarkdownNote'
|
||||
graph.add(mdNote)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(Object.keys(output)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes virtual nodes in workflow JSON for save fidelity', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const note = new LGraphNode('Note')
|
||||
note.isVirtualNode = true
|
||||
note.comfyClass = 'Note'
|
||||
graph.add(note)
|
||||
|
||||
const realNode = new LGraphNode('RealNode')
|
||||
realNode.comfyClass = 'KSampler'
|
||||
graph.add(realNode)
|
||||
|
||||
const { workflow, output } = await graphToPrompt(graph)
|
||||
|
||||
expect(
|
||||
workflow.nodes.some((n) => n.id === note.id),
|
||||
'Workflow JSON should preserve virtual nodes by ID'
|
||||
).toBe(true)
|
||||
expect(output[String(note.id)]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves multiple non-virtual nodes', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const node1 = new LGraphNode('Node1')
|
||||
node1.comfyClass = 'KSampler'
|
||||
graph.add(node1)
|
||||
|
||||
const node2 = new LGraphNode('Node2')
|
||||
node2.comfyClass = 'SaveImage'
|
||||
graph.add(node2)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(Object.keys(output)).toHaveLength(2)
|
||||
expect(output[String(node1.id)].class_type).toBe('KSampler')
|
||||
expect(output[String(node2.id)].class_type).toBe('SaveImage')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ from .nodes import (
|
||||
LongComboDropdown,
|
||||
MultiSelectNode,
|
||||
NodeWithBooleanInput,
|
||||
NodeWithComboControlWidget,
|
||||
NodeWithDefaultInput,
|
||||
NodeWithForceInput,
|
||||
NodeWithOptionalComboInput,
|
||||
@@ -43,6 +44,7 @@ __all__ = [
|
||||
"LongComboDropdown",
|
||||
"MultiSelectNode",
|
||||
"NodeWithBooleanInput",
|
||||
"NodeWithComboControlWidget",
|
||||
"NodeWithDefaultInput",
|
||||
"NodeWithForceInput",
|
||||
"NodeWithOptionalComboInput",
|
||||
|
||||
@@ -11,6 +11,7 @@ from .errors import (
|
||||
from .inputs import (
|
||||
LongComboDropdown,
|
||||
NodeWithBooleanInput,
|
||||
NodeWithComboControlWidget,
|
||||
NodeWithDefaultInput,
|
||||
NodeWithForceInput,
|
||||
NodeWithOptionalComboInput,
|
||||
@@ -69,6 +70,7 @@ __all__ = [
|
||||
"LongComboDropdown",
|
||||
"MultiSelectNode",
|
||||
"NodeWithBooleanInput",
|
||||
"NodeWithComboControlWidget",
|
||||
"NodeWithDefaultInput",
|
||||
"NodeWithForceInput",
|
||||
"NodeWithOptionalComboInput",
|
||||
|
||||
@@ -303,6 +303,31 @@ class NodeWithV2ComboInput:
|
||||
return (combo_input,)
|
||||
|
||||
|
||||
class NodeWithComboControlWidget:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"combo_option": (
|
||||
"COMBO",
|
||||
{
|
||||
"options": ["Option A", "Option B", "Option C"],
|
||||
"control_after_generate": True,
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a combo input that has control_after_generate, producing control widgets with a filter list"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, combo_option: str):
|
||||
return (combo_option,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsLongComboDropdown": LongComboDropdown,
|
||||
"DevToolsNodeWithOptionalInput": NodeWithOptionalInput,
|
||||
@@ -318,6 +343,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
|
||||
"DevToolsNodeWithValidation": NodeWithValidation,
|
||||
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
|
||||
"DevToolsNodeWithComboControlWidget": NodeWithComboControlWidget,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -335,6 +361,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DevToolsNodeWithSeedInput": "Node With Seed Input",
|
||||
"DevToolsNodeWithValidation": "Node With Validation",
|
||||
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
|
||||
"DevToolsNodeWithComboControlWidget": "Node With Combo Control Widget",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
@@ -352,6 +379,7 @@ __all__ = [
|
||||
"NodeWithSeedInput",
|
||||
"NodeWithValidation",
|
||||
"NodeWithV2ComboInput",
|
||||
"NodeWithComboControlWidget",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user