Compare commits
1 Commits
v1.3.33
...
fast-widge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c684732199 |
2
.github/workflows/test-browser-exp.yaml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/test-ui.yaml
vendored
@@ -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
|
||||
|
||||
20
README.md
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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!')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 75 KiB |
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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('.')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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('/')
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
@@ -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(() => {
|
||||
@@ -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)
|
||||
})
|
||||
156
tests-ui/tests/store/userFileStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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
|
||||