mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
18 Commits
glary/remo
...
litegraph/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0821f4b443 | ||
|
|
1af4b8efc6 | ||
|
|
6ba54935ce | ||
|
|
b0947ee834 | ||
|
|
74c09f31ef | ||
|
|
173293b919 | ||
|
|
fc6a0c8491 | ||
|
|
699824f1e4 | ||
|
|
1f8e2c71d3 | ||
|
|
5738c7a539 | ||
|
|
2dadcde05d | ||
|
|
8abfa678a3 | ||
|
|
b36b601a1c | ||
|
|
ad63f7cb9b | ||
|
|
f7ef563b46 | ||
|
|
9cc09cd46c | ||
|
|
de1c1ee1f2 | ||
|
|
86b1e1a965 |
9
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
9
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -109,14 +109,16 @@ jobs:
|
||||
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
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
|
||||
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></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
|
||||
--precision 1 \
|
||||
--ignore-errors source
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
@@ -130,7 +132,8 @@ jobs:
|
||||
needs: merge
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
needs.merge.outputs.has-coverage == 'true' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png[output]",
|
||||
"image"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["ComfyUI_00001_.png [output]", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadVideo",
|
||||
"pos": [430, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": ["clip.mp4 [output]", "image"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoadAudio",
|
||||
"pos": [810, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "AUDIO",
|
||||
"type": "AUDIO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadAudio"
|
||||
},
|
||||
"widgets_values": ["sound.wav [output]", null, ""]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
BIN
browser_tests/assets/plain_video.mp4
Normal file
BIN
browser_tests/assets/plain_video.mp4
Normal file
Binary file not shown.
@@ -246,4 +246,18 @@ export class VueNodeHelpers {
|
||||
position: { x: box.width / 2, y: box.height * 0.75 }
|
||||
})
|
||||
}
|
||||
async isSlotConnected(slot: Locator) {
|
||||
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
|
||||
if (!key) return false
|
||||
|
||||
return await this.page.evaluate((key) => {
|
||||
const [nodeId, type, slotId] = key.split('-')
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)].links?.length
|
||||
}, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ export class ContextMenu {
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly litegraphContextMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
protected readonly anyMenu: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.litegraphContextMenu = page.locator('.litecontextmenu')
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
this.anyMenu = this.primeVueMenu
|
||||
.or(this.litegraphMenu)
|
||||
.or(this.litegraphContextMenu)
|
||||
}
|
||||
|
||||
async clickMenuItem(name: string): Promise<void> {
|
||||
@@ -36,16 +40,7 @@ export class ContextMenu {
|
||||
}
|
||||
|
||||
async isVisible(): Promise<boolean> {
|
||||
const primeVueVisible = await this.primeVueMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphVisible = await this.litegraphMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphContextVisible = await this.litegraphContextMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return primeVueVisible || litegraphVisible || litegraphContextVisible
|
||||
return await this.anyMenu.isVisible()
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
@@ -58,7 +53,7 @@ export class ContextMenu {
|
||||
|
||||
async openFor(locator: Locator): Promise<this> {
|
||||
await locator.click({ button: 'right' })
|
||||
await expect.poll(() => this.isVisible()).toBe(true)
|
||||
await expect(this.anyMenu).toBeVisible()
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
|
||||
81
browser_tests/fixtures/components/SubgraphEditor.ts
Normal file
81
browser_tests/fixtures/components/SubgraphEditor.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class SubgraphEditor {
|
||||
public readonly root: Locator
|
||||
public readonly promotionItems: Locator
|
||||
|
||||
constructor(protected readonly comfyPage: ComfyPage) {
|
||||
this.root = this.comfyPage.menu.propertiesPanel.root
|
||||
this.promotionItems = this.root.getByTestId(
|
||||
TestIds.subgraphEditor.widgetItem
|
||||
)
|
||||
}
|
||||
|
||||
async open(subgraphNode: Locator) {
|
||||
await new VueNodeFixture(subgraphNode).select()
|
||||
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
|
||||
await menu.clickMenuItemExact('Edit Subgraph Widgets')
|
||||
await expect(this.root, 'Open Properties Panel').toBeVisible()
|
||||
}
|
||||
|
||||
resolveItem(options: {
|
||||
nodeName?: string
|
||||
nodeId?: string
|
||||
widgetName: string
|
||||
}): Locator {
|
||||
const nodeItems =
|
||||
options.nodeId !== undefined
|
||||
? this.comfyPage.page.locator(`[data-nodeid="${options.nodeId}"]`)
|
||||
: options.nodeName !== undefined
|
||||
? this.promotionItems.filter({
|
||||
has: this.comfyPage.page
|
||||
.getByTestId(TestIds.subgraphEditor.nodeName)
|
||||
.filter({ hasText: options.nodeName })
|
||||
})
|
||||
: this.promotionItems
|
||||
|
||||
return nodeItems.filter({
|
||||
has: this.comfyPage.page
|
||||
.getByTestId(TestIds.subgraphEditor.widgetLabel)
|
||||
.filter({ hasText: options.widgetName })
|
||||
})
|
||||
}
|
||||
|
||||
getToggleButton(item: Locator) {
|
||||
return item.getByTestId(TestIds.subgraphEditor.widgetToggle)
|
||||
}
|
||||
|
||||
async togglePromotionOnItem(item: Locator, toState?: boolean) {
|
||||
const toggleIcon = item.getByTestId(TestIds.subgraphEditor.iconEye)
|
||||
if (toState !== undefined) {
|
||||
const expectedIcon = `icon-[lucide--eye${toState ? '-off' : ''}]`
|
||||
await expect(toggleIcon).toContainClass(expectedIcon)
|
||||
}
|
||||
await toggleIcon.click()
|
||||
}
|
||||
|
||||
async togglePromotion(
|
||||
subgraphNode: Locator,
|
||||
options: {
|
||||
nodeName?: string
|
||||
nodeId?: string
|
||||
widgetName: string
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.open(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
}
|
||||
async dragItem(fromIndex: number, toIndex: number) {
|
||||
await dragByIndex(this.promotionItems, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,16 @@ export class Topbar {
|
||||
private readonly menuTrigger: Locator
|
||||
readonly newWorkflowButton: Locator
|
||||
readonly workflowTabs: Locator
|
||||
readonly integratedTabBarActions: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menuLocator = page.locator('.comfy-command-menu')
|
||||
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
|
||||
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
|
||||
this.workflowTabs = page.getByTestId(TestIds.topbar.workflowTabs)
|
||||
this.integratedTabBarActions = this.workflowTabs.getByTestId(
|
||||
TestIds.topbar.integratedTabBarActions
|
||||
)
|
||||
}
|
||||
|
||||
async getTabNames(): Promise<string[]> {
|
||||
|
||||
@@ -2,34 +2,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
|
||||
@@ -9,12 +9,17 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
export class SubgraphHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
public readonly editor: SubgraphEditor
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.editor = new SubgraphEditor(comfyPage)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
@@ -327,6 +332,23 @@ export class SubgraphHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async promoteWidget(nodeLocator: Locator, widgetName: string): Promise<void> {
|
||||
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
|
||||
await this.comfyPage.contextMenu
|
||||
.openFor(widget)
|
||||
.then((m) => m.clickMenuItemExact(`Promote Widget: ${widgetName}`))
|
||||
}
|
||||
|
||||
async unpromoteWidget(
|
||||
nodeLocator: Locator,
|
||||
widgetName: string
|
||||
): Promise<void> {
|
||||
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
|
||||
await this.comfyPage.contextMenu
|
||||
.openFor(widget)
|
||||
.then((m) => m.clickMenuItemExact(`Un-Promote Widget: ${widgetName}`))
|
||||
}
|
||||
|
||||
async isInSubgraph(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
|
||||
@@ -8,6 +8,7 @@ export const TestIds = {
|
||||
toolbar: 'side-toolbar',
|
||||
nodeLibrary: 'node-library-tree',
|
||||
nodeLibrarySearch: 'node-library-search',
|
||||
nodePreviewCard: 'node-preview-card',
|
||||
workflows: 'workflows-sidebar',
|
||||
modeToggle: 'mode-toggle'
|
||||
},
|
||||
@@ -92,6 +93,7 @@ export const TestIds = {
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
workflowTabs: 'topbar-workflow-tabs',
|
||||
integratedTabBarActions: 'integrated-tab-bar-actions',
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
},
|
||||
nodeLibrary: {
|
||||
@@ -102,14 +104,16 @@ export const TestIds = {
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
subgraphEditor: {
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
shownSection: 'subgraph-editor-shown-section',
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
widgetToggle: 'subgraph-widget-toggle',
|
||||
widgetLabel: 'subgraph-widget-label',
|
||||
iconLink: 'icon-link',
|
||||
iconEye: 'icon-eye',
|
||||
widgetActionsMenuButton: 'widget-actions-menu-button'
|
||||
iconLink: 'icon-link',
|
||||
nodeName: 'subgraph-widget-node-name',
|
||||
shownSection: 'subgraph-editor-shown-section',
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
widgetActionsMenuButton: 'widget-actions-menu-button',
|
||||
widgetItem: 'subgraph-widget-item',
|
||||
widgetLabel: 'subgraph-widget-label',
|
||||
widgetToggle: 'subgraph-widget-toggle'
|
||||
},
|
||||
node: {
|
||||
titleInput: 'node-title-input',
|
||||
|
||||
33
browser_tests/fixtures/utils/dragAndDrop.ts
Normal file
33
browser_tests/fixtures/utils/dragAndDrop.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
export async function dragByIndex(
|
||||
items: Locator,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export class VueNodeFixture {
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
public readonly content: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -27,6 +28,7 @@ export class VueNodeFixture {
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
this.content = locator.locator('.lg-node-content')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
@@ -39,6 +41,10 @@ export class VueNodeFixture {
|
||||
await this.titleEditor.setTitle(value)
|
||||
}
|
||||
|
||||
async select() {
|
||||
await this.header.click()
|
||||
}
|
||||
|
||||
async toggleCollapse(): Promise<void> {
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
@@ -60,4 +66,15 @@ export class VueNodeFixture {
|
||||
boundingBox(): ReturnType<Locator['boundingBox']> {
|
||||
return this.locator.boundingBox()
|
||||
}
|
||||
|
||||
getSlot(nameOrLocator: string | Locator) {
|
||||
const slotLocators = this.root
|
||||
.getByTestId('node-widget')
|
||||
.or(this.root.locator('.lg-slot'))
|
||||
const filteredLocator =
|
||||
typeof nameOrLocator === 'string'
|
||||
? slotLocators.filter({ hasText: nameOrLocator })
|
||||
: slotLocators.filter({ has: nameOrLocator })
|
||||
return filteredLocator.getByTestId('slot-dot').locator('..')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +59,10 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await test.step('Capture HUD region with setting on', async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
// FPS value varies per run; allow ~1% pixel variance in the 180×160 clip
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-on.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
{ clip: hudClip, maxDiffPixels: 350 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -78,6 +78,22 @@ test.describe('Layout & sidebar settings', { tag: ['@settings'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.UI.TabBarLayout', () => {
|
||||
test('"Default" renders integrated tab bar actions container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Default')
|
||||
await expect(comfyPage.menu.topbar.integratedTabBarActions).toBeAttached()
|
||||
})
|
||||
|
||||
test('"Legacy" does not render integrated tab bar actions container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Legacy')
|
||||
await expect(comfyPage.menu.topbar.integratedTabBarActions).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.TreeExplorer.ItemPadding', () => {
|
||||
// The setting writes a CSS var consumed by .p-tree-node-content,
|
||||
// which only renders in the legacy PrimeVue Tree.
|
||||
|
||||
@@ -47,19 +47,6 @@ test.describe('Login Button', { tag: ['@ui'] }, () => {
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('button falls back to TopMenuSection when workflow tabs are in sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ARIA', () => {
|
||||
|
||||
@@ -25,6 +25,21 @@ const FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
// NaN-variant fixtures embed only an API-format prompt containing bare
|
||||
// `NaN`/`Infinity` tokens (Python's `json.dumps` default). The loader must
|
||||
// tolerate Python generated JSON for these to import successfully.
|
||||
const NAN_FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_nan_metadata.json', parser: 'json' },
|
||||
{ fileName: 'with_nan_metadata.png', parser: 'png' },
|
||||
{ fileName: 'with_nan_metadata.avif', parser: 'avif' },
|
||||
{ fileName: 'with_nan_metadata.webp', parser: 'webp' },
|
||||
{ fileName: 'with_nan_metadata.flac', parser: 'flac' },
|
||||
{ fileName: 'with_nan_metadata.mp3', parser: 'mp3' },
|
||||
{ fileName: 'with_nan_metadata.opus', parser: 'ogg' },
|
||||
{ fileName: 'with_nan_metadata.mp4', parser: 'isobmff' },
|
||||
{ fileName: 'with_nan_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
test.describe(
|
||||
'Metadata drop-to-load workflow import',
|
||||
{ tag: ['@workflow'] },
|
||||
@@ -58,5 +73,42 @@ test.describe(
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const { fileName, parser } of NAN_FIXTURES) {
|
||||
test(`loads Python JSON prompt with NaN/Infinity from ${fileName} (${parser})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step(`drop ${fileName} on canvas`, async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFilePath(
|
||||
metadataFixturePath(fileName)
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('graph contains only the embedded KSampler', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const ksamplers =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
expect(
|
||||
ksamplers,
|
||||
'exactly one KSampler should have been loaded from the NaN-laden prompt'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
await test.step('NaN-coerced widget values are 0', async () => {
|
||||
const [ksampler] =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
for (const widgetName of ['cfg', 'denoise']) {
|
||||
const widget = await ksampler.getWidgetByName(widgetName)
|
||||
expect(
|
||||
await widget.getValue(),
|
||||
`${widgetName} should be 0 after NaN coercion to null`
|
||||
).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
|
||||
const cloudOutputAsset: Asset = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
asset_hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
interface CloudUploadAssetState {
|
||||
isUploadedAssetAvailable: boolean
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
|
||||
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
|
||||
const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
markUploadedCloudAssetAvailable: () => void
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
const state: CloudUploadAssetState = {
|
||||
isUploadedAssetAvailable: false
|
||||
}
|
||||
cloudUploadAssetStateByPage.set(page, state)
|
||||
|
||||
const assetsRouteHandler = async (route: Route) => {
|
||||
const allAssets = [
|
||||
cloudDefaultGraphInputAsset,
|
||||
...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : [])
|
||||
]
|
||||
const includeTags =
|
||||
new URL(route.request().url()).searchParams
|
||||
.get('include_tags')
|
||||
?.split(',')
|
||||
.filter(Boolean) ?? []
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
asset.tags?.some((tag) => includeTags.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
await use(page)
|
||||
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
cloudUploadAssetStateByPage.delete(page)
|
||||
},
|
||||
markUploadedCloudAssetAvailable: async ({ page }, use) => {
|
||||
await use(() => {
|
||||
const state = cloudUploadAssetStateByPage.get(page)
|
||||
if (state) state.isUploadedAssetAvailable = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function enableErrorsTab(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
let releaseUpload!: () => void
|
||||
let resolveUploadStarted!: () => void
|
||||
const uploadStarted = new Promise<void>((resolve) => {
|
||||
resolveUploadStarted = resolve
|
||||
})
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releaseUpload = resolve
|
||||
})
|
||||
|
||||
const uploadRouteHandler = async (route: Route) => {
|
||||
resolveUploadStarted()
|
||||
await release
|
||||
await route.continue()
|
||||
}
|
||||
|
||||
await comfyPage.page.route('**/upload/image', uploadRouteHandler)
|
||||
|
||||
return {
|
||||
waitForUploadStarted: () => uploadStarted,
|
||||
finishUpload: async () => {
|
||||
const uploadResponse = comfyPage.page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/upload/image') && response.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
releaseUpload()
|
||||
try {
|
||||
await uploadResponse
|
||||
} finally {
|
||||
await comfyPage.page.unroute('**/upload/image', uploadRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLoadVideoUploading(comfyPage: ComfyPage) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.nodes.some(
|
||||
(node) => node.type === 'LoadVideo' && node.isUploading
|
||||
)
|
||||
),
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let sawErrorOverlay = false
|
||||
const startedAt = Date.now()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
sawErrorOverlay =
|
||||
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
|
||||
return (
|
||||
!sawErrorOverlay &&
|
||||
Date.now() - startedAt >= missingMediaUploadObservationMs
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
|
||||
intervals: [missingMediaUploadPollMs]
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function outputHistoryJobs() {
|
||||
return createMockJobRecords([
|
||||
createMockJob({
|
||||
id: 'history-output-image',
|
||||
preview_output: {
|
||||
filename: 'ComfyUI_00001_.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'history-output-video',
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'video'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'history-output-audio',
|
||||
preview_output: {
|
||||
filename: 'sound.wav',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'audio'
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
ossTest.describe(
|
||||
'Errors tab - OSS missing media runtime sources',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
ossTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
})
|
||||
|
||||
ossTest(
|
||||
'resolves annotated output media from job history',
|
||||
async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(outputHistoryJobs())
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_output_annotations'
|
||||
)
|
||||
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
ossTest(
|
||||
'does not surface missing media while dropped video upload is in progress',
|
||||
async ({ comfyFiles, comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const delayedUpload = await delayNextUpload(comfyPage)
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
|
||||
dropPosition: graphDropPosition
|
||||
})
|
||||
await delayedUpload.waitForUploadStarted()
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename: plainVideoFileName,
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
|
||||
await delayedUpload.finishUpload()
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
cloudOutputTest.describe(
|
||||
'Errors tab - Cloud missing media runtime sources',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
})
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves compact annotated output media from output assets',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_annotation'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'output')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
cloudUploadRaceTest.describe(
|
||||
'Errors tab - Cloud missing media upload race',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
})
|
||||
|
||||
cloudUploadRaceTest(
|
||||
'does not surface missing media while dropped video upload is in progress',
|
||||
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const delayedUpload = await delayNextUpload(comfyPage)
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
|
||||
dropPosition: graphDropPosition
|
||||
})
|
||||
await delayedUpload.waitForUploadStarted()
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename: plainVideoFileName,
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
|
||||
markUploadedCloudAssetAvailable()
|
||||
await delayedUpload.finishUpload()
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -120,4 +120,13 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(options.first()).toBeVisible()
|
||||
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
|
||||
await tab.getNode('test blueprint').hover()
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -607,3 +607,218 @@ test.describe(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: false
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
})
|
||||
|
||||
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'linked widgets are first by default'
|
||||
).toHaveText('steps')
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(
|
||||
editor.promotionItems.first(),
|
||||
'Swap widget order'
|
||||
).toContainText('cfg')
|
||||
|
||||
// FIXME: solve actual bug and remove the not
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'Linked widget is first on node'
|
||||
).not.toHaveText('cfg')
|
||||
})
|
||||
|
||||
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(steps).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
// FUTURE: Add test for re-ordering previews?
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -36,8 +36,19 @@ WORKFLOW = {
|
||||
}
|
||||
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
|
||||
|
||||
# API-format prompt with bare NaN/Infinity tokens (as Python's json.dumps emits
|
||||
# by default). The NaN variant fixtures omit the workflow field so the loader
|
||||
# must route through prompt-parsing, which trips JSON.parse on bare NaN.
|
||||
PROMPT_NAN = {
|
||||
'1': {
|
||||
'class_type': 'KSampler',
|
||||
'inputs': {'cfg': float('nan'), 'denoise': float('inf')},
|
||||
}
|
||||
}
|
||||
|
||||
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
|
||||
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
|
||||
PROMPT_NAN_JSON = json.dumps(PROMPT_NAN, separators=(',', ':'))
|
||||
|
||||
|
||||
def out(name: str) -> str:
|
||||
@@ -53,15 +64,21 @@ def make_1x1_image() -> Image.Image:
|
||||
return Image.new('RGB', (1, 1), (255, 0, 0))
|
||||
|
||||
|
||||
def build_exif_bytes() -> bytes:
|
||||
def build_exif_bytes(
|
||||
workflow_str: str | None = WORKFLOW_JSON,
|
||||
prompt_str: str | None = PROMPT_JSON,
|
||||
) -> bytes:
|
||||
"""Build EXIF bytes matching the backend's tag assignments.
|
||||
|
||||
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
|
||||
Pass ``None`` to omit a tag.
|
||||
"""
|
||||
img = make_1x1_image()
|
||||
exif = img.getexif()
|
||||
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
|
||||
exif[0x0110] = f'prompt:{PROMPT_JSON}'
|
||||
if workflow_str is not None:
|
||||
exif[0x010F] = f'workflow:{workflow_str}'
|
||||
if prompt_str is not None:
|
||||
exif[0x0110] = f'prompt:{prompt_str}'
|
||||
return exif.tobytes()
|
||||
|
||||
|
||||
@@ -93,6 +110,9 @@ def generate_av_fixture(
|
||||
codec: str,
|
||||
rate: int = 44100,
|
||||
options: dict | None = None,
|
||||
*,
|
||||
prompt_json: str | None = PROMPT_JSON,
|
||||
workflow_json: str | None = WORKFLOW_JSON,
|
||||
):
|
||||
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
|
||||
path = out(name)
|
||||
@@ -100,8 +120,10 @@ def generate_av_fixture(
|
||||
stream = container.add_stream(codec, rate=rate)
|
||||
stream.layout = 'mono'
|
||||
|
||||
container.metadata['prompt'] = PROMPT_JSON
|
||||
container.metadata['workflow'] = WORKFLOW_JSON
|
||||
if prompt_json is not None:
|
||||
container.metadata['prompt'] = prompt_json
|
||||
if workflow_json is not None:
|
||||
container.metadata['workflow'] = workflow_json
|
||||
|
||||
sample_fmt = stream.codec_context.codec.audio_formats[0].name
|
||||
samples = stream.codec_context.frame_size or 1024
|
||||
@@ -175,6 +197,63 @@ def generate_webm():
|
||||
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
|
||||
|
||||
|
||||
def generate_nan_variants():
|
||||
"""Per-format fixtures carrying ONLY a NaN/Infinity-laden API prompt.
|
||||
|
||||
These force the loader through the prompt-parsing path, where Python's
|
||||
bare NaN/Infinity tokens trip JSON.parse.
|
||||
"""
|
||||
img = make_1x1_image()
|
||||
info = PngInfo()
|
||||
info.add_text('prompt', PROMPT_NAN_JSON)
|
||||
img.save(out('with_nan_metadata.png'), 'PNG', pnginfo=info)
|
||||
report('with_nan_metadata.png')
|
||||
|
||||
exif_nan = build_exif_bytes(workflow_str=None, prompt_str=PROMPT_NAN_JSON)
|
||||
|
||||
img = make_1x1_image()
|
||||
img.save(out('with_nan_metadata.webp'), 'WEBP', exif=exif_nan)
|
||||
report('with_nan_metadata.webp')
|
||||
|
||||
img = make_1x1_image()
|
||||
img.save(out('with_nan_metadata.avif'), 'AVIF', exif=exif_nan)
|
||||
report('with_nan_metadata.avif')
|
||||
|
||||
generate_av_fixture(
|
||||
'with_nan_metadata.flac', 'flac', 'flac',
|
||||
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
|
||||
)
|
||||
generate_av_fixture(
|
||||
'with_nan_metadata.opus', 'opus', 'libopus', rate=48000,
|
||||
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
|
||||
)
|
||||
generate_av_fixture(
|
||||
'with_nan_metadata.mp3', 'mp3', 'libmp3lame',
|
||||
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
|
||||
)
|
||||
generate_av_fixture(
|
||||
'with_nan_metadata.webm', 'webm', 'libvorbis',
|
||||
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
|
||||
)
|
||||
|
||||
path = out('with_nan_metadata.mp4')
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-loglevel', 'error',
|
||||
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
|
||||
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
|
||||
'-movflags', 'use_metadata_tags',
|
||||
'-metadata', f'prompt={PROMPT_NAN_JSON}',
|
||||
path,
|
||||
], check=True)
|
||||
report('with_nan_metadata.mp4')
|
||||
|
||||
# Direct JSON file containing API-format prompt with bare NaN/Infinity.
|
||||
json_path = out('with_nan_metadata.json')
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
f.write(PROMPT_NAN_JSON)
|
||||
report('with_nan_metadata.json')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_png()
|
||||
@@ -185,4 +264,5 @@ if __name__ == '__main__':
|
||||
generate_mp3()
|
||||
generate_mp4()
|
||||
generate_webm()
|
||||
generate_nan_variants()
|
||||
print('Done.')
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
const mockData = vi.hoisted(() => ({
|
||||
isLoggedIn: false,
|
||||
isDesktop: false,
|
||||
isCloud: false,
|
||||
setShowConflictRedDot: (_value: boolean) => {}
|
||||
}))
|
||||
|
||||
@@ -37,9 +36,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockData.isCloud
|
||||
},
|
||||
isCloud: false,
|
||||
isNightly: false,
|
||||
get isDesktop() {
|
||||
return mockData.isDesktop
|
||||
@@ -196,41 +193,54 @@ describe('TopMenuSection', () => {
|
||||
localStorage.clear()
|
||||
mockData.isDesktop = false
|
||||
mockData.isLoggedIn = false
|
||||
mockData.isCloud = false
|
||||
mockData.setShowConflictRedDot(false)
|
||||
})
|
||||
|
||||
describe('auth fallback when workflow tabs are not in topbar', () => {
|
||||
function createSidebarTabsWrapper() {
|
||||
describe('authentication state', () => {
|
||||
function createLegacyTabBarWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Workflow.WorkflowTabsPosition' ? 'Sidebar' : undefined
|
||||
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
|
||||
)
|
||||
return createWrapper({ pinia })
|
||||
}
|
||||
|
||||
it('should display CurrentUserButton when user is logged in', () => {
|
||||
mockData.isLoggedIn = true
|
||||
const { container } = createSidebarTabsWrapper()
|
||||
expect(container.querySelector('current-user-button-stub')).not.toBeNull()
|
||||
expect(container.querySelector('login-button-stub')).toBeNull()
|
||||
describe('when user is logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = true
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||
const { container } = createLegacyTabBarWrapper()
|
||||
expect(
|
||||
container.querySelector('current-user-button-stub')
|
||||
).not.toBeNull()
|
||||
expect(container.querySelector('login-button-stub')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display LoginButton when user is not logged in on desktop', () => {
|
||||
mockData.isLoggedIn = false
|
||||
mockData.isDesktop = true
|
||||
const { container } = createSidebarTabsWrapper()
|
||||
expect(container.querySelector('login-button-stub')).not.toBeNull()
|
||||
expect(container.querySelector('current-user-button-stub')).toBeNull()
|
||||
})
|
||||
describe('when user is not logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = false
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton when user is logged out on cloud', () => {
|
||||
mockData.isLoggedIn = false
|
||||
mockData.isCloud = true
|
||||
const { container } = createSidebarTabsWrapper()
|
||||
expect(container.querySelector('current-user-button-stub')).not.toBeNull()
|
||||
expect(container.querySelector('login-button-stub')).toBeNull()
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
mockData.isDesktop = true
|
||||
const { container } = createLegacyTabBarWrapper()
|
||||
expect(container.querySelector('login-button-stub')).not.toBeNull()
|
||||
expect(container.querySelector('current-user-button-stub')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('on web platform', () => {
|
||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||
const { container } = createLegacyTabBarWrapper()
|
||||
expect(container.querySelector('current-user-button-stub')).toBeNull()
|
||||
expect(container.querySelector('login-button-stub')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -547,7 +557,7 @@ describe('TopMenuSection', () => {
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
if (key === 'Comfy.UI.TabBarLayout') return 'Default'
|
||||
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
|
||||
if (key === 'Comfy.RightSidePanel.IsOpen') return true
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -49,12 +49,10 @@
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="showCurrentUser && !isWorkflowTabsInTopbar"
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton
|
||||
v-else-if="showLoginButton && !isWorkflowTabsInTopbar"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
@@ -191,13 +189,6 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
const isWorkflowTabsInTopbar = computed(
|
||||
() => settingStore.get('Comfy.Workflow.WorkflowTabsPosition') === 'Topbar'
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
const showLoginButton = computed(
|
||||
() => !showCurrentUser.value && (flags.showSignInButton ?? isDesktop)
|
||||
)
|
||||
/**
|
||||
* Whether the actionbar container has any visible docked buttons
|
||||
* (excluding ComfyActionbar, which uses position:fixed when floating
|
||||
@@ -206,8 +197,8 @@ const showLoginButton = computed(
|
||||
const hasDockedButtons = computed(() => {
|
||||
if (actionBarButtonStore.buttons.length > 0) return true
|
||||
if (hasLegacyContent.value) return true
|
||||
if (showCurrentUser.value && !isWorkflowTabsInTopbar.value) return true
|
||||
if (showLoginButton.value && !isWorkflowTabsInTopbar.value) return true
|
||||
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
|
||||
if (isDesktop && !isIntegratedTabBar.value) return true
|
||||
if (isCloud && flags.workflowSharingEnabled) return true
|
||||
if (!isRightSidePanelOpen.value) return true
|
||||
return false
|
||||
@@ -230,6 +221,9 @@ const actionbarContainerClass = computed(() => {
|
||||
|
||||
return cn(base, 'px-2', 'border-interface-stroke')
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
const isQueueProgressOverlayEnabled = computed(
|
||||
|
||||
@@ -141,6 +141,21 @@ describe('PointerZone', () => {
|
||||
y: 45
|
||||
})
|
||||
})
|
||||
|
||||
it('should preventDefault on wheel to block browser zoom on ctrl+wheel', () => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaY: -1,
|
||||
ctrlKey: true
|
||||
})
|
||||
zone.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPanning watcher', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@wheel="handleWheel"
|
||||
@wheel.prevent="handleWheel"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
|
||||
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
|
||||
data-testid="node-preview-card"
|
||||
>
|
||||
<div ref="previewContainerRef" class="overflow-hidden p-3">
|
||||
<div
|
||||
|
||||
@@ -263,6 +263,7 @@ onMounted(() => {
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
:data-nodeid="node.id"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.label || widget.name"
|
||||
@@ -295,6 +296,7 @@ onMounted(() => {
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
:data-nodeid="node.id"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
|
||||
@@ -41,9 +41,13 @@ const icon = computed(() =>
|
||||
className
|
||||
)
|
||||
"
|
||||
data-testid="subgraph-widget-item"
|
||||
>
|
||||
<div class="pointer-events-none flex-1">
|
||||
<div class="line-clamp-1 text-xs text-text-secondary">
|
||||
<div
|
||||
class="line-clamp-1 text-xs text-text-secondary"
|
||||
data-testid="subgraph-widget-node-name"
|
||||
>
|
||||
{{ nodeTitle }}
|
||||
</div>
|
||||
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
|
||||
|
||||
@@ -14,6 +14,8 @@ const distribution = vi.hoisted(() => ({
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
@@ -26,6 +28,13 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: { value: false } })
|
||||
}))
|
||||
@@ -125,6 +134,7 @@ describe('WorkflowTabs feedback button', () => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
tabBarLayout.value = 'Default'
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
@@ -164,4 +174,13 @@ describe('WorkflowTabs feedback button', () => {
|
||||
screen.queryByRole('button', { name: 'Feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the feedback button when the legacy tab bar is active', () => {
|
||||
distribution.isCloud = true
|
||||
tabBarLayout.value = 'Legacy'
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,7 +79,11 @@
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2 px-2">
|
||||
<div
|
||||
v-if="isIntegratedTabBar"
|
||||
data-testid="integrated-tab-bar-actions"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
@@ -114,6 +118,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -134,6 +139,7 @@ const props = defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
@@ -141,6 +147,9 @@ const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
function openFeedback() {
|
||||
|
||||
@@ -212,9 +212,6 @@ describe('useCoreCommands', () => {
|
||||
clear: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
configure: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
runStep: vi.fn(),
|
||||
findNodeByTitle: vi.fn(),
|
||||
findNodesByTitle: vi.fn(),
|
||||
findNodesByType: vi.fn(),
|
||||
|
||||
83
src/extensions/core/cloudFeedbackTopbarButton.test.ts
Normal file
83
src/extensions/core/cloudFeedbackTopbarButton.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
const registerExtension = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
describe('cloudFeedbackTopbarButton', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
registerExtension.mockReset()
|
||||
distribution.isCloud = false
|
||||
distribution.isNightly = false
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
function getRegisteredButtons(): ActionBarButton[] {
|
||||
expect(registerExtension).toHaveBeenCalledTimes(1)
|
||||
const extension = registerExtension.mock.calls[0]?.[0] as {
|
||||
actionBarButtons: ActionBarButton[]
|
||||
}
|
||||
return extension.actionBarButtons
|
||||
}
|
||||
|
||||
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
|
||||
tabBarLayout.value = 'Legacy'
|
||||
distribution.isCloud = true
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
const buttons = getRegisteredButtons()
|
||||
expect(buttons).toHaveLength(1)
|
||||
buttons[0].onClick?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1)
|
||||
const [url, target, features] = openSpy.mock.calls[0]
|
||||
expect(url).toBe(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
|
||||
)
|
||||
expect(target).toBe('_blank')
|
||||
expect(features).toBe('noopener,noreferrer')
|
||||
})
|
||||
|
||||
it('only registers the action bar button when the tab bar is Legacy', async () => {
|
||||
tabBarLayout.value = 'Default'
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
expect(getRegisteredButtons()).toEqual([])
|
||||
})
|
||||
})
|
||||
29
src/extensions/core/cloudFeedbackTopbarButton.ts
Normal file
29
src/extensions/core/cloudFeedbackTopbarButton.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('action-bar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.FeedbackButton',
|
||||
get actionBarButtons() {
|
||||
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
|
||||
? buttons
|
||||
: []
|
||||
}
|
||||
})
|
||||
@@ -43,6 +43,11 @@ if (isCloud) {
|
||||
}
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
if (isCloud || isNightly) {
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
}
|
||||
|
||||
// Nightly-only extensions
|
||||
if (isNightly && !isCloud) {
|
||||
await import('./nightlyBadges')
|
||||
|
||||
@@ -102,7 +102,8 @@ app.registerExtension({
|
||||
'SaveAudio',
|
||||
'PreviewAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus'
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced'
|
||||
].includes(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
nodeType.prototype.comfyClass
|
||||
|
||||
@@ -104,8 +104,6 @@ const secondNode = LiteGraph.createNode('basic/sum')
|
||||
graph.add(secondNode)
|
||||
|
||||
firstNode.connect(0, secondNode, 1)
|
||||
|
||||
graph.start()
|
||||
```
|
||||
|
||||
## Projects using it
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
multiClone,
|
||||
splitPositionables
|
||||
} from './subgraph/subgraphUtils'
|
||||
import { Alignment, LGraphEventMode } from './types/globalEnums'
|
||||
import { Alignment } from './types/globalEnums'
|
||||
import type {
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
@@ -173,8 +173,10 @@ export interface BaseLGraph {
|
||||
}
|
||||
|
||||
/**
|
||||
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
|
||||
* supported callbacks:
|
||||
* LGraph contains a full graph. Instantiate it, add nodes/groups, and use it
|
||||
* for editing, traversal, and serialisation.
|
||||
*
|
||||
* Supported callbacks:
|
||||
* + onNodeAdded: when a new node is added to the graph
|
||||
* + onNodeRemoved: when a node inside this graph is removed
|
||||
*/
|
||||
@@ -183,9 +185,6 @@ export class LGraph
|
||||
{
|
||||
static serialisedSchemaVersion = 1 as const
|
||||
|
||||
static STATUS_STOPPED = 1
|
||||
static STATUS_RUNNING = 2
|
||||
|
||||
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
||||
static readonly ConfigureProperties = new Set([
|
||||
'nodes',
|
||||
@@ -224,7 +223,6 @@ export class LGraph
|
||||
*/
|
||||
links: Map<LinkId, LLink> & Record<LinkId, LLink>
|
||||
list_of_graphcanvas: LGraphCanvas[] | null
|
||||
status: number = LGraph.STATUS_STOPPED
|
||||
|
||||
private _state: LGraphState = {
|
||||
lastGroupId: 0,
|
||||
@@ -249,20 +247,6 @@ export class LGraph
|
||||
_nodes_in_order: LGraphNode[] = []
|
||||
_nodes_executable: LGraphNode[] | null = null
|
||||
_groups: LGraphGroup[] = []
|
||||
iteration: number = 0
|
||||
globaltime: number = 0
|
||||
/** @deprecated Unused */
|
||||
runningtime: number = 0
|
||||
fixedtime: number = 0
|
||||
fixedtime_lapse: number = 0.01
|
||||
elapsed_time: number = 0.01
|
||||
last_update_time: number = 0
|
||||
starttime: number = 0
|
||||
catch_errors: boolean = true
|
||||
execution_timer_id?: number | null
|
||||
errors_in_execution?: boolean
|
||||
/** @deprecated Unused */
|
||||
execution_time!: number
|
||||
_last_trigger_time?: number
|
||||
filter?: string
|
||||
/** Must contain serialisable values, e.g. primitive types */
|
||||
@@ -329,12 +313,6 @@ export class LGraph
|
||||
this.state.lastLinkId = value
|
||||
}
|
||||
|
||||
onAfterStep?(): void
|
||||
onBeforeStep?(): void
|
||||
onPlayEvent?(): void
|
||||
onStopEvent?(): void
|
||||
onAfterExecute?(): void
|
||||
onExecuteStep?(): void
|
||||
onNodeAdded?(node: LGraphNode): void
|
||||
onNodeRemoved?(node: LGraphNode): void
|
||||
onTrigger?: LGraphTriggerHandler
|
||||
@@ -374,9 +352,6 @@ export class LGraph
|
||||
* Removes all nodes from this graph
|
||||
*/
|
||||
clear(): void {
|
||||
this.stop()
|
||||
this.status = LGraph.STATUS_STOPPED
|
||||
|
||||
const graphId = this.id
|
||||
if (this.isRootGraph && graphId !== zeroUuid) {
|
||||
usePromotionStore().clearGraph(graphId)
|
||||
@@ -422,26 +397,12 @@ export class LGraph
|
||||
// other scene stuff
|
||||
this._groups = []
|
||||
|
||||
// iterations
|
||||
this.iteration = 0
|
||||
|
||||
// custom data
|
||||
this.config = {}
|
||||
this.vars = {}
|
||||
// to store custom data
|
||||
this.extra = {}
|
||||
|
||||
// timing
|
||||
this.globaltime = 0
|
||||
this.runningtime = 0
|
||||
this.fixedtime = 0
|
||||
this.fixedtime_lapse = 0.01
|
||||
this.elapsed_time = 0.01
|
||||
this.last_update_time = 0
|
||||
this.starttime = 0
|
||||
|
||||
this.catch_errors = true
|
||||
|
||||
this.nodes_executing = []
|
||||
this.nodes_actioning = []
|
||||
this.nodes_executedAction = []
|
||||
@@ -498,146 +459,6 @@ export class LGraph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in 0.9
|
||||
* Starts running this graph every interval milliseconds.
|
||||
* @param interval amount of milliseconds between executions, if 0 then it renders to the monitor refresh rate
|
||||
*/
|
||||
start(interval?: number): void {
|
||||
if (this.status == LGraph.STATUS_RUNNING) return
|
||||
this.status = LGraph.STATUS_RUNNING
|
||||
|
||||
this.onPlayEvent?.()
|
||||
this.sendEventToAllNodes('onStart')
|
||||
|
||||
// launch
|
||||
this.starttime = LiteGraph.getTime()
|
||||
this.last_update_time = this.starttime
|
||||
interval ||= 0
|
||||
|
||||
// execute once per frame
|
||||
if (
|
||||
interval == 0 &&
|
||||
typeof window != 'undefined' &&
|
||||
window.requestAnimationFrame
|
||||
) {
|
||||
const on_frame = () => {
|
||||
if (this.execution_timer_id != -1) return
|
||||
|
||||
window.requestAnimationFrame(on_frame)
|
||||
this.onBeforeStep?.()
|
||||
this.runStep(1, !this.catch_errors)
|
||||
this.onAfterStep?.()
|
||||
}
|
||||
this.execution_timer_id = -1
|
||||
on_frame()
|
||||
} else {
|
||||
// execute every 'interval' ms
|
||||
// @ts-expect-error - Timer ID type mismatch needs fixing
|
||||
this.execution_timer_id = setInterval(() => {
|
||||
// execute
|
||||
this.onBeforeStep?.()
|
||||
this.runStep(1, !this.catch_errors)
|
||||
this.onAfterStep?.()
|
||||
}, interval)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in 0.9
|
||||
* Stops the execution loop of the graph
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.status == LGraph.STATUS_STOPPED) return
|
||||
|
||||
this.status = LGraph.STATUS_STOPPED
|
||||
|
||||
this.onStopEvent?.()
|
||||
|
||||
if (this.execution_timer_id != null) {
|
||||
if (this.execution_timer_id != -1) {
|
||||
clearInterval(this.execution_timer_id)
|
||||
}
|
||||
this.execution_timer_id = null
|
||||
}
|
||||
|
||||
this.sendEventToAllNodes('onStop')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run N steps (cycles) of the graph
|
||||
* @param num number of steps to run, default is 1
|
||||
* @param do_not_catch_errors [optional] if you want to try/catch errors
|
||||
* @param limit max number of nodes to execute (used to execute from start to a node)
|
||||
*/
|
||||
runStep(num: number, do_not_catch_errors: boolean, limit?: number): void {
|
||||
num = num || 1
|
||||
|
||||
const start = LiteGraph.getTime()
|
||||
this.globaltime = 0.001 * (start - this.starttime)
|
||||
|
||||
const nodes = this._nodes_executable || this._nodes
|
||||
if (!nodes) return
|
||||
|
||||
limit = limit || nodes.length
|
||||
|
||||
if (do_not_catch_errors) {
|
||||
// iterations
|
||||
for (let i = 0; i < num; i++) {
|
||||
for (let j = 0; j < limit; ++j) {
|
||||
const node = nodes[j]
|
||||
// FIXME: Looks like copy/paste broken logic - checks for "on", executes "do"
|
||||
if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) {
|
||||
// wrap node.onExecute();
|
||||
node.doExecute?.()
|
||||
}
|
||||
}
|
||||
|
||||
this.fixedtime += this.fixedtime_lapse
|
||||
this.onExecuteStep?.()
|
||||
}
|
||||
|
||||
this.onAfterExecute?.()
|
||||
} else {
|
||||
try {
|
||||
// iterations
|
||||
for (let i = 0; i < num; i++) {
|
||||
for (let j = 0; j < limit; ++j) {
|
||||
const node = nodes[j]
|
||||
if (node.mode == LGraphEventMode.ALWAYS) {
|
||||
node.onExecute?.()
|
||||
}
|
||||
}
|
||||
|
||||
this.fixedtime += this.fixedtime_lapse
|
||||
this.onExecuteStep?.()
|
||||
}
|
||||
|
||||
this.onAfterExecute?.()
|
||||
this.errors_in_execution = false
|
||||
} catch (error) {
|
||||
this.errors_in_execution = true
|
||||
if (LiteGraph.throw_errors) throw error
|
||||
|
||||
if (LiteGraph.debug) console.error('Error during execution:', error)
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
const now = LiteGraph.getTime()
|
||||
let elapsed = now - start
|
||||
if (elapsed == 0) elapsed = 1
|
||||
|
||||
this.execution_time = 0.001 * elapsed
|
||||
this.globaltime += 0.001 * elapsed
|
||||
this.iteration += 1
|
||||
this.elapsed_time = (now - this.last_update_time) * 0.001
|
||||
this.last_update_time = now
|
||||
this.nodes_executing = []
|
||||
this.nodes_actioning = []
|
||||
this.nodes_executedAction = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the graph execution order according to relevance of the nodes (nodes with only outputs have more relevance than
|
||||
* nodes with only inputs.
|
||||
@@ -824,33 +645,6 @@ export class LGraph
|
||||
this.setDirtyCanvas(true, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of time the graph has been running in milliseconds
|
||||
* @returns number of milliseconds the graph has been running
|
||||
*/
|
||||
getTime(): number {
|
||||
return this.globaltime
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of time accumulated using the fixedtime_lapse var.
|
||||
* This is used in context where the time increments should be constant
|
||||
* @returns number of milliseconds the graph has been running
|
||||
*/
|
||||
getFixedTime(): number {
|
||||
return this.fixedtime
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of time it took to compute the latest iteration.
|
||||
* Take into account that this number could be not correct
|
||||
* if the nodes are using graphical actions
|
||||
* @returns number of milliseconds it took the last cycle
|
||||
*/
|
||||
getElapsedTime(): number {
|
||||
return this.elapsed_time
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the internal version counter.
|
||||
* Currently only read for debug display in {@link LGraphCanvas.renderInfo}.
|
||||
@@ -860,39 +654,6 @@ export class LGraph
|
||||
this._version++
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in 0.9
|
||||
* Sends an event to all the nodes, useful to trigger stuff
|
||||
* @param eventname the name of the event (function to be called)
|
||||
* @param params parameters in array format
|
||||
*/
|
||||
sendEventToAllNodes(
|
||||
eventname: string,
|
||||
params?: object | object[],
|
||||
mode?: LGraphEventMode
|
||||
): void {
|
||||
mode = mode || LGraphEventMode.ALWAYS
|
||||
|
||||
const nodes = this._nodes_in_order || this._nodes
|
||||
if (!nodes) return
|
||||
|
||||
for (const node of nodes) {
|
||||
// @ts-expect-error deprecated
|
||||
if (!node[eventname] || node.mode != mode) continue
|
||||
if (params === undefined) {
|
||||
// @ts-expect-error deprecated
|
||||
node[eventname]()
|
||||
} else if (params && params.constructor === Array) {
|
||||
// @ts-expect-error deprecated
|
||||
// eslint-disable-next-line prefer-spread
|
||||
node[eventname].apply(node, params)
|
||||
} else {
|
||||
// @ts-expect-error deprecated
|
||||
node[eventname](params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an action on every canvas registered to this graph.
|
||||
* @param action Action to run for every canvas
|
||||
|
||||
@@ -48,9 +48,9 @@ describe('LGraphCanvas.renderInfo', () => {
|
||||
try {
|
||||
lgCanvas.renderInfo(ctx, 10, 0)
|
||||
|
||||
// lineCount = 5 (graph present, no info_text), lineHeight = 13
|
||||
// lineCount = 3 (graph present, no info_text), lineHeight = 13
|
||||
// y = canvas.height / DPR - (lineCount + 1) * lineHeight
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 2160 / 2 - 6 * 13)
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 2160 / 2 - 4 * 13)
|
||||
} finally {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
value: originalDPR,
|
||||
|
||||
@@ -1837,6 +1837,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// this.offset = [0,0];
|
||||
this.dragging_rectangle = null
|
||||
|
||||
for (const item of this.selectedItems.keys()) item.selected = undefined
|
||||
this.selected_nodes = {}
|
||||
this.selected_group = null
|
||||
this.selectedItems.clear()
|
||||
@@ -5403,7 +5404,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
*/
|
||||
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
const lineHeight = 13
|
||||
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
|
||||
const lineCount = (this.graph ? 3 : 1) + (this.info_text ? 1 : 0)
|
||||
x = x || 10
|
||||
y =
|
||||
y ||
|
||||
@@ -5420,12 +5421,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctx.textAlign = 'left'
|
||||
let line = 1
|
||||
if (this.graph) {
|
||||
ctx.fillText(
|
||||
`T: ${this.graph.globaltime.toFixed(2)}s`,
|
||||
5,
|
||||
lineHeight * line++
|
||||
)
|
||||
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
|
||||
ctx.fillText(
|
||||
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
|
||||
5,
|
||||
|
||||
@@ -1422,8 +1422,6 @@ export class LGraphNode
|
||||
// @ts-expect-error deprecated
|
||||
this.graph.nodes_executing[this.id] = false
|
||||
|
||||
// save execution/action ref
|
||||
this.exec_version = this.graph.iteration
|
||||
if (options?.action_call) {
|
||||
this.action_call = options.action_call
|
||||
// @ts-expect-error deprecated
|
||||
|
||||
@@ -584,9 +584,12 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
id: 'Comfy.UI.TabBarLayout',
|
||||
category: ['Appearance', 'General'],
|
||||
name: 'Tab Bar Layout',
|
||||
type: 'hidden',
|
||||
type: 'combo',
|
||||
options: ['Default', 'Legacy'],
|
||||
tooltip: 'Controls the elements contained in the integrated tab bar.',
|
||||
defaultValue: 'Default',
|
||||
migrateDeprecatedValue: () => 'Default'
|
||||
migrateDeprecatedValue: (value: unknown) =>
|
||||
value === 'Integrated' ? 'Default' : value
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Appearance.DisableAnimations',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataS
|
||||
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
import { getJobWorkflow } from '@/services/jobOutputCache'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
/**
|
||||
* Extract workflow from AssetItem using jobs API
|
||||
@@ -51,11 +52,11 @@ export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
|
||||
// Handle both string and object workflow data
|
||||
const workflow =
|
||||
typeof workflowData.workflow === 'string'
|
||||
? JSON.parse(workflowData.workflow)
|
||||
: workflowData.workflow
|
||||
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflowData.workflow)
|
||||
: (workflowData.workflow as ComfyWorkflowJSON)
|
||||
|
||||
return {
|
||||
workflow: workflow as ComfyWorkflowJSON,
|
||||
workflow,
|
||||
filename: baseFilename
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ const zSettings = z.object({
|
||||
'Comfy.ConfirmClear': z.boolean(),
|
||||
'Comfy.DevMode': z.boolean(),
|
||||
'Comfy.Appearance.DisableAnimations': z.boolean(),
|
||||
'Comfy.UI.TabBarLayout': z.literal('Default'),
|
||||
'Comfy.UI.TabBarLayout': z.enum(['Default', 'Legacy']),
|
||||
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),
|
||||
'Comfy.Desktop.CloudNotificationShown': z.boolean(),
|
||||
|
||||
@@ -84,6 +84,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import type { ExtensionManager } from '@/types/extensionTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
|
||||
import {
|
||||
@@ -946,8 +947,6 @@ export class ComfyApp {
|
||||
}
|
||||
)
|
||||
|
||||
this.rootGraph.start()
|
||||
|
||||
// Ensure the canvas fills the window
|
||||
useResizeObserver(this.canvasElRef, ([canvasEl]) => {
|
||||
if (canvasEl.target instanceof HTMLCanvasElement) {
|
||||
@@ -1091,7 +1090,7 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
// Check for old clipboard format
|
||||
const data = JSON.parse(template.data)
|
||||
const data = parseJsonWithNonFinite<{ reroutes?: unknown }>(template.data)
|
||||
if (!data.reroutes) {
|
||||
deserialiseAndCreate(template.data, app.canvas)
|
||||
} else {
|
||||
@@ -1802,7 +1801,9 @@ export class ComfyApp {
|
||||
let workflowObj: ComfyWorkflowJSON | undefined = undefined
|
||||
try {
|
||||
workflowObj =
|
||||
typeof workflow === 'string' ? JSON.parse(workflow) : workflow
|
||||
typeof workflow === 'string'
|
||||
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow)
|
||||
: (workflow as ComfyWorkflowJSON)
|
||||
|
||||
// Only load workflow if parsing succeeded AND validation passed
|
||||
if (
|
||||
@@ -1831,7 +1832,9 @@ export class ComfyApp {
|
||||
if (prompt) {
|
||||
try {
|
||||
const promptObj =
|
||||
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
|
||||
typeof prompt === 'string'
|
||||
? parseJsonWithNonFinite<ComfyApiWorkflow>(prompt)
|
||||
: prompt
|
||||
if (this.isApiJson(promptObj)) {
|
||||
this.loadApiJson(promptObj, fileName)
|
||||
return
|
||||
|
||||
@@ -8,6 +8,12 @@ export const EXPECTED_PROMPT = {
|
||||
'1': { class_type: 'KSampler', inputs: {} }
|
||||
}
|
||||
|
||||
// API prompt as parsed from the `with_nan_metadata.*` fixtures, after the
|
||||
// loader coerces bare NaN/Infinity tokens to null.
|
||||
export const EXPECTED_PROMPT_NAN_COERCED = {
|
||||
'1': { class_type: 'KSampler', inputs: { cfg: null, denoise: null } }
|
||||
}
|
||||
|
||||
type ReadMethod = 'readAsText' | 'readAsArrayBuffer'
|
||||
|
||||
export function mockFileReaderError(method: ReadMethod): void {
|
||||
|
||||
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.avif
Normal file
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 486 B |
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.flac
Normal file
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.flac
Normal file
Binary file not shown.
1
src/scripts/metadata/__fixtures__/with_nan_metadata.json
Normal file
1
src/scripts/metadata/__fixtures__/with_nan_metadata.json
Normal file
@@ -0,0 +1 @@
|
||||
{"1":{"class_type":"KSampler","inputs":{"cfg":NaN,"denoise":Infinity}}}
|
||||
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.mp3
Normal file
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.mp3
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.mp4
Normal file
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.mp4
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.opus
Normal file
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.opus
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.png
Normal file
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 B |
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.webm
Normal file
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.webm
Normal file
Binary file not shown.
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.webp
Normal file
BIN
src/scripts/metadata/__fixtures__/with_nan_metadata.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 B |
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EXPECTED_PROMPT,
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
EXPECTED_WORKFLOW,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
import { getFromAvifFile } from './avif'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.avif')
|
||||
const nanFixturePath = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__/with_nan_metadata.avif'
|
||||
)
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -25,6 +30,16 @@ describe('AVIF metadata', () => {
|
||||
expect(JSON.parse(result.prompt)).toEqual(EXPECTED_PROMPT)
|
||||
})
|
||||
|
||||
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
|
||||
const bytes = fs.readFileSync(nanFixturePath)
|
||||
const file = new File([bytes], 'nan.avif', { type: 'image/avif' })
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(JSON.parse(result.prompt)).toEqual(EXPECTED_PROMPT_NAN_COERCED)
|
||||
})
|
||||
|
||||
it('returns empty for non-AVIF data', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new Uint8Array(16)], 'fake.avif')
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import {
|
||||
type AvifIinfBox,
|
||||
type AvifIlocBox,
|
||||
@@ -6,6 +10,7 @@ import {
|
||||
ComfyMetadataTags,
|
||||
type IsobmffBoxContentRange
|
||||
} from '@/types/metadataTypes'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
const readNullTerminatedString = (
|
||||
dataView: DataView,
|
||||
@@ -281,7 +286,10 @@ function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata {
|
||||
if (typeof value === 'string') {
|
||||
if (key === 'usercomment') {
|
||||
try {
|
||||
const metadataJson = JSON.parse(value)
|
||||
const metadataJson = parseJsonWithNonFinite<{
|
||||
prompt?: ComfyApiWorkflow
|
||||
workflow?: ComfyWorkflowJSON
|
||||
}>(value)
|
||||
if (metadataJson.prompt) {
|
||||
metadata[ComfyMetadataTags.PROMPT] = metadataJson.prompt
|
||||
}
|
||||
@@ -301,7 +309,9 @@ function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata {
|
||||
ComfyMetadataTags.WORKFLOW.toLowerCase()
|
||||
) {
|
||||
try {
|
||||
const jsonValue = JSON.parse(metadataValue)
|
||||
const jsonValue = parseJsonWithNonFinite<
|
||||
ComfyApiWorkflow | ComfyWorkflowJSON
|
||||
>(metadataValue)
|
||||
metadata[metadataKey.toLowerCase() as keyof ComfyMetadata] =
|
||||
jsonValue
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EXPECTED_PROMPT,
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
EXPECTED_WORKFLOW,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
import { getFromWebmFile } from './ebml'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.webm')
|
||||
const nanFixturePath = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__/with_nan_metadata.webm'
|
||||
)
|
||||
|
||||
describe('WebM/EBML metadata', () => {
|
||||
it('extracts workflow and prompt from EBML SimpleTag elements', async () => {
|
||||
@@ -23,6 +28,16 @@ describe('WebM/EBML metadata', () => {
|
||||
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||
})
|
||||
|
||||
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
|
||||
const bytes = fs.readFileSync(nanFixturePath)
|
||||
const file = new File([bytes], 'nan.webm', { type: 'video/webm' })
|
||||
|
||||
const result = await getFromWebmFile(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
|
||||
})
|
||||
|
||||
it('returns empty for non-WebM data', async () => {
|
||||
const file = new File([new Uint8Array(16)], 'fake.webm')
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type TextRange,
|
||||
type VInt
|
||||
} from '@/types/metadataTypes'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
const WEBM_SIGNATURE = [0x1a, 0x45, 0xdf, 0xa3]
|
||||
const MAX_READ_BYTES = 2 * 1024 * 1024
|
||||
@@ -245,7 +246,9 @@ const parseJsonText = (
|
||||
if (jsonEndPos === null) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonText.substring(0, jsonEndPos))
|
||||
return parseJsonWithNonFinite<ComfyWorkflowJSON | ComfyApiWorkflow>(
|
||||
jsonText.substring(0, jsonEndPos)
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
|
||||
|
||||
import {
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError
|
||||
} from './__fixtures__/helpers'
|
||||
@@ -15,12 +16,6 @@ describe('GLTF binary metadata parser', () => {
|
||||
return { header, headerView }
|
||||
}
|
||||
|
||||
const jsonToBinary = (json: object) => {
|
||||
const jsonString = JSON.stringify(json)
|
||||
const jsonData = new TextEncoder().encode(jsonString)
|
||||
return jsonData
|
||||
}
|
||||
|
||||
const createJSONChunk = (jsonData: ArrayBuffer) => {
|
||||
const chunkHeader = new ArrayBuffer(GltfSizeBytes.CHUNK_HEADER)
|
||||
const chunkView = new DataView(chunkHeader)
|
||||
@@ -51,7 +46,14 @@ describe('GLTF binary metadata parser', () => {
|
||||
}
|
||||
|
||||
function createMockGltfFile(jsonContent: object): File {
|
||||
const jsonData = jsonToBinary(jsonContent)
|
||||
return createMockGltfFileFromText(JSON.stringify(jsonContent))
|
||||
}
|
||||
|
||||
// Builds a GLB whose JSON chunk is the literal text passed in - used to
|
||||
// embed Python generated bare NaN/Infinity tokens that JSON.stringify
|
||||
// would otherwise coerce to null.
|
||||
function createMockGltfFileFromText(jsonText: string): File {
|
||||
const jsonData = new TextEncoder().encode(jsonText)
|
||||
const { header, headerView } = createGLTFFileStructure()
|
||||
|
||||
setHeaders(headerView, jsonData.buffer)
|
||||
@@ -159,6 +161,18 @@ describe('GLTF binary metadata parser', () => {
|
||||
expect(workflow.nodes[0].type).toBe('StringifiedNode')
|
||||
})
|
||||
|
||||
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
|
||||
const pythonJsonText =
|
||||
'{"asset":{"version":"2.0","extras":{"prompt":' +
|
||||
'{"1":{"class_type":"KSampler","inputs":{"cfg":NaN,"denoise":Infinity}}}' +
|
||||
'}}}'
|
||||
const mockFile = createMockGltfFileFromText(pythonJsonText)
|
||||
|
||||
const metadata = await getGltfBinaryMetadata(mockFile)
|
||||
|
||||
expect(metadata.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
|
||||
})
|
||||
|
||||
it('should handle invalid GLTF binary files gracefully', async () => {
|
||||
const invalidEmptyFile = new File([], 'invalid.glb')
|
||||
const metadata = await getGltfBinaryMetadata(invalidEmptyFile)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type GltfJsonData,
|
||||
GltfSizeBytes
|
||||
} from '@/types/metadataTypes'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
const MAX_READ_BYTES = 1 << 20
|
||||
|
||||
@@ -81,19 +82,17 @@ const extractJsonChunkData = (buffer: ArrayBuffer): Uint8Array | null => {
|
||||
return new Uint8Array(buffer, chunkLocation.start, chunkLocation.length)
|
||||
}
|
||||
|
||||
const parseJson = (text: string): ReturnType<typeof JSON.parse> | null => {
|
||||
const parseJson = <T = unknown>(text: string): T | null => {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
return parseJsonWithNonFinite<T>(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const parseJsonBytes = (
|
||||
bytes: Uint8Array
|
||||
): ReturnType<typeof JSON.parse> | null => {
|
||||
const parseJsonBytes = <T = unknown>(bytes: Uint8Array): T | null => {
|
||||
const jsonString = byteArrayToString(bytes)
|
||||
return parseJson(jsonString)
|
||||
return parseJson<T>(jsonString)
|
||||
}
|
||||
|
||||
const parseMetadataValue = (
|
||||
@@ -102,10 +101,7 @@ const parseMetadataValue = (
|
||||
if (typeof value !== 'string')
|
||||
return value as ComfyWorkflowJSON | ComfyApiWorkflow
|
||||
|
||||
const parsed = parseJson(value)
|
||||
if (!parsed) return undefined
|
||||
|
||||
return parsed as ComfyWorkflowJSON | ComfyApiWorkflow
|
||||
return parseJson<ComfyWorkflowJSON | ComfyApiWorkflow>(value) ?? undefined
|
||||
}
|
||||
|
||||
const extractComfyMetadata = (jsonData: GltfJsonData): ComfyMetadata => {
|
||||
@@ -136,7 +132,7 @@ const processGltfFileBuffer = (buffer: ArrayBuffer): ComfyMetadata => {
|
||||
const jsonChunk = extractJsonChunkData(buffer)
|
||||
if (!jsonChunk) return {}
|
||||
|
||||
const parsedJson = parseJsonBytes(jsonChunk)
|
||||
const parsedJson = parseJsonBytes<GltfJsonData>(jsonChunk)
|
||||
if (!parsedJson) return {}
|
||||
|
||||
return extractComfyMetadata(parsedJson)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EXPECTED_PROMPT,
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
EXPECTED_WORKFLOW,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
import { getFromIsobmffFile } from './isobmff'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp4')
|
||||
const nanFixturePath = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__/with_nan_metadata.mp4'
|
||||
)
|
||||
|
||||
describe('ISOBMFF (MP4) metadata', () => {
|
||||
it('extracts workflow and prompt from QuickTime keys/ilst boxes', async () => {
|
||||
@@ -23,6 +28,16 @@ describe('ISOBMFF (MP4) metadata', () => {
|
||||
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||
})
|
||||
|
||||
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
|
||||
const bytes = fs.readFileSync(nanFixturePath)
|
||||
const file = new File([bytes], 'nan.mp4', { type: 'video/mp4' })
|
||||
|
||||
const result = await getFromIsobmffFile(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
|
||||
})
|
||||
|
||||
it('returns empty for non-ISOBMFF data', async () => {
|
||||
const file = new File([new Uint8Array(16)], 'fake.mp4', {
|
||||
type: 'video/mp4'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ComfyMetadataTags,
|
||||
type IsobmffBoxContentRange
|
||||
} from '@/types/metadataTypes'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
// Set max read high, as atoms are stored near end of file
|
||||
// while search is made to be efficient.
|
||||
@@ -85,7 +86,9 @@ const extractJson = (
|
||||
|
||||
try {
|
||||
const jsonText = new TextDecoder().decode(data.slice(jsonStart, end))
|
||||
return JSON.parse(jsonText)
|
||||
return parseJsonWithNonFinite<ComfyWorkflowJSON | ComfyApiWorkflow>(
|
||||
jsonText
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError,
|
||||
mockFileReaderResult
|
||||
} from './__fixtures__/helpers'
|
||||
import { getDataFromJSON } from './json'
|
||||
|
||||
const nanFixturePath = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__/with_nan_metadata.json'
|
||||
)
|
||||
|
||||
function jsonFile(content: object): File {
|
||||
return new File([JSON.stringify(content)], 'test.json', {
|
||||
type: 'application/json'
|
||||
@@ -41,6 +49,15 @@ describe('getDataFromJSON', () => {
|
||||
expect(result).toEqual({ templates })
|
||||
})
|
||||
|
||||
it('parses Python generated API prompt with bare NaN/Infinity tokens', async () => {
|
||||
const bytes = fs.readFileSync(nanFixturePath, 'utf-8')
|
||||
const file = new File([bytes], 'nan.json', { type: 'application/json' })
|
||||
|
||||
const result = await getDataFromJSON(file)
|
||||
|
||||
expect(result).toEqual({ prompt: EXPECTED_PROMPT_NAN_COERCED })
|
||||
})
|
||||
|
||||
it('returns undefined for non-JSON content', async () => {
|
||||
const file = new File(['not valid json'], 'bad.json', {
|
||||
type: 'application/json'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isObject } from 'es-toolkit/compat'
|
||||
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
export function getDataFromJSON(
|
||||
file: File
|
||||
): Promise<Record<string, object> | undefined> {
|
||||
@@ -11,9 +13,11 @@ export function getDataFromJSON(
|
||||
resolve(undefined)
|
||||
return
|
||||
}
|
||||
const jsonContent = JSON.parse(reader.result)
|
||||
const jsonContent = parseJsonWithNonFinite<Record<string, unknown>>(
|
||||
reader.result
|
||||
)
|
||||
if (jsonContent?.templates) {
|
||||
resolve({ templates: jsonContent.templates })
|
||||
resolve({ templates: jsonContent.templates as object })
|
||||
return
|
||||
}
|
||||
if (isApiJson(jsonContent)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EXPECTED_PROMPT,
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
EXPECTED_WORKFLOW,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
import { getMp3Metadata } from './mp3'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp3')
|
||||
const nanFixturePath = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__/with_nan_metadata.mp3'
|
||||
)
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -63,6 +68,16 @@ describe('MP3 metadata', () => {
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
|
||||
const bytes = fs.readFileSync(nanFixturePath)
|
||||
const file = new File([bytes], 'nan.mp3', { type: 'audio/mpeg' })
|
||||
|
||||
const result = await getMp3Metadata(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
|
||||
})
|
||||
|
||||
it('extracts metadata that spans the 4096-byte page boundary', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const metadata =
|
||||
@@ -84,6 +99,31 @@ describe('MP3 metadata', () => {
|
||||
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||
})
|
||||
|
||||
it('logs and skips when embedded JSON is malformed', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const metadata = `prompt\0{not json}\0workflow\0{also bad}\0`
|
||||
const buf = new Uint8Array(64 + metadata.length)
|
||||
buf[0] = 0xff
|
||||
buf[1] = 0xfb
|
||||
for (let i = 0; i < metadata.length; i++) {
|
||||
buf[16 + i] = metadata.charCodeAt(i)
|
||||
}
|
||||
const file = new File([buf], 'malformed.mp3', { type: 'audio/mpeg' })
|
||||
|
||||
const result = await getMp3Metadata(file)
|
||||
|
||||
expect(result.prompt).toBeUndefined()
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse MP3 prompt metadata',
|
||||
expect.any(SyntaxError)
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse MP3 workflow metadata',
|
||||
expect.any(SyntaxError)
|
||||
)
|
||||
})
|
||||
|
||||
describe('FileReader failure modes', () => {
|
||||
const file = new File([new Uint8Array(16)], 'test.mp3')
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
export async function getMp3Metadata(file: File) {
|
||||
const reader = new FileReader()
|
||||
const read_process = new Promise<ArrayBuffer | null>((r) => {
|
||||
@@ -27,10 +33,23 @@ export async function getMp3Metadata(file: File) {
|
||||
header += page
|
||||
if (page.match('\u00ff\u00fb')) break
|
||||
}
|
||||
let workflow, prompt
|
||||
let workflow: ComfyWorkflowJSON | undefined
|
||||
let prompt: ComfyApiWorkflow | undefined
|
||||
let prompt_s = header.match(/prompt\u0000(\{.*?\})\u0000/s)?.[1]
|
||||
if (prompt_s) prompt = JSON.parse(prompt_s)
|
||||
if (prompt_s) {
|
||||
try {
|
||||
prompt = parseJsonWithNonFinite<ComfyApiWorkflow>(prompt_s)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse MP3 prompt metadata', e)
|
||||
}
|
||||
}
|
||||
let workflow_s = header.match(/workflow\u0000(\{.*?\})\u0000/s)?.[1]
|
||||
if (workflow_s) workflow = JSON.parse(workflow_s)
|
||||
if (workflow_s) {
|
||||
try {
|
||||
workflow = parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow_s)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse MP3 workflow metadata', e)
|
||||
}
|
||||
}
|
||||
return { prompt, workflow }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EXPECTED_PROMPT,
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
EXPECTED_WORKFLOW,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
import { getOggMetadata } from './ogg'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.opus')
|
||||
const nanFixturePath = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__/with_nan_metadata.opus'
|
||||
)
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -25,6 +30,16 @@ describe('OGG/Opus metadata', () => {
|
||||
expect(result.prompt).toEqual(EXPECTED_PROMPT)
|
||||
})
|
||||
|
||||
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
|
||||
const bytes = fs.readFileSync(nanFixturePath)
|
||||
const file = new File([bytes], 'nan.opus', { type: 'audio/ogg' })
|
||||
|
||||
const result = await getOggMetadata(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
|
||||
})
|
||||
|
||||
it('returns undefined fields for non-OGG data', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new Uint8Array(16)], 'fake.ogg', {
|
||||
@@ -52,6 +67,32 @@ describe('OGG/Opus metadata', () => {
|
||||
expect(result.prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('logs and skips when embedded JSON is malformed', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const metadata = `prompt={not json}\0workflow={also bad}\0`
|
||||
const oggs = new TextEncoder().encode('OggS\0')
|
||||
const buf = new Uint8Array(128)
|
||||
buf.set(oggs, 0)
|
||||
for (let i = 0; i < metadata.length; i++) {
|
||||
buf[16 + i] = metadata.charCodeAt(i)
|
||||
}
|
||||
buf.set(oggs, 16 + metadata.length + 8)
|
||||
const file = new File([buf], 'malformed.opus', { type: 'audio/ogg' })
|
||||
|
||||
const result = await getOggMetadata(file)
|
||||
|
||||
expect(result.prompt).toBeUndefined()
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse Ogg prompt metadata',
|
||||
expect.any(SyntaxError)
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse Ogg workflow metadata',
|
||||
expect.any(SyntaxError)
|
||||
)
|
||||
})
|
||||
|
||||
describe('FileReader failure modes', () => {
|
||||
const file = new File([new Uint8Array(16)], 'test.ogg')
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
export async function getOggMetadata(file: File) {
|
||||
const reader = new FileReader()
|
||||
const read_process = new Promise<ArrayBuffer | null>((r) => {
|
||||
@@ -24,14 +30,27 @@ export async function getOggMetadata(file: File) {
|
||||
header += page
|
||||
if (oggs > 1) break
|
||||
}
|
||||
let workflow, prompt
|
||||
let workflow: ComfyWorkflowJSON | undefined
|
||||
let prompt: ComfyApiWorkflow | undefined
|
||||
let prompt_s = header
|
||||
.match(/prompt=(\{.*?(\}.*?\u0000))/s)?.[1]
|
||||
?.match(/\{.*\}/)?.[0]
|
||||
if (prompt_s) prompt = JSON.parse(prompt_s)
|
||||
if (prompt_s) {
|
||||
try {
|
||||
prompt = parseJsonWithNonFinite<ComfyApiWorkflow>(prompt_s)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse Ogg prompt metadata', e)
|
||||
}
|
||||
}
|
||||
let workflow_s = header
|
||||
.match(/workflow=(\{.*?(\}.*?\u0000))/s)?.[1]
|
||||
?.match(/\{.*\}/)?.[0]
|
||||
if (workflow_s) workflow = JSON.parse(workflow_s)
|
||||
if (workflow_s) {
|
||||
try {
|
||||
workflow = parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow_s)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse Ogg workflow metadata', e)
|
||||
}
|
||||
}
|
||||
return { prompt, workflow }
|
||||
}
|
||||
|
||||
@@ -39,4 +39,18 @@ describe('getSvgMetadata', () => {
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('coerces bare NaN/Infinity tokens to null (Python json.dumps output)', async () => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata><![CDATA[{"prompt": {"1": {"class_type": "KSampler", "inputs": {"cfg": NaN, "denoise": Infinity}}}}]]></metadata>
|
||||
</svg>`
|
||||
|
||||
const result = await getSvgMetadata(svgFile(svg))
|
||||
|
||||
expect(result).toEqual({
|
||||
prompt: {
|
||||
'1': { class_type: 'KSampler', inputs: { cfg: null, denoise: null } }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ComfyMetadata } from '@/types/metadataTypes'
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
export async function getSvgMetadata(file: File): Promise<ComfyMetadata> {
|
||||
const text = await file.text()
|
||||
@@ -7,7 +8,7 @@ export async function getSvgMetadata(file: File): Promise<ComfyMetadata> {
|
||||
|
||||
if (metadataMatch && metadataMatch[1]) {
|
||||
try {
|
||||
return JSON.parse(metadataMatch[1].trim())
|
||||
return parseJsonWithNonFinite<ComfyMetadata>(metadataMatch[1].trim())
|
||||
} catch (error) {
|
||||
console.error('Error parsing SVG metadata:', error)
|
||||
return {}
|
||||
|
||||
@@ -289,7 +289,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
)
|
||||
const workflowExtra = workflow.initialState.extra
|
||||
const description =
|
||||
workflowExtra?.BlueprintDescription ?? 'User generated subgraph blueprint'
|
||||
workflowExtra?.BlueprintDescription ??
|
||||
workflow.initialState?.definitions?.subgraphs[0].description ??
|
||||
'User generated subgraph blueprint'
|
||||
const search_aliases = workflowExtra?.BlueprintSearchAliases
|
||||
const subgraphDefCategory =
|
||||
workflow.initialState.definitions?.subgraphs?.[0]?.category
|
||||
|
||||
242
src/utils/jsonUtil.test.ts
Normal file
242
src/utils/jsonUtil.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('parseJsonWithNonFinite', () => {
|
||||
it('parses standard JSON unchanged', () => {
|
||||
expect(
|
||||
parseJsonWithNonFinite(
|
||||
'{"x": 1, "y": "hello", "z": [1, 2, null, true, false]}'
|
||||
)
|
||||
).toEqual({ x: 1, y: 'hello', z: [1, 2, null, true, false] })
|
||||
})
|
||||
|
||||
it('handles compact Python separators with no spaces', () => {
|
||||
expect(parseJsonWithNonFinite('{"a":NaN,"b":Infinity}')).toEqual({
|
||||
a: null,
|
||||
b: null
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces NaN as the last value before object close', () => {
|
||||
expect(parseJsonWithNonFinite('{"a":1,"b":NaN}')).toEqual({
|
||||
a: 1,
|
||||
b: null
|
||||
})
|
||||
})
|
||||
|
||||
it('handles multi-line pretty-printed Python output', () => {
|
||||
expect(
|
||||
parseJsonWithNonFinite('{\n "x": NaN,\n "y": Infinity\n}')
|
||||
).toEqual({
|
||||
x: null,
|
||||
y: null
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces NaN deeply nested across object and array levels', () => {
|
||||
expect(
|
||||
parseJsonWithNonFinite(
|
||||
'{"a": {"b": {"c": [1, {"d": [NaN, [Infinity, {"e": -Infinity}]]}]}}}'
|
||||
)
|
||||
).toEqual({
|
||||
a: { b: { c: [1, { d: [null, [null, { e: null }]] }] } }
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
['NaN', null],
|
||||
['Infinity', null],
|
||||
['-Infinity', null],
|
||||
['null', null],
|
||||
['true', true],
|
||||
['false', false],
|
||||
['{}', {}],
|
||||
['[]', []]
|
||||
] as const)('parses bare top-level value: %s', ([input, expected]) => {
|
||||
expect(parseJsonWithNonFinite(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['[NaN]', [null]],
|
||||
['[Infinity]', [null]],
|
||||
['[-Infinity]', [null]]
|
||||
] as const)(
|
||||
'coerces token at right-boundary of array: %s',
|
||||
([input, expected]) => {
|
||||
expect(parseJsonWithNonFinite(input)).toEqual(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
['tab', '{"x":\tNaN}'],
|
||||
['newline', '{"x":\nNaN}'],
|
||||
['carriage return', '{"x":\rNaN}'],
|
||||
['runs of spaces', '{"x": NaN}']
|
||||
])('treats %s as a delimiter', ([, input]) => {
|
||||
expect(parseJsonWithNonFinite(input)).toEqual({ x: null })
|
||||
})
|
||||
|
||||
it('preserves NaN appearing inside string values', () => {
|
||||
expect(parseJsonWithNonFinite('{"desc": "value is NaN here"}')).toEqual({
|
||||
desc: 'value is NaN here'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves Infinity appearing inside string values', () => {
|
||||
expect(parseJsonWithNonFinite('{"x": "to Infinity and beyond"}')).toEqual({
|
||||
x: 'to Infinity and beyond'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves NaN appearing as a string key', () => {
|
||||
expect(parseJsonWithNonFinite('{"NaN": 1, "Infinity": 2}')).toEqual({
|
||||
NaN: 1,
|
||||
Infinity: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves token-like substrings inside strings with escaped quotes', () => {
|
||||
expect(
|
||||
parseJsonWithNonFinite('{"x": "say \\"NaN\\" loud", "y": NaN}')
|
||||
).toEqual({
|
||||
x: 'say "NaN" loud',
|
||||
y: null
|
||||
})
|
||||
})
|
||||
|
||||
it('handles escaped backslash immediately before a closing quote', () => {
|
||||
expect(parseJsonWithNonFinite('{"x": "a\\\\", "y": NaN}')).toEqual({
|
||||
x: 'a\\',
|
||||
y: null
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves token-like text after escape sequences inside strings', () => {
|
||||
expect(
|
||||
parseJsonWithNonFinite('{"x": "a\\nNaN", "y": "\\u0022Infinity\\u0022"}')
|
||||
).toEqual({ x: 'a\nNaN', y: '"Infinity"' })
|
||||
})
|
||||
|
||||
it('throws SyntaxError on otherwise-invalid JSON', () => {
|
||||
expect(() => parseJsonWithNonFinite('{not json}')).toThrow(SyntaxError)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['NaN with trailing digits', '{"x": NaN123}'],
|
||||
['Infinity with trailing letter', '{"x": Infinityy}'],
|
||||
['-Infinity with trailing digit', '{"x": -Infinity0}'],
|
||||
['adjacent NaNs', '{"x": NaNNaN}'],
|
||||
['-Infinity with trailing letter', '{"x": -Infinityy}']
|
||||
])('throws on partial token match: %s', ([, input]) => {
|
||||
expect(() => parseJsonWithNonFinite(input)).toThrow(SyntaxError)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['after digit', '{"x": 1-Infinity}'],
|
||||
['after decimal float', '{"x": 1.5-Infinity}']
|
||||
])(
|
||||
'throws when -Infinity is not delimiter-bounded on the left: %s',
|
||||
([, input]) => {
|
||||
expect(() => parseJsonWithNonFinite(input)).toThrow(SyntaxError)
|
||||
}
|
||||
)
|
||||
|
||||
it('throws on unterminated string ending in a lone backslash', () => {
|
||||
expect(() => parseJsonWithNonFinite('{"x": "abc\\')).toThrow(SyntaxError)
|
||||
})
|
||||
|
||||
it('throws on unsupported +Infinity prefix', () => {
|
||||
expect(() => parseJsonWithNonFinite('{"x": +Infinity}')).toThrow(
|
||||
SyntaxError
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat non-ASCII letters as a token boundary (throws)', () => {
|
||||
expect(() => parseJsonWithNonFinite('{"x": éNaN}')).toThrow(SyntaxError)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['top-level JSON string of NaN', '"NaN"', 'NaN'],
|
||||
['token alone as string value', '{"x": "NaN"}', { x: 'NaN' }],
|
||||
[
|
||||
'-Infinity alone as string value',
|
||||
'{"x": "-Infinity"}',
|
||||
{ x: '-Infinity' }
|
||||
],
|
||||
[
|
||||
'multiple tokens in one string',
|
||||
'{"x": "NaN Infinity -Infinity"}',
|
||||
{ x: 'NaN Infinity -Infinity' }
|
||||
],
|
||||
[
|
||||
'token as prefix of identifier in string',
|
||||
'{"s": "NaNny"}',
|
||||
{ s: 'NaNny' }
|
||||
],
|
||||
[
|
||||
'hyphen-bracketed Infinity in string',
|
||||
'{"s": "pre-Infinity-post"}',
|
||||
{ s: 'pre-Infinity-post' }
|
||||
]
|
||||
] as const)(
|
||||
'preserves token text inside string contexts: %s',
|
||||
([, input, expected]) => {
|
||||
expect(parseJsonWithNonFinite(input)).toEqual(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves numeric exponents (does not match Infinity prefix)', () => {
|
||||
expect(parseJsonWithNonFinite('[1e10, -1.5e-3]')).toEqual([1e10, -1.5e-3])
|
||||
})
|
||||
|
||||
it.for([
|
||||
['token embedded in identifier', '{"x": fooNaNbar}'],
|
||||
['Infinity followed by decimal point', '{"x": Infinity.123}'],
|
||||
['trailing garbage after valid JSON', '{"a": 1} extra'],
|
||||
['bare unknown identifier', '{"a": Foo}']
|
||||
])('throws on invalid JSON: %s', ([, input]) => {
|
||||
expect(() => parseJsonWithNonFinite(input)).toThrow(SyntaxError)
|
||||
})
|
||||
|
||||
it('handles 10k-element array of mixed tokens without backtracking', () => {
|
||||
const items = Array.from({ length: 10000 }, (_, i) =>
|
||||
i % 3 === 0 ? 'NaN' : i % 3 === 1 ? 'Infinity' : '-Infinity'
|
||||
).join(',')
|
||||
const result = parseJsonWithNonFinite<null[]>(`[${items}]`)
|
||||
expect(result).toHaveLength(10000)
|
||||
expect(result[0]).toBeNull()
|
||||
expect(result[9999]).toBeNull()
|
||||
})
|
||||
|
||||
describe('fallback warning', () => {
|
||||
it('does not warn when strict parse succeeds', () => {
|
||||
parseJsonWithNonFinite('{"a": 1}')
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns once per call regardless of how many tokens are replaced', () => {
|
||||
parseJsonWithNonFinite('{"a": NaN, "b": Infinity, "c": [-Infinity, NaN]}')
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('warns again on a separate call', () => {
|
||||
parseJsonWithNonFinite('{"a": NaN}')
|
||||
parseJsonWithNonFinite('{"b": Infinity}')
|
||||
expect(console.warn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not warn when the relaxed parse itself throws on input with no tokens', () => {
|
||||
expect(() => parseJsonWithNonFinite('{not json}')).toThrow(SyntaxError)
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
37
src/utils/jsonUtil.ts
Normal file
37
src/utils/jsonUtil.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Parse JSON that may contain bare `NaN`/`Infinity`/`-Infinity` tokens
|
||||
* (which Python's `json.dumps` emits) by replacing them with `null` on
|
||||
* fallback. Coercion is lossy; a one-time warning is logged when it fires.
|
||||
*/
|
||||
export function parseJsonWithNonFinite<T = unknown>(text: string): T {
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
return JSON.parse(replaceNonFiniteTokens(text)) as T
|
||||
}
|
||||
}
|
||||
|
||||
// Match a JSON string OR a non-finite token outside string.
|
||||
// - `"(?:\\.|[^"\\])*"` a quoted string - matched first so anything that
|
||||
// looks like a token inside a string is skipped
|
||||
// - `(?<![\w.-])` skip non bare tokens (e.g. 1NaN)
|
||||
// - `(-?Infinity|NaN)` capture the non-finite token
|
||||
// - `(?![\w.])` skip non bare suffix tokens (e.g. NaN1)
|
||||
const NON_FINITE_TOKEN =
|
||||
/"(?:\\.|[^"\\])*"|(?<![\w.-])(-?Infinity|NaN)(?![\w.])/g
|
||||
|
||||
function replaceNonFiniteTokens(text: string): string {
|
||||
let hasWarned = false
|
||||
return text.replace(NON_FINITE_TOKEN, (match, token) => {
|
||||
if (token) {
|
||||
if (!hasWarned) {
|
||||
console.warn(
|
||||
'JSON contained non-finite numeric tokens (NaN/Infinity); they were replaced with null.'
|
||||
)
|
||||
hasWarned = true
|
||||
}
|
||||
return 'null'
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
133
tools/devtools/subgraphs/test blueprint.json
Normal file
133
tools/devtools/subgraphs/test blueprint.json
Normal file
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "d0056772-3aca-4bc2-9971-8781df454c2b",
|
||||
"pos": [470.93671874999995, 461],
|
||||
"size": [225, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": []
|
||||
},
|
||||
"widgets_values": [],
|
||||
"title": "test blueprint"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "d0056772-3aca-4bc2-9971-8781df454c2b",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "test blueprint",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [240.43671874999995, 435, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [713.43671875, 435, 128, 68]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "d291ee9e-b1a6-4a9b-8163-ae7980ad312d",
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": [344.43671874999995, 459]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "4836730c-14a8-487d-9009-594b7e745076",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [2],
|
||||
"pos": [737.43671875, 459]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageInvert",
|
||||
"pos": [428.43671874999995, 438],
|
||||
"size": [225, 72],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image",
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {},
|
||||
"description": "Inverts the image"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extra": {}
|
||||
}
|
||||
Reference in New Issue
Block a user