Compare commits

..

1 Commits

Author SHA1 Message Date
filtered
c684732199 Allow fast widget conversion from any node 2024-10-31 03:32:26 +11:00
80 changed files with 588 additions and 1203 deletions

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event.label.name == 'New Browser Test Expectations'
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend

View File

@@ -15,7 +15,7 @@ jobs:
jest-tests:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
- name: Run Jest tests
run: |
npm run test:generate
@@ -26,7 +26,7 @@ jobs:
playwright-tests-chromium:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
@@ -43,7 +43,7 @@ jobs:
playwright-tests-chromium-2x:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
@@ -60,7 +60,7 @@ jobs:
playwright-tests-mobile-chrome:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend

View File

@@ -62,14 +62,6 @@ There will be a 2-day feature freeze before each stable release. During this per
### Major features
<details>
<summary>v1.3.22: Integrated server terminal</summary>
Press Ctrl + ` to toggle integrated terminal.
https://github.com/user-attachments/assets/eddedc6a-07a3-4a83-9475-63b3977f6d94
</details>
<details>
<summary>v1.3.7: Keybinding customization</summary>
@@ -115,18 +107,6 @@ https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
### QoL changes
<details>
<summary>v1.3.32: **Litegraph** Nested group</summary>
https://github.com/user-attachments/assets/f51adeb1-028e-40af-81e4-0ac13075198a
</details>
<details>
<summary>v1.3.24: **Litegraph** Group selection</summary>
https://github.com/user-attachments/assets/e6230a94-411e-4fba-90cb-6c694200adaa
</details>
<details>
<summary>v1.3.6: **Litegraph** Toggle link visibility</summary>

View File

@@ -1,227 +0,0 @@
{
"last_node_id": 3,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "KSampler",
"pos": {
"0": 420,
"1": 130
},
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 3,
"type": "KSampler",
"pos": {
"0": 820,
"1": 130
},
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 1,
"type": "KSampler",
"pos": {
"0": 30,
"1": 130
},
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
}
],
"links": [],
"groups": [
{
"id": 0,
"title": "Group",
"bounding": [
406.9701232910156,
59.079444885253906,
335,
345.6000061035156
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Group Parent",
"bounding": [
796.9703979492188,
14.796443939208984,
355,
399.20001220703125
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Group Child",
"bounding": [
806.9703979492188,
58.39643096923828,
335,
345.6000061035156
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -51,9 +51,7 @@ test.describe('Missing models warning', () => {
}, comfyPage.url)
})
// Flaky test after parallelization
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
test.skip('Should display a warning when missing models are found', async ({
test('Should display a warning when missing models are found', async ({
comfyPage
}) => {
// The fake_model.safetensors is served by

View File

@@ -80,58 +80,22 @@ test.describe('Topbar commands', () => {
)
})
test.describe('Settings', () => {
test('Should allow adding settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!',
onChange: () => {
window['changeCount'] = (window['changeCount'] ?? 0) + 1
}
}
]
})
test('Should allow adding settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!'
}
]
})
// onChange is called when the setting is first added
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
})
test('Should allow setting boolean settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'Comfy.TestSetting',
name: 'Test Setting',
type: 'boolean',
defaultValue: false,
onChange: () => {
window['changeCount'] = (window['changeCount'] ?? 0) + 1
}
}
]
})
})
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(false)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
})
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
})
})

View File

@@ -17,7 +17,6 @@ import {
import { Topbar } from './components/Topbar'
import { NodeReference } from './utils/litegraphUtils'
import type { Position, Size } from './types'
import { SettingDialog } from './components/SettingDialog'
class ComfyMenu {
public readonly sideToolbar: Locator
@@ -90,15 +89,6 @@ export class ComfyPage {
public readonly menu: ComfyMenu
public readonly actionbar: ComfyActionbar
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
/** Worker index to test user ID */
public readonly userIds: string[] = []
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
}
constructor(
public readonly page: Page,
@@ -114,7 +104,6 @@ export class ComfyPage {
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page)
}
convertLeafToContent(structure: FolderStructure): FolderStructure {
@@ -154,7 +143,7 @@ export class ComfyPage {
{
data: {
tree_structure: this.convertLeafToContent(structure),
base_path: `user/${this.id}/workflows`
base_path: 'user/default/workflows'
}
}
)
@@ -166,31 +155,6 @@ export class ComfyPage {
}
}
async setupUser(username: string) {
const res = await this.request.get(`${this.url}/users`)
if (res.status() !== 200)
throw new Error(`Failed to retrieve users: ${await res.text()}`)
const apiRes = await res.json()
const user = Object.entries(apiRes?.users ?? {}).find(
([, name]) => name === username
)
const id = user?.[0]
return id ? id : await this.createUser(username)
}
async createUser(username: string) {
const resp = await this.request.post(`${this.url}/users`, {
data: { username }
})
if (resp.status() !== 200)
throw new Error(`Failed to create user: ${await resp.text()}`)
return await resp.json()
}
async setupSettings(settings: Record<string, any>) {
const resp = await this.request.post(
`${this.url}/api/devtools/set_settings`,
@@ -206,13 +170,10 @@ export class ComfyPage {
async setup() {
await this.goto()
await this.page.evaluate((id) => {
await this.page.evaluate(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, this.id)
await this.goto()
})
// Unify font for consistent screenshots.
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
@@ -650,14 +611,10 @@ export class ComfyPage {
x: nodePos.x + nodeSize.width * ratioX,
y: nodePos.y + nodeSize.height * ratioY
}
// -1 to be inside the node. -2 because nodes currently get an arbitrary +1 to width.
await this.dragAndDrop(
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
target
)
await this.dragAndDrop(bottomRight, target)
await this.nextFrame()
if (revertAfter) {
await this.dragAndDrop({ x: target.x - 2, y: target.y - 1 }, bottomRight)
await this.dragAndDrop(target, bottomRight)
await this.nextFrame()
}
}
@@ -668,20 +625,14 @@ export class ComfyPage {
revertAfter: boolean = false
) {
const ksamplerPos = {
x: 863,
y: 156
x: 864,
y: 157
}
const ksamplerSize = {
width: 315,
height: 292
}
return this.resizeNode(
ksamplerPos,
ksamplerSize,
percentX,
percentY,
revertAfter
)
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter)
}
async resizeLoadCheckpointNode(
@@ -690,14 +641,14 @@ export class ComfyPage {
revertAfter: boolean = false
) {
const loadCheckpointPos = {
x: 26,
y: 444
x: 25,
y: 440
}
const loadCheckpointSize = {
width: 315,
height: 127
width: 320,
height: 120
}
return this.resizeNode(
this.resizeNode(
loadCheckpointPos,
loadCheckpointSize,
percentX,
@@ -712,14 +663,14 @@ export class ComfyPage {
revertAfter: boolean = false
) {
const emptyLatentPos = {
x: 473,
y: 579
x: 475,
y: 580
}
const emptyLatentSize = {
width: 315,
height: 136
width: 303,
height: 132
}
return this.resizeNode(
this.resizeNode(
emptyLatentPos,
emptyLatentSize,
percentX,
@@ -771,12 +722,6 @@ export class ComfyPage {
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ page, request }, use) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = comfyPageFixture.info()
const username = `playwright-test-${parallelIndex}`
const userId = await comfyPage.setupUser(username)
comfyPage.userIds[parallelIndex] = userId
await comfyPage.setupSettings({
// Hide canvas menu/info by default.
'Comfy.Graph.CanvasInfo': false,
@@ -785,8 +730,7 @@ export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
// Disable tooltips by default to avoid flakiness.
'Comfy.EnableTooltips': false,
'Comfy.userId': userId
'Comfy.EnableTooltips': false
})
await comfyPage.setup()
await use(comfyPage)

View File

@@ -1,34 +0,0 @@
import { Page } from '@playwright/test'
export class SettingDialog {
constructor(public readonly page: Page) {}
async open() {
const button = this.page.locator('button.comfy-settings-btn:visible')
await button.click()
await this.page.waitForSelector('div.settings-container')
}
/**
* Set the value of a text setting
* @param id - The id of the setting
* @param value - The value to set
*/
async setStringSetting(id: string, value: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
await settingInputDiv.locator('input').fill(value)
}
/**
* Toggle the value of a boolean setting
* @param id - The id of the setting
*/
async toggleBooleanSetting(id: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
await settingInputDiv.locator('input').click()
}
}

View File

@@ -139,9 +139,7 @@ test.describe('Group Node', () => {
}) => {
await comfyPage.loadWorkflow('legacy_group_node')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.locator('.comfy-missing-nodes')
).not.toBeVisible()
expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
})
test.describe('Copy and paste', () => {

View File

@@ -1,27 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await expect(comfyPage.canvas).toHaveScreenshot('selected-all.png')
await comfyPage.canvas.press('Delete')
await expect(comfyPage.canvas).toHaveScreenshot('deleted-all.png')
})
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png')
})
})
test.describe('Node Interaction', () => {
test('Can enter prompt', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox
@@ -33,7 +12,7 @@ test.describe('Node Interaction', () => {
})
test.describe('Node Selection', () => {
const multiSelectModifiers = ['Control', 'Shift', 'Meta'] as const
const multiSelectModifiers = ['Control', 'Shift', 'Meta']
multiSelectModifiers.forEach((modifier) => {
test(`Can add multiple nodes to selection using ${modifier}+Click`, async ({
@@ -137,24 +116,6 @@ test.describe('Node Interaction', () => {
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot('copied-link.png')
})
test('Auto snap&highlight when dragging link over node', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.page.mouse.move(
comfyPage.clipTextEncodeNode1InputSlot.x,
comfyPage.clipTextEncodeNode1InputSlot.y
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
comfyPage.clipTextEncodeNode2InputSlot.x,
comfyPage.clipTextEncodeNode2InputSlot.y
)
await expect(comfyPage.canvas).toHaveScreenshot('snapped-highlighted.png')
})
})
test('Can adjust widget value', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -494,31 +494,6 @@ test.describe('Menu', () => {
})
expect(await exportTag.count()).toBe(1)
})
test('Can catch error when executing command', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo-command',
function: () => {
throw new Error('foo!')
}
}
],
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
})
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
})
// Only test 'Top' to reduce test time.

View File

@@ -27,14 +27,10 @@ test.describe('Optional input', () => {
test('Only optional inputs', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('only_optional_inputs')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.locator('.comfy-missing-nodes')
).not.toBeVisible()
expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
// If the node's multiline text widget is visible, then it was loaded successfully
await expect(comfyPage.page.locator('.comfy-multiline-input')).toHaveCount(
1
)
expect(comfyPage.page.locator('.comfy-multiline-input')).toHaveCount(1)
})
test('Old workflow with converted input', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('old_workflow_converted_input')

View File

@@ -138,8 +138,6 @@ test.describe('Release context menu', () => {
test('Can trigger on link release', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'link-release-context-menu.png'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,12 +0,0 @@
import type { JestConfigWithTsJest } from 'ts-jest'
import baseConfig from './jest.config.base'
const jestConfig: JestConfigWithTsJest = {
...baseConfig,
displayName: 'fast',
setupFiles: ['./tests-ui/tests/fast/globalSetup.ts'],
setupFilesAfterEnv: undefined,
testMatch: ['**/tests-ui/tests/fast/**/*.test.ts']
}
export default jestConfig

View File

@@ -1,11 +0,0 @@
import type { JestConfigWithTsJest } from 'ts-jest'
import baseConfig from './jest.config.base'
const jestConfig: JestConfigWithTsJest = {
...baseConfig,
displayName: 'slow (DOM)',
testTimeout: 10000,
testMatch: ['**/tests-ui/tests/slow/**/*.test.ts']
}
export default jestConfig

View File

@@ -14,14 +14,15 @@ const jestConfig: JestConfigWithTsJest = {
}
]
},
setupFiles: ['./tests-ui/globalSetup.ts'],
setupFilesAfterEnv: ['./tests-ui/afterSetup.ts'],
clearMocks: true,
resetModules: true,
testTimeout: 10000,
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
clearMocks: true,
resetModules: true,
setupFiles: ['./tests-ui/tests/globalSetup.ts'],
setupFilesAfterEnv: ['./tests-ui/tests/afterSetup.ts']
}
}
export default jestConfig

12
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "comfyui-frontend",
"version": "1.3.33",
"version": "1.3.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.3.33",
"version": "1.3.28",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/litegraph": "^0.8.19",
"@comfyorg/litegraph": "^0.8.11",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",
@@ -1911,9 +1911,9 @@
"dev": true
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.19.tgz",
"integrity": "sha512-SmeqvljlIwYFCIgdVnvUiPDzPXAt6oWXvh2RJHQZUqSnIxCB73yzHp6vBgz/SwU3PrEL0H14wQMeW8RkcMn9IQ==",
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.11.tgz",
"integrity": "sha512-oTKWUWDaAjCV1kZROimKsevDJUSEIpSzjLdm8lE2IwMV+O1KUQNV3Y+/Xw4RywxGL4Z0zOpdI0aYA9zSB81w7Q==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,19 +1,16 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.3.33",
"version": "1.3.28",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"deploy": "npm run build && node scripts/deploy.js",
"release": "node scripts/release.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "tsc --noEmit && tsc-strict",
"format": "prettier --write './**/*.{js,ts,tsx,vue}'",
"test": "jest --config jest.config.base.ts",
"test:jest:fast": "jest --config jest.config.fast.ts",
"test:jest:slow": "jest --config jest.config.slow.ts",
"test": "npm run build && jest",
"test:generate:examples": "npx tsx tests-ui/extractExamples",
"test:generate": "npx tsx tests-ui/setup",
"test:browser": "npx playwright test",
@@ -70,7 +67,7 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/litegraph": "^0.8.19",
"@comfyorg/litegraph": "^0.8.11",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",

View File

@@ -18,6 +18,8 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -1,30 +0,0 @@
import { execSync } from 'child_process'
import { readFileSync } from 'fs'
try {
// Create a new branch with version-bump prefix
console.log('Creating new branch...')
const date = new Date().toISOString().split('T')[0]
const timestamp = new Date().getTime()
const branchName = `version-bump-${date}-${timestamp}`
execSync(`git checkout -b ${branchName} -t origin/main`, { stdio: 'inherit' })
// Run npm version patch and capture the output
console.log('Bumping version...')
execSync('npm version patch', { stdio: 'inherit' })
// Read the new version from package.json
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'))
const newVersion = packageJson.version
// Create the PR
console.log('Creating PR...')
execSync(
`gh pr create --title "${newVersion}" --label "Release" --body "Automated version bump to ${newVersion}"`,
{ stdio: 'inherit' }
)
console.log(`✅ Successfully created PR for version ${newVersion}`)
} catch (error) {
console.error('❌ Error during release process:', error.message)
}

View File

@@ -66,12 +66,6 @@ const bottomPanelVisible = computed(
pointer-events: auto;
}
:deep(.p-splitter-gutter:hover),
:deep(.p-splitter-gutter[data-p-gutter-resizing='true']) {
transition: background-color 0.2s ease 300ms;
background-color: var(--p-primary-color);
}
.side-bar-panel {
background-color: var(--bg-color);
pointer-events: auto;

View File

@@ -3,7 +3,6 @@
<span v-if="!props.isEditing">
{{ modelValue }}
</span>
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
<InputText
v-else
type="text"
@@ -11,7 +10,7 @@
fluid
v-model:modelValue="inputValue"
ref="inputRef"
@keyup.enter="blurInputElement"
@keyup.enter="finishEditing"
@click.stop
:pt="{
root: {
@@ -38,12 +37,13 @@ const props = withDefaults(defineProps<EditableTextProps>(), {
const emit = defineEmits(['update:modelValue', 'edit'])
const inputValue = ref<string>(props.modelValue)
const isEditingFinished = ref<boolean>(false)
const inputRef = ref(null)
const blurInputElement = () => {
inputRef.value?.$el.blur()
}
const finishEditing = () => {
if (isEditingFinished.value) {
return
}
isEditingFinished.value = true
emit('edit', inputValue.value)
}
watch(
@@ -51,6 +51,7 @@ watch(
(newVal) => {
if (newVal) {
inputValue.value = props.modelValue
isEditingFinished.value = false
nextTick(() => {
if (!inputRef.value) return
const fileName = inputValue.value.includes('.')

View File

@@ -80,11 +80,9 @@ const errorHandling = useErrorHandling()
const handleRename = errorHandling.wrapWithErrorHandlingAsync(
async (newName: string) => {
await props.node.handleRename(props.node, newName)
},
props.node.handleError,
() => {
renameEditingNode.value = null
}
},
props.node.handleError
)
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)

View File

@@ -51,10 +51,8 @@ describe('EditableText', () => {
})
await wrapper.findComponent(InputText).setValue('New Text')
await wrapper.findComponent(InputText).trigger('keyup.enter')
// Blur event should have been triggered
expect(wrapper.findComponent(InputText).element).not.toBe(
document.activeElement
)
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')[0]).toEqual(['New Text'])
})
it('finishes editing on blur', async () => {

View File

@@ -86,6 +86,7 @@ function getSettingAttrs(setting: SettingParams) {
}
const updateSetting = (setting: SettingParams, value: any) => {
if (setting.onChange) setting.onChange(value, settingStore.get(setting.id))
settingStore.set(setting.id, value)
}

View File

@@ -53,8 +53,6 @@ import {
} from '@/stores/modelToNodeStore'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { useWorkflowStore } from '@/stores/workflowStore'
import { setStorageValue } from '@/scripts/utils'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -87,16 +85,6 @@ watchEffect(() => {
}
})
watchEffect(() => {
LiteGraph.snaps_for_comfy = settingStore.get('Comfy.Node.AutoSnapLinkToSlot')
})
watchEffect(() => {
LiteGraph.snap_highlights_node = settingStore.get(
'Comfy.Node.SnapHighlightsNode'
)
})
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -143,14 +131,6 @@ watchEffect(() => {
canvasStore.canvas.canvas.style.cursor = 'default'
})
const workflowStore = useWorkflowStore()
watchEffect(() => {
if (workflowStore.activeWorkflow) {
const workflow = workflowStore.activeWorkflow
setStorageValue('Comfy.PreviousWorkflow', workflow.path ?? workflow.name)
}
})
usePragmaticDroppable(() => canvasRef.value, {
onDrop: (event) => {
const loc = event.location.current.input

View File

@@ -23,6 +23,35 @@ const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
const getHoveredWidget = () => {
const node = comfyApp.canvas.node_over
if (!node.widgets) return
const graphPos = comfyApp.canvas.graph_mouse
const x = graphPos[0] - node.pos[0]
const y = graphPos[1] - node.pos[1]
for (const w of node.widgets) {
let widgetWidth: number, widgetHeight: number
if (w.computeSize) {
;[widgetWidth, widgetHeight] = w.computeSize(node.size[0])
} else {
widgetWidth = (w as { width?: number }).width || node.size[0]
widgetHeight = LiteGraph.NODE_WIDGET_HEIGHT
}
if (
w.last_y !== undefined &&
x >= 6 &&
x <= widgetWidth - 12 &&
y >= w.last_y &&
y <= w.last_y + widgetHeight
) {
return w
}
}
}
const hideTooltip = () => (tooltipText.value = null)
const showTooltip = async (tooltip: string | null | undefined) => {
@@ -82,7 +111,7 @@ const onIdle = () => {
return showTooltip(nodeDef.output.all?.[outputSlot]?.tooltip)
}
const widget = comfyApp.canvas.getWidgetAtCursor()
const widget = getHoveredWidget()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !widget.element) {
return showTooltip(

View File

@@ -94,9 +94,10 @@ const handleSearch = async (query: string) => {
type ModelOrFolder = ComfyModelDef | ModelFolder
const root = computed<TreeNode>(() => {
const allNodes: ModelOrFolder[] = searchQuery.value
? filteredModels.value
: [...modelStore.modelFolders, ...modelStore.models]
const allNodes: ModelOrFolder[] = [
...modelStore.modelFolders,
...(searchQuery.value ? filteredModels.value : modelStore.models)
]
return buildTree(allNodes, (modelOrFolder: ModelOrFolder) =>
modelOrFolder.key.split('/')
)

View File

@@ -177,12 +177,12 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
const actions = node.leaf
? {
handleClick,
handleRename: async (
handleRename: (
node: TreeExplorerNode<ComfyWorkflow>,
newName: string
) => {
const workflow = node.data
await workflow.rename(newName)
workflow.rename(newName)
},
handleDelete: workflow.isTemporary
? undefined

View File

@@ -39,6 +39,7 @@ import { useI18n } from 'vue-i18n'
import { useTreeExpansion } from '@/hooks/treeHooks'
import { app } from '@/scripts/app'
import { findNodeByKey } from '@/utils/treeUtil'
import { useErrorHandling } from '@/hooks/errorHooks'
const props = defineProps<{
filteredNodeDefs: ComfyNodeDefImpl[]
@@ -211,11 +212,13 @@ defineExpose({
addNewBookmarkFolder
})
const handleRename = (node: TreeNode, newName: string) => {
if (node.data && node.data.isDummyFolder) {
nodeBookmarkStore.renameBookmarkFolder(node.data, newName)
const handleRename = useErrorHandling().wrapWithErrorHandling(
(node: TreeNode, newName: string) => {
if (node.data && node.data.isDummyFolder) {
nodeBookmarkStore.renameBookmarkFolder(node.data, newName)
}
}
}
)
const showCustomizationDialog = ref(false)
const initialIcon = ref(nodeBookmarkStore.defaultBookmarkIcon)

View File

@@ -29,7 +29,7 @@ const ext = {
// We must request an animation frame for the current node of the active canvas to update.
requestAnimationFrame(() => {
const currentNode = LGraphCanvas.active_canvas.current_node
const clickedComboValue = currentNode?.widgets
const clickedComboValue = currentNode.widgets
?.filter(
(w) =>
w.type === 'combo' && w.options.values?.length === values.length

View File

@@ -1,17 +1,66 @@
// @ts-strict-ignore
import { LGraphGroup } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
import { LGraphCanvas } from '@comfyorg/litegraph'
import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
import type { LGraphNode } from '@comfyorg/litegraph'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
function setNodeMode(node: LGraphNode, mode: number) {
function setNodeMode(node, mode) {
node.mode = mode
node.graph?.change()
node.graph.change()
}
function addNodesToGroup(group: LGraphGroup, items: Iterable<Positionable>) {
group.resizeTo([...group.children, ...items])
function addNodesToGroup(group, nodes = []) {
var x1, y1, x2, y2
var nx1, ny1, nx2, ny2
var node
x1 = y1 = x2 = y2 = -1
nx1 = ny1 = nx2 = ny2 = -1
for (var n of [group.nodes, nodes]) {
for (var i in n) {
node = n[i]
nx1 = node.pos[0]
ny1 = node.pos[1]
nx2 = node.pos[0] + node.size[0]
ny2 = node.pos[1] + node.size[1]
if (node.type != 'Reroute') {
ny1 -= LiteGraph.NODE_TITLE_HEIGHT
}
if (node.flags?.collapsed) {
ny2 = ny1 + LiteGraph.NODE_TITLE_HEIGHT
if (node?._collapsed_width) {
nx2 = nx1 + Math.round(node._collapsed_width)
}
}
if (x1 == -1 || nx1 < x1) {
x1 = nx1
}
if (y1 == -1 || ny1 < y1) {
y1 = ny1
}
if (x2 == -1 || nx2 > x2) {
x2 = nx2
}
if (y2 == -1 || ny2 > y2) {
y2 = ny2
}
}
}
var padding = 10
y1 = y1 - Math.round(group.font_size * 1.4)
group.pos = [x1 - padding, y1 - padding]
group.size = [x2 - x1 + padding * 2, y2 - y1 + padding * 2]
}
app.registerExtension({
@@ -19,9 +68,7 @@ app.registerExtension({
setup() {
const orig = LGraphCanvas.prototype.getCanvasMenuOptions
// graph_mouse
LGraphCanvas.prototype.getCanvasMenuOptions = function (
this: LGraphCanvas
) {
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments)
const group = this.graph.getGroupOnPos(
this.graph_mouse[0],
@@ -30,11 +77,11 @@ app.registerExtension({
if (!group) {
options.push({
content: 'Add Group For Selected Nodes',
disabled: !this.selectedItems?.size,
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
callback: () => {
const group = new LGraphGroup()
addNodesToGroup(group, this.selectedItems)
this.graph.add(group)
addNodesToGroup(group, this.selected_nodes)
app.canvas.graph.add(group)
this.graph.change()
}
})
@@ -48,9 +95,9 @@ app.registerExtension({
options.push({
content: 'Add Selected Nodes To Group',
disabled: !this.selectedItems?.size,
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
callback: () => {
addNodesToGroup(group, this.selectedItems)
addNodesToGroup(group, this.selected_nodes)
this.graph.change()
}
})
@@ -75,8 +122,7 @@ app.registerExtension({
options.push({
content: 'Fit Group To Nodes',
callback: () => {
group.recomputeInsideNodes()
group.resizeTo(group.children)
addNodesToGroup(group)
this.graph.change()
}
})

View File

@@ -215,7 +215,7 @@ class PrimitiveNode extends LGraphNode {
}
// Store current size as addWidget resizes the node
const [oldWidth, oldHeight] = this.size
const size = this.size
let widget
if (type in ComfyWidgets) {
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
@@ -277,8 +277,8 @@ class PrimitiveNode extends LGraphNode {
// Use the biggest dimensions in case the widgets caused the node to grow
this.size = [
Math.max(this.size[0], oldWidth),
Math.max(this.size[1], oldHeight)
Math.max(this.size[0], size[0]),
Math.max(this.size[1], size[1])
]
if (!recreating) {
@@ -489,7 +489,7 @@ export function convertToInput(
const { type } = getWidgetType(config)
// Add input and store widget config for creating on primitive node
const [oldWidth, oldHeight] = node.size
const sz = node.size
const inputIsOptional = !!widget.options?.inputIsOptional
const input = node.addInput(widget.name, type, {
widget: { name: widget.name, [GET_CONFIG]: () => config },
@@ -501,16 +501,13 @@ export function convertToInput(
}
// Restore original size but grow if needed
node.setSize([
Math.max(oldWidth, node.size[0]),
Math.max(oldHeight, node.size[1])
])
node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])])
return input
}
function convertToWidget(node, widget) {
showWidget(widget)
const [oldWidth, oldHeight] = node.size
const sz = node.size
node.removeInput(node.inputs.findIndex((i) => i.widget?.name === widget.name))
for (const widget of node.widgets) {
@@ -518,10 +515,7 @@ function convertToWidget(node, widget) {
}
// Restore original size but grow if needed
node.setSize([
Math.max(oldWidth, node.size[0]),
Math.max(oldHeight, node.size[1])
])
node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])])
}
function getWidgetType(config: InputSpec) {
@@ -722,6 +716,9 @@ app.registerExtension({
})
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
nodeType.prototype.isValidWidgetLink ??=
PrimitiveNode.prototype.isValidWidgetLink
// Add menu options to convert to/from widgets
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions
nodeType.prototype.convertWidgetToInput = function (widget) {

View File

@@ -15,34 +15,25 @@ export function useErrorHandling() {
}
const wrapWithErrorHandling =
(
action: (...args: any[]) => any,
errorHandler?: (error: any) => void,
finallyHandler?: () => void
) =>
(action: (...args: any[]) => any, errorHandler?: (error: any) => void) =>
(...args: any[]) => {
try {
return action(...args)
} catch (e) {
;(errorHandler ?? toastErrorHandler)(e)
} finally {
finallyHandler?.()
}
}
const wrapWithErrorHandlingAsync =
(
action: ((...args: any[]) => Promise<any>) | ((...args: any[]) => any),
errorHandler?: (error: any) => void,
finallyHandler?: () => void
errorHandler?: (error: any) => void
) =>
async (...args: any[]) => {
try {
return await action(...args)
} catch (e) {
;(errorHandler ?? toastErrorHandler)(e)
} finally {
finallyHandler?.()
}
}

View File

@@ -602,16 +602,10 @@ class ComfyApi extends EventTarget {
overwrite?: boolean
stringify?: boolean
throwOnError?: boolean
full_info?: boolean
} = {
overwrite: true,
stringify: true,
throwOnError: true,
full_info: false
}
} = { overwrite: true, stringify: true, throwOnError: true }
): Promise<Response> {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(file)}?overwrite=${options.overwrite}&full_info=${options.full_info}`,
`/userdata/${encodeURIComponent(file)}?overwrite=${options.overwrite}`,
{
method: 'POST',
body: options?.stringify ? JSON.stringify(data) : data,

View File

@@ -57,7 +57,6 @@ import { useExtensionStore } from '@/stores/extensionStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { shallowReactive } from 'vue'
import { type IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -1219,6 +1218,29 @@ export class ComfyApp {
}
const res = origProcessMouseDown.apply(this, arguments)
this.selected_group_moving = false
if (this.selected_group && !this.selected_group_resizing) {
var font_size =
this.selected_group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
var height = font_size * 1.4
// Move group by header
if (
LiteGraph.isInsideRectangle(
e.canvasX,
e.canvasY,
this.selected_group.pos[0],
this.selected_group.pos[1],
this.selected_group.size[0],
height
)
) {
this.selected_group_moving = true
}
}
return res
}
const origProcessMouseMove = LGraphCanvas.prototype.processMouseMove
@@ -1246,7 +1268,27 @@ export class ComfyApp {
return
}
return origProcessMouseMove.apply(this, arguments)
const orig_selected_group = this.selected_group
if (
this.selected_group &&
!this.selected_group_resizing &&
!this.selected_group_moving
) {
this.selected_group = null
}
const res = origProcessMouseMove.apply(this, arguments)
if (
orig_selected_group &&
!this.selected_group_resizing &&
!this.selected_group_moving
) {
this.selected_group = orig_selected_group
}
return res
}
}
@@ -1971,11 +2013,7 @@ export class ComfyApp {
nodeData['input']['optional']
)
}
const config: {
minWidth: number
minHeight: number
widget?: IBaseWidget
} = { minWidth: 1, minHeight: 1 }
const config = { minWidth: 1, minHeight: 1 }
for (const inputName in inputs) {
const inputData = inputs[inputName]
const type = inputData[0]
@@ -2004,23 +2042,27 @@ export class ComfyApp {
widgetCreated = false
}
if (widgetCreated && config?.widget) {
config.widget.options ??= {}
if (!inputIsRequired) {
config.widget.options.inputIsOptional = true
}
if (inputData[1]?.forceInput) {
config.widget.options.forceInput = true
}
if (inputData[1]?.defaultInput) {
config.widget.options.defaultInput = true
}
if (inputData[1]?.advanced) {
config.widget.advanced = true
}
if (inputData[1]?.hidden) {
config.widget.hidden = true
}
// @ts-expect-error
if (widgetCreated && !inputIsRequired && config?.widget) {
// @ts-expect-error
if (!config.widget.options) config.widget.options = {}
// @ts-expect-error
config.widget.options.inputIsOptional = true
}
// @ts-expect-error
if (widgetCreated && inputData[1]?.forceInput && config?.widget) {
// @ts-expect-error
if (!config.widget.options) config.widget.options = {}
// @ts-expect-error
config.widget.options.forceInput = inputData[1].forceInput
}
// @ts-expect-error
if (widgetCreated && inputData[1]?.defaultInput && config?.widget) {
// @ts-expect-error
if (!config.widget.options) config.widget.options = {}
// @ts-expect-error
config.widget.options.defaultInput = inputData[1].defaultInput
}
}
@@ -2257,7 +2299,6 @@ export class ComfyApp {
}
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (
restore_view &&
@@ -2595,7 +2636,6 @@ export class ComfyApp {
const p = await this.graphToPrompt()
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
const res = await api.queuePrompt(number, p)
this.lastNodeErrors = res.node_errors
if (this.lastNodeErrors.length > 0) {
@@ -2977,7 +3017,7 @@ export class ComfyApp {
public goToNode(nodeId: NodeId) {
const graphNode = this.graph.getNodeById(nodeId)
if (!graphNode) return
this.canvas.animateToNode(graphNode)
this.canvas.centerOnNode(graphNode)
}
}

View File

@@ -1,6 +1,9 @@
import type { ComfyApp } from '@/scripts/app'
import { $el } from '../../ui'
import { downloadBlob } from '../../utils'
import { ComfyButtonGroup } from '../components/buttonGroup'
import { showPromptDialog } from '@/services/dialogService'
import { useSettingStore } from '@/stores/settingStore'
import './menu.css'
// Export to make sure following components are shimmed and exported by vite
@@ -30,4 +33,35 @@ export class ComfyAppMenu {
this.viewGroup.element
])
}
async getFilename(defaultName: string): Promise<string | null> {
if (useSettingStore().get('Comfy.PromptFilename')) {
let filename = await showPromptDialog({
title: 'Export Workflow',
message: 'Enter the filename:',
defaultValue: defaultName
})
if (!filename) return null
if (!filename.toLowerCase().endsWith('.json')) {
filename += '.json'
}
return filename
}
return defaultName
}
async exportWorkflow(
filename: string,
promptProperty: 'workflow' | 'output'
): Promise<void> {
if (this.app.workflowManager.activeWorkflow?.path) {
filename = this.app.workflowManager.activeWorkflow.name
}
const p = await this.app.graphToPrompt()
const json = JSON.stringify(p[promptProperty], null, 2)
const blob = new Blob([json], { type: 'application/json' })
const file = await this.getFilename(filename)
if (!file) return
downloadBlob(file, blob)
}
}

View File

@@ -160,6 +160,7 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`)
}
let skipOnChange = false
let value = this.getSettingValue(id)
if (value == null) {
if (this.app.isNewUserSession) {
@@ -176,8 +177,10 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
}
// Trigger initial setting of value
onChange?.(value, undefined)
this.#dispatchChange(id, value)
if (!skipOnChange) {
onChange?.(value, undefined)
this.#dispatchChange(id, value)
}
this.settingsParamLookup[id] = params
if (this.app.vueAppReady) {

View File

@@ -3,6 +3,7 @@ import type { ComfyApp } from './app'
import { api } from './api'
import { ChangeTracker } from './changeTracker'
import { ComfyAsyncDialog } from './ui/components/asyncDialog'
import { setStorageValue } from './utils'
import { LGraphCanvas, LGraph } from '@comfyorg/litegraph'
import { appendJsonExt, trimJsonExt } from '@/utils/formatUtil'
import {
@@ -84,18 +85,6 @@ export class ComfyWorkflowManager extends EventTarget {
}
}
createTemporary(path?: string): ComfyWorkflow {
const workflow = new ComfyWorkflow(
this,
path ??
`Unsaved Workflow${
this.#unsavedCount++ ? ` (${this.#unsavedCount})` : ''
}`
)
this.workflowLookup[workflow.key] = workflow
return workflow
}
/**
* @param {string | ComfyWorkflow | null} workflow
*/
@@ -108,8 +97,15 @@ export class ComfyWorkflowManager extends EventTarget {
}
}
if (!workflow || typeof workflow === 'string') {
workflow = this.createTemporary(workflow)
if (!(toRaw(workflow) instanceof ComfyWorkflow)) {
// Still not found, either reloading a deleted workflow or blank
workflow = new ComfyWorkflow(
this,
workflow ||
'Unsaved Workflow' +
(this.#unsavedCount++ ? ` (${this.#unsavedCount})` : '')
)
this.workflowLookup[workflow.key] = workflow
}
if (!workflow.isOpen) {
@@ -119,6 +115,7 @@ export class ComfyWorkflowManager extends EventTarget {
this._activeWorkflow = workflow
setStorageValue('Comfy.PreviousWorkflow', this.activeWorkflow.path ?? '')
this.dispatchEvent(new CustomEvent('changeWorkflow'))
}
@@ -196,6 +193,13 @@ export class ComfyWorkflow {
return this.manager.workflowBookmarkStore?.isBookmarked(this.path) ?? false
}
/**
* @deprecated Use isBookmarked instead
*/
get isFavorite() {
return this.isBookmarked
}
constructor(
manager: ComfyWorkflowManager,
path: string,
@@ -306,15 +310,17 @@ export class ComfyWorkflow {
return
}
if (this.isBookmarked) {
const isFav = this.isFavorite
if (isFav) {
await this.favorite(false)
}
path = (await resp.json()).substring('workflows/'.length)
this.updatePath(path, null)
if (this.isBookmarked) {
if (isFav) {
await this.favorite(true)
}
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
setStorageValue('Comfy.PreviousWorkflow', this.path ?? '')
}
async insert() {
@@ -336,7 +342,7 @@ export class ComfyWorkflow {
async delete() {
// TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default
if (this.isBookmarked) {
if (this.isFavorite) {
await this.favorite(false)
}
const resp = await api.deleteUserData('workflows/' + this.path)
@@ -414,6 +420,7 @@ export class ComfyWorkflow {
await this.manager.loadWorkflows()
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
setStorageValue('Comfy.PreviousWorkflow', this.path ?? '')
} else if (path !== this.path) {
// Saved as, open the new copy
await this.manager.loadWorkflows()

View File

@@ -1,39 +0,0 @@
import { downloadBlob } from '@/scripts/utils'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { showPromptDialog } from './dialogService'
import { app } from '@/scripts/app'
async function getFilename(defaultName: string): Promise<string | null> {
if (useSettingStore().get('Comfy.PromptFilename')) {
let filename = await showPromptDialog({
title: 'Export Workflow',
message: 'Enter the filename:',
defaultValue: defaultName
})
if (!filename) return null
if (!filename.toLowerCase().endsWith('.json')) {
filename += '.json'
}
return filename
}
return defaultName
}
export const workflowService = {
async exportWorkflow(
filename: string,
promptProperty: 'workflow' | 'output'
): Promise<void> {
const workflow = useWorkflowStore().activeWorkflow
if (workflow?.path) {
filename = workflow.name
}
const p = await app.graphToPrompt()
const json = JSON.stringify(p[promptProperty], null, 2)
const blob = new Blob([json], { type: 'application/json' })
const file = await getFilename(filename)
if (!file) return
downloadBlob(file, blob)
}
}

View File

@@ -20,7 +20,6 @@ import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
import { useBottomPanelStore } from './workspace/bottomPanelStore'
import { LGraphNode } from '@comfyorg/litegraph'
import { useWorkspaceStore } from './workspaceStore'
import { workflowService } from '@/services/workflowService'
export interface ComfyCommand {
id: string
@@ -168,7 +167,7 @@ export const useCommandStore = defineStore('command', () => {
label: 'Export Workflow',
menubarLabel: 'Export',
function: () => {
workflowService.exportWorkflow('workflow', 'workflow')
app.menu.exportWorkflow('workflow', 'workflow')
}
},
{
@@ -177,7 +176,7 @@ export const useCommandStore = defineStore('command', () => {
label: 'Export Workflow (API Format)',
menubarLabel: 'Export (API)',
function: () => {
workflowService.exportWorkflow('workflow_api', 'output')
app.menu.exportWorkflow('workflow_api', 'output')
}
},
{
@@ -361,13 +360,14 @@ export const useCommandStore = defineStore('command', () => {
label: 'Group Selected Nodes',
versionAdded: '1.3.7',
function: () => {
const { canvas } = app
if (!canvas.selectedItems?.size) {
if (
!app.canvas.selected_nodes ||
Object.keys(app.canvas.selected_nodes).length === 0
) {
useToastStore().add({
severity: 'error',
summary: 'Nothing to group',
detail:
'Please select the nodes (or other groups) to create a group for',
summary: 'No nodes selected',
detail: 'Please select nodes to group',
life: 3000
})
return
@@ -376,8 +376,8 @@ export const useCommandStore = defineStore('command', () => {
const padding = useSettingStore().get(
'Comfy.GroupSelectedNodes.Padding'
)
group.resizeTo(canvas.selectedItems, padding)
canvas.graph.add(group)
group.addNodes(Object.values(app.canvas.selected_nodes), padding)
app.canvas.graph.add(group)
useTitleEditorStore().titleEditorTarget = group
}
},
@@ -428,19 +428,6 @@ export const useCommandStore = defineStore('command', () => {
})
}
},
{
id: 'Comfy.Canvas.ToggleSelected.Pin',
icon: 'pi pi-pin',
label: 'Pin/Unpin Selected Items',
versionAdded: '1.3.33',
function: () => {
for (const item of app.canvas.selectedItems) {
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
item.pin(!item.pinned)
}
}
}
},
{
id: 'Comfy.Canvas.ToggleSelectedNodes.Collapse',
icon: 'pi pi-minus',

View File

@@ -134,7 +134,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
combo: {
key: 'p'
},
commandId: 'Comfy.Canvas.ToggleSelected.Pin',
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Pin',
targetSelector: '#graph-canvas'
},
{

View File

@@ -458,23 +458,5 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: LiteGraph.SPLINE_LINK, text: 'Spline' },
{ value: LiteGraph.HIDDEN_LINK, text: 'Hidden' }
]
},
{
id: 'Comfy.Node.AutoSnapLinkToSlot',
name: 'Auto snap link to node slot',
tooltip:
'When dragging a link over a node, the link automatically snap to a viable input slot on the node',
type: 'boolean',
defaultValue: true,
versionAdded: '1.3.29'
},
{
id: 'Comfy.Node.SnapHighlightsNode',
name: 'Snap highlights node',
tooltip:
'When dragging a link over a node with viable input slot, highlight the node',
type: 'boolean',
defaultValue: true,
versionAdded: '1.3.29'
}
]

View File

@@ -49,7 +49,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
.map(
(command) =>
({
command: () => commandStore.execute(command.id),
command: command.function,
label: command.menubarLabel,
icon: command.icon,
tooltip: command.tooltip,

View File

@@ -28,7 +28,7 @@ export enum TaskItemDisplayStatus {
export class ResultItemImpl {
filename: string
subfolder: string
subfolder?: string
type: string
nodeId: NodeId
@@ -40,9 +40,9 @@ export class ResultItemImpl {
frame_rate?: number
constructor(obj: Record<string, any>) {
this.filename = obj.filename ?? ''
this.subfolder = obj.subfolder ?? ''
this.type = obj.type ?? ''
this.filename = obj.filename
this.subfolder = obj.subfolder
this.type = obj.type
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
@@ -55,7 +55,7 @@ export class ResultItemImpl {
const params = new URLSearchParams()
params.set('filename', this.filename)
params.set('type', this.type)
params.set('subfolder', this.subfolder)
params.set('subfolder', this.subfolder || '')
if (this.format) {
params.set('format', this.format)

View File

@@ -3,49 +3,17 @@ import { api } from '@/scripts/api'
import { buildTree } from '@/utils/treeUtil'
import { computed, ref } from 'vue'
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { UserDataFullInfo } from '@/types/apiTypes'
/**
* Represents a file in the user's data directory.
*/
export class UserFile {
/**
* Various path components.
* Example:
* - path: 'dir/file.txt'
* - directory: 'dir'
* - fullFilename: 'file.txt'
* - filename: 'file'
* - suffix: 'txt'
*/
directory: string
fullFilename: string
filename: string
suffix: string | null
isLoading: boolean = false
content: string | null = null
originalContent: string | null = null
constructor(
/**
* Path relative to ComfyUI/user/ directory.
*/
public path: string,
/**
* Last modified timestamp.
*/
public lastModified: number,
/**
* File size in bytes.
*/
public size: number
) {
this.directory = path.split('/').slice(0, -1).join('/')
this.fullFilename = path.split('/').pop() ?? path
this.filename = this.fullFilename.split('.').slice(0, -1).join('.')
this.suffix = this.fullFilename.split('.').pop() ?? null
}
) {}
get isOpen() {
return !!this.content
@@ -54,113 +22,6 @@ export class UserFile {
get isModified() {
return this.content !== this.originalContent
}
async load(): Promise<UserFile> {
this.isLoading = true
const resp = await api.getUserData(this.path)
if (resp.status !== 200) {
throw new Error(
`Failed to load file '${this.path}': ${resp.status} ${resp.statusText}`
)
}
this.content = await resp.text()
this.originalContent = this.content
this.isLoading = false
return this
}
async saveAs(newPath: string): Promise<UserFile> {
const tempFile = new TempUserFile(newPath, this.content ?? undefined)
await tempFile.save()
return tempFile
}
async save(): Promise<UserFile> {
if (!this.isModified) return this
const resp = await api.storeUserData(this.path, this.content, {
throwOnError: true,
full_info: true
})
// Note: Backend supports full_info=true feature after
// https://github.com/comfyanonymous/ComfyUI/pull/5446
const updatedFile = (await resp.json()) as string | UserDataFullInfo
if (typeof updatedFile === 'object') {
this.lastModified = updatedFile.modified
this.size = updatedFile.size
}
this.originalContent = this.content
return this
}
async delete(): Promise<void> {
const resp = await api.deleteUserData(this.path)
if (resp.status !== 204) {
throw new Error(
`Failed to delete file '${this.path}': ${resp.status} ${resp.statusText}`
)
}
}
async rename(newPath: string): Promise<UserFile> {
const resp = await api.moveUserData(this.path, newPath)
if (resp.status !== 200) {
throw new Error(
`Failed to rename file '${this.path}': ${resp.status} ${resp.statusText}`
)
}
this.path = newPath
// Note: Backend supports full_info=true feature after
// https://github.com/comfyanonymous/ComfyUI/pull/5446
const updatedFile = (await resp.json()) as string | UserDataFullInfo
if (typeof updatedFile === 'object') {
this.lastModified = updatedFile.modified
this.size = updatedFile.size
}
return this
}
}
export class TempUserFile extends UserFile {
constructor(path: string, content: string = '') {
// Initialize with current timestamp and 0 size since it's temporary
super(path, Date.now(), 0)
this.content = content
this.originalContent = content
}
// Override methods that interact with backend
async load(): Promise<TempUserFile> {
// No need to load as it's a temporary file
return this
}
async save(): Promise<TempUserFile> {
// First save should create the actual file on the backend
const resp = await api.storeUserData(this.path, this.content, {
throwOnError: true,
full_info: true
})
const updatedFile = (await resp.json()) as string | UserDataFullInfo
if (typeof updatedFile === 'object') {
this.lastModified = updatedFile.modified
this.size = updatedFile.size
}
this.originalContent = this.content
return this
}
async delete(): Promise<void> {
// No need to delete from backend as it doesn't exist there
}
async rename(newPath: string): Promise<TempUserFile> {
// Just update the path locally since it's not on backend yet
this.path = newPath
return this
}
}
export const useUserFileStore = defineStore('userFile', () => {
@@ -174,7 +35,7 @@ export const useUserFileStore = defineStore('userFile', () => {
userFiles.value.filter((file: UserFile) => file.isOpen)
)
const fileTree = computed<TreeExplorerNode<UserFile>>(
const workflowsTree = computed<TreeExplorerNode<UserFile>>(
() =>
buildTree<UserFile>(userFiles.value, (userFile: UserFile) =>
userFile.path.split('/')
@@ -185,8 +46,8 @@ export const useUserFileStore = defineStore('userFile', () => {
* Syncs the files in the given directory with the API.
* @param dir The directory to sync.
*/
const syncFiles = async (dir: string = '') => {
const files = await api.listUserDataFullInfo(dir)
const syncFiles = async () => {
const files = await api.listUserDataFullInfo('')
for (const file of files) {
const existingFile = userFilesByPath.value.get(file.path)
@@ -215,11 +76,63 @@ export const useUserFileStore = defineStore('userFile', () => {
}
}
const loadFile = async (file: UserFile) => {
file.isLoading = true
const resp = await api.getUserData(file.path)
if (resp.status !== 200) {
throw new Error(
`Failed to load file '${file.path}': ${resp.status} ${resp.statusText}`
)
}
file.content = await resp.text()
file.originalContent = file.content
file.isLoading = false
}
const saveFile = async (file: UserFile) => {
if (file.isModified) {
const resp = await api.storeUserData(file.path, file.content)
if (resp.status !== 200) {
throw new Error(
`Failed to save file '${file.path}': ${resp.status} ${resp.statusText}`
)
}
}
await syncFiles()
}
const deleteFile = async (file: UserFile) => {
const resp = await api.deleteUserData(file.path)
if (resp.status !== 204) {
throw new Error(
`Failed to delete file '${file.path}': ${resp.status} ${resp.statusText}`
)
}
await syncFiles()
}
const renameFile = async (file: UserFile, newPath: string) => {
const resp = await api.moveUserData(file.path, newPath)
if (resp.status !== 200) {
throw new Error(
`Failed to rename file '${file.path}': ${resp.status} ${resp.statusText}`
)
}
file.path = newPath
userFilesByPath.value.set(newPath, file)
userFilesByPath.value.delete(file.path)
await syncFiles()
}
return {
userFiles,
modifiedFiles,
openedFiles,
fileTree,
syncFiles
workflowsTree,
syncFiles,
loadFile,
saveFile,
deleteFile,
renameFile
}
})

View File

@@ -10,9 +10,9 @@ const zNodeType = z.string()
const zQueueIndex = z.number()
const zPromptId = z.string()
const zResultItem = z.object({
filename: z.string().optional(),
filename: z.string(),
subfolder: z.string().optional(),
type: z.string().optional()
type: z.string()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z
@@ -244,9 +244,7 @@ const zBaseInputSpecValue = z
forceInput: z.boolean().optional(),
lazy: z.boolean().optional(),
rawLink: z.boolean().optional(),
tooltip: z.string().optional(),
hidden: z.boolean().optional(),
advanced: z.boolean().optional()
tooltip: z.string().optional()
})
.passthrough()
@@ -504,9 +502,7 @@ const zSettings = z.record(z.any()).and(
'Comfy.Keybinding.NewBindings': z.array(zKeybinding),
'Comfy.Extension.Disabled': z.array(z.string()),
'Comfy.Settings.ExtensionPanel': z.boolean(),
'Comfy.LinkRenderMode': z.number(),
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),
'Comfy.Node.SnapHighlightsNode': z.boolean()
'Comfy.LinkRenderMode': z.number()
})
.optional()
)

View File

@@ -1,5 +1,5 @@
import { start } from '../utils'
import lg from '../utils/litegraph'
import { start } from './utils'
import lg from './utils/litegraph'
// Load things once per test file before to ensure its all warmed up for the tests
beforeAll(async () => {

View File

@@ -6,7 +6,7 @@ module.exports = async function () {
disconnect() {}
}
const { nop } = require('../utils/nopProxy')
const { nop } = require('./utils/nopProxy')
global.enableWebGLCanvas = nop
HTMLCanvasElement.prototype.getContext = nop

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { validateComfyWorkflow } from '@/types/comfyWorkflow'
import { defaultGraph } from '@/scripts/defaultGraph'
import { validateComfyWorkflow } from '../../src/types/comfyWorkflow'
import { defaultGraph } from '../../src/scripts/defaultGraph'
import fs from 'fs'
const WORKFLOW_DIR = 'tests-ui/workflows'

View File

@@ -1,7 +1,7 @@
import { readdirSync, readFileSync } from 'fs'
import lg from '../../utils/litegraph'
import lg from '../utils/litegraph'
import path from 'path'
import { start } from '../../utils'
import { start } from '../utils'
const WORKFLOW_DIR = 'tests-ui/workflows/examples'

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { start } from '../../utils'
import lg from '../../utils/litegraph'
import { start } from '../utils'
import lg from '../utils/litegraph'
describe('extensions', () => {
beforeEach(() => {

View File

@@ -1,22 +0,0 @@
module.exports = async function () {
jest.mock('@/services/dialogService', () => {
return {
showLoadWorkflowWarning: jest.fn(),
showMissingModelsWarning: jest.fn(),
showSettingsDialog: jest.fn(),
showExecutionErrorDialog: jest.fn(),
showTemplateWorkflowsDialog: jest.fn(),
showPromptDialog: jest
.fn()
.mockImplementation((message, defaultValue) => {
return Promise.resolve(defaultValue)
})
}
})
jest.mock('vue-i18n', () => {
return {
useI18n: jest.fn()
}
})
}

View File

@@ -1,207 +0,0 @@
import { setActivePinia, createPinia } from 'pinia'
import {
TempUserFile,
UserFile,
useUserFileStore
} from '@/stores/userFileStore'
import { api } from '@/scripts/api'
// Mock the api
jest.mock('@/scripts/api', () => ({
api: {
listUserDataFullInfo: jest.fn(),
getUserData: jest.fn(),
storeUserData: jest.fn(),
deleteUserData: jest.fn(),
moveUserData: jest.fn()
}
}))
describe('useUserFileStore', () => {
let store: ReturnType<typeof useUserFileStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useUserFileStore()
})
it('should initialize with empty files', () => {
expect(store.userFiles).toHaveLength(0)
expect(store.modifiedFiles).toHaveLength(0)
expect(store.openedFiles).toHaveLength(0)
})
describe('syncFiles', () => {
it('should add new files', async () => {
const mockFiles = [
{ path: 'dir/file1.txt', modified: 123, size: 100 },
{ path: 'dir/file2.txt', modified: 456, size: 200 }
]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(mockFiles)
await store.syncFiles('dir')
expect(store.userFiles).toHaveLength(2)
expect(store.userFiles[0].path).toBe('dir/file1.txt')
expect(store.userFiles[1].path).toBe('dir/file2.txt')
})
it('should update existing files', async () => {
const initialFile = { path: 'dir/file1.txt', modified: 123, size: 100 }
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([initialFile])
await store.syncFiles('dir')
const updatedFile = { path: 'dir/file1.txt', modified: 456, size: 200 }
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([updatedFile])
await store.syncFiles('dir')
expect(store.userFiles).toHaveLength(1)
expect(store.userFiles[0].lastModified).toBe(456)
expect(store.userFiles[0].size).toBe(200)
})
it('should remove non-existent files', async () => {
const initialFiles = [
{ path: 'dir/file1.txt', modified: 123, size: 100 },
{ path: 'dir/file2.txt', modified: 456, size: 200 }
]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(initialFiles)
await store.syncFiles('dir')
const updatedFiles = [{ path: 'dir/file1.txt', modified: 123, size: 100 }]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(updatedFiles)
await store.syncFiles('dir')
expect(store.userFiles).toHaveLength(1)
expect(store.userFiles[0].path).toBe('dir/file1.txt')
})
it('should sync root directory when no directory is specified', async () => {
const mockFiles = [{ path: 'file1.txt', modified: 123, size: 100 }]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(mockFiles)
await store.syncFiles()
expect(api.listUserDataFullInfo).toHaveBeenCalledWith('')
expect(store.userFiles).toHaveLength(1)
expect(store.userFiles[0].path).toBe('file1.txt')
})
})
describe('UserFile', () => {
describe('load', () => {
it('should load file content', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
text: () => Promise.resolve('file content')
})
await file.load()
expect(file.content).toBe('file content')
expect(file.originalContent).toBe('file content')
expect(file.isLoading).toBe(false)
})
it('should throw error on failed load', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 404,
statusText: 'Not Found'
})
await expect(file.load()).rejects.toThrow(
"Failed to load file 'file1.txt': 404 Not Found"
)
})
})
describe('save', () => {
it('should save modified file', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'modified content'
file.originalContent = 'original content'
;(api.storeUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ modified: 456, size: 200 })
})
await file.save()
expect(api.storeUserData).toHaveBeenCalledWith(
'file1.txt',
'modified content',
{ throwOnError: true, full_info: true }
)
expect(file.lastModified).toBe(456)
expect(file.size).toBe(200)
})
it('should not save unmodified file', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'content'
file.originalContent = 'content'
await file.save()
expect(api.storeUserData).not.toHaveBeenCalled()
})
})
describe('delete', () => {
it('should delete file', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.deleteUserData as jest.Mock).mockResolvedValue({ status: 204 })
await file.delete()
expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt')
})
})
describe('rename', () => {
it('should rename file', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.moveUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ modified: 456, size: 200 })
})
await file.rename('newfile.txt')
expect(api.moveUserData).toHaveBeenCalledWith(
'file1.txt',
'newfile.txt'
)
expect(file.path).toBe('newfile.txt')
expect(file.lastModified).toBe(456)
expect(file.size).toBe(200)
})
})
describe('saveAs', () => {
it('should save file with new path', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'file content'
;(api.storeUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ modified: 456, size: 200 })
})
const newFile = await file.saveAs('newfile.txt')
expect(api.storeUserData).toHaveBeenCalledWith(
'newfile.txt',
'file content',
{ throwOnError: true, full_info: true }
)
expect(newFile).toBeInstanceOf(TempUserFile)
expect(newFile.path).toBe('newfile.txt')
expect(newFile.lastModified).toBe(456)
expect(newFile.size).toBe(200)
expect(newFile.content).toBe('file content')
})
})
})
})

View File

@@ -1,12 +1,13 @@
// @ts-strict-ignore
import { createPinia, setActivePinia } from 'pinia'
import {
start,
createDefaultWorkflow,
getNodeDef,
checkBeforeAndAfterReload
} from '../../utils'
import { EzNode } from '../../utils/ezgraph'
import lg from '../../utils/litegraph'
} from '../utils'
import { EzNode } from '../utils/ezgraph'
import lg from '../utils/litegraph'
describe('group node', () => {
beforeEach(() => {
@@ -566,7 +567,7 @@ describe('group node', () => {
nodes.save
])
const { api } = await import('../../../src/scripts/api')
const { api } = await import('../../src/scripts/api')
api.dispatchEvent(new CustomEvent('execution_start', {}))
api.dispatchEvent(

View File

@@ -1,5 +1,8 @@
import { setActivePinia, createPinia } from 'pinia'
import { useKeybindingStore, KeybindingImpl } from '@/stores/keybindingStore'
import {
useKeybindingStore,
KeybindingImpl
} from '../../../src/stores/keybindingStore'
describe('useKeybindingStore', () => {
beforeEach(() => {

View File

@@ -90,9 +90,7 @@ describe('useModelStore', () => {
it('should cache model information', async () => {
enableMocks()
await store.loadModelFolders()
expect(api.getModels).toHaveBeenCalledTimes(0)
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
})

View File

@@ -0,0 +1,156 @@
import { setActivePinia, createPinia } from 'pinia'
import { UserFile, useUserFileStore } from '@/stores/userFileStore'
import { api } from '@/scripts/api'
// Mock the api
jest.mock('@/scripts/api', () => ({
api: {
listUserDataFullInfo: jest.fn(),
getUserData: jest.fn(),
storeUserData: jest.fn(),
deleteUserData: jest.fn(),
moveUserData: jest.fn()
}
}))
describe('useUserFileStore', () => {
let store: ReturnType<typeof useUserFileStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useUserFileStore()
})
it('should initialize with empty files', () => {
expect(store.userFiles).toHaveLength(0)
expect(store.modifiedFiles).toHaveLength(0)
expect(store.openedFiles).toHaveLength(0)
})
describe('syncFiles', () => {
it('should add new files', async () => {
const mockFiles = [
{ path: 'file1.txt', modified: 123, size: 100 },
{ path: 'file2.txt', modified: 456, size: 200 }
]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(mockFiles)
await store.syncFiles()
expect(store.userFiles).toHaveLength(2)
expect(store.userFiles[0].path).toBe('file1.txt')
expect(store.userFiles[1].path).toBe('file2.txt')
})
it('should update existing files', async () => {
const initialFile = { path: 'file1.txt', modified: 123, size: 100 }
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([initialFile])
await store.syncFiles()
const updatedFile = { path: 'file1.txt', modified: 456, size: 200 }
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([updatedFile])
await store.syncFiles()
expect(store.userFiles).toHaveLength(1)
expect(store.userFiles[0].lastModified).toBe(456)
expect(store.userFiles[0].size).toBe(200)
})
it('should remove non-existent files', async () => {
const initialFiles = [
{ path: 'file1.txt', modified: 123, size: 100 },
{ path: 'file2.txt', modified: 456, size: 200 }
]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(initialFiles)
await store.syncFiles()
const updatedFiles = [{ path: 'file1.txt', modified: 123, size: 100 }]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(updatedFiles)
await store.syncFiles()
expect(store.userFiles).toHaveLength(1)
expect(store.userFiles[0].path).toBe('file1.txt')
})
})
describe('loadFile', () => {
it('should load file content', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
text: () => Promise.resolve('file content')
})
await store.loadFile(file)
expect(file.content).toBe('file content')
expect(file.originalContent).toBe('file content')
expect(file.isLoading).toBe(false)
})
it('should throw error on failed load', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 404,
statusText: 'Not Found'
})
await expect(store.loadFile(file)).rejects.toThrow(
"Failed to load file 'file1.txt': 404 Not Found"
)
})
})
describe('saveFile', () => {
it('should save modified file', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'modified content'
file.originalContent = 'original content'
;(api.storeUserData as jest.Mock).mockResolvedValue({ status: 200 })
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([])
await store.saveFile(file)
expect(api.storeUserData).toHaveBeenCalledWith(
'file1.txt',
'modified content'
)
})
it('should not save unmodified file', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'content'
file.originalContent = 'content'
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([])
await store.saveFile(file)
expect(api.storeUserData).not.toHaveBeenCalled()
})
})
describe('deleteFile', () => {
it('should delete file', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.deleteUserData as jest.Mock).mockResolvedValue({ status: 204 })
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([])
await store.deleteFile(file)
expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt')
})
})
describe('renameFile', () => {
it('should rename file', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.moveUserData as jest.Mock).mockResolvedValue({ status: 200 })
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([])
await store.renameFile(file, 'newfile.txt')
expect(api.moveUserData).toHaveBeenCalledWith('file1.txt', 'newfile.txt')
expect(file.path).toBe('newfile.txt')
})
})
})

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { start } from '../../utils'
import lg from '../../utils/litegraph'
import { start } from '../utils'
import lg from '../utils/litegraph'
describe('users', () => {
beforeEach(() => {
@@ -21,14 +21,14 @@ describe('users', () => {
describe('multi-user', () => {
async function mockAddStylesheet() {
const utils = await import('../../../src/scripts/utils')
const utils = await import('../../src/scripts/utils')
utils.addStylesheet = jest.fn().mockReturnValue(Promise.resolve())
}
async function waitForUserScreenShow() {
// Wait for "show" to be called
const { UserSelectionScreen } = await import(
'../../../src/scripts/ui/userSelection'
'../../src/scripts/ui/userSelection'
)
let resolve, reject
const fn = UserSelectionScreen.prototype.show
@@ -91,7 +91,7 @@ describe('users', () => {
expect(window.getComputedStyle(menu)?.display).not.toBe('none')
// Ensure settings + templates are saved
const { api } = await import('../../../src/scripts/api')
const { api } = await import('../../src/scripts/api')
expect(api.createUser).toHaveBeenCalledTimes(+isCreate)
expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate)
expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate)
@@ -234,7 +234,7 @@ describe('users', () => {
expectNoUserScreen()
// It should store the settings
const { api } = await import('../../../src/scripts/api')
const { api } = await import('../../src/scripts/api')
expect(api.storeSettings).toHaveBeenCalledTimes(1)
expect(api.storeUserData).toHaveBeenCalledTimes(1)
expect(api.storeUserData).toHaveBeenCalledWith(
@@ -252,7 +252,7 @@ describe('users', () => {
expectNoUserScreen()
// It should store the settings
const { api } = await import('../../../src/scripts/api')
const { api } = await import('../../src/scripts/api')
expect(api.storeSettings).toHaveBeenCalledTimes(0)
expect(api.storeUserData).toHaveBeenCalledTimes(0)
expect(app.isNewUserSession).toBeFalsy()
@@ -276,7 +276,7 @@ describe('users', () => {
expectNoUserScreen()
// It should store the settings
const { api } = await import('../../../src/scripts/api')
const { api } = await import('../../src/scripts/api')
expect(api.storeSettings).toHaveBeenCalledTimes(0)
expect(api.storeUserData).toHaveBeenCalledTimes(0)
expect(app.isNewUserSession).toBeFalsy()
@@ -289,7 +289,7 @@ describe('users', () => {
expectNoUserScreen()
// It should store the settings
const { api } = await import('../../../src/scripts/api')
const { api } = await import('../../src/scripts/api')
expect(api.storeSettings).toHaveBeenCalledTimes(0)
expect(api.storeUserData).toHaveBeenCalledTimes(0)
expect(app.isNewUserSession).toBeFalsy()

View File

@@ -5,8 +5,8 @@ import {
checkBeforeAndAfterReload,
assertNotNullOrUndefined,
createDefaultWorkflow
} from '../../utils'
import lg from '../../utils/litegraph'
} from '../utils'
import lg from '../utils/litegraph'
/**
* @typedef { import("../utils/ezgraph") } Ez