Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
b8d2b8fad2 test 2025-12-10 08:40:20 -05:00
328 changed files with 4022 additions and 7868 deletions

View File

@@ -1,26 +0,0 @@
# Description: Runs shellcheck on tracked shell scripts when they change
name: "CI: Shell Validation"
on:
push:
branches:
- main
paths:
- '**/*.sh'
pull_request:
paths:
- '**/*.sh'
jobs:
shell-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install shellcheck
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
- name: Run shellcheck
run: bash ./scripts/cicd/check-shell.sh

View File

@@ -16,10 +16,6 @@ on:
type: boolean
default: false
concurrency:
group: backport-${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
backport:
if: >

View File

@@ -124,16 +124,12 @@ jobs:
- name: Stage changed snapshot files
id: changed-snapshots
run: |
set -euo pipefail
echo "=========================================="
echo "STAGING CHANGED SNAPSHOTS (Shard ${{ matrix.shardIndex }})"
echo "=========================================="
# Get list of changed snapshot files (including untracked/new files)
changed_files=$( (
git diff --name-only browser_tests/ 2>/dev/null || true
git ls-files --others --exclude-standard browser_tests/ 2>/dev/null || true
) | sort -u | grep -E '\-snapshots/' || true )
# Get list of changed snapshot files
changed_files=$(git diff --name-only browser_tests/ 2>/dev/null | grep -E '\-snapshots/' || echo "")
if [ -z "$changed_files" ]; then
echo "No snapshot changes in this shard"
@@ -155,11 +151,6 @@ jobs:
# Strip 'browser_tests/' prefix to avoid double nesting
echo "Copying changed files to staging directory..."
while IFS= read -r file; do
# Skip paths that no longer exist (e.g. deletions)
if [ ! -f "$file" ]; then
echo " → (skipped; not a file) $file"
continue
fi
# Remove 'browser_tests/' prefix
file_without_prefix="${file#browser_tests/}"
# Create parent directories
@@ -270,19 +261,11 @@ jobs:
echo "CHANGES SUMMARY"
echo "=========================================="
echo ""
echo "Changed files in browser_tests (including untracked):"
CHANGES=$(git status --porcelain=v1 --untracked-files=all -- browser_tests/)
if [ -z "$CHANGES" ]; then
echo "No changes"
echo ""
echo "Total changes:"
echo "0"
else
echo "$CHANGES" | head -50
echo ""
echo "Total changes:"
echo "$CHANGES" | wc -l
fi
echo "Changed files in browser_tests:"
git diff --name-only browser_tests/ | head -20 || echo "No changes"
echo ""
echo "Total changes:"
git diff --name-only browser_tests/ | wc -l || echo "0"
- name: Commit updated expectations
id: commit
@@ -290,7 +273,7 @@ jobs:
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
if [ -z "$(git status --porcelain=v1 --untracked-files=all -- browser_tests/)" ]; then
if git diff --quiet browser_tests/; then
echo "No changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0

View File

@@ -20,13 +20,6 @@ on:
required: true
default: 'main'
type: string
schedule:
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
- cron: '0 0 * * *'
concurrency:
group: release-version-bump
cancel-in-progress: true
jobs:
bump-version:
@@ -36,99 +29,15 @@ jobs:
pull-requests: write
steps:
- name: Prepare inputs
id: prepared-inputs
shell: bash
env:
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
run: |
set -euo pipefail
VERSION_TYPE="$RAW_VERSION_TYPE"
PRE_RELEASE="$RAW_PRE_RELEASE"
TARGET_BRANCH="$RAW_BRANCH"
if [[ -z "$VERSION_TYPE" ]]; then
VERSION_TYPE='patch'
fi
if [[ -z "$TARGET_BRANCH" ]]; then
TARGET_BRANCH='main'
fi
{
echo "version_type=$VERSION_TYPE"
echo "pre_release=$PRE_RELEASE"
echo "branch=$TARGET_BRANCH"
} >> "$GITHUB_OUTPUT"
- name: Close stale nightly version bump PRs
if: github.event_name == 'schedule'
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const prefix = 'version-bump-'
const closed = []
const prs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100
})
for (const pr of prs) {
if (!pr.head?.ref?.startsWith(prefix)) {
continue
}
if (pr.user?.login !== 'github-actions[bot]') {
continue
}
// Only clean up stale nightly PRs targeting main.
// Adjust here if other target branches should be cleaned.
if (pr.base?.ref !== 'main') {
continue
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
})
try {
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${pr.head.ref}`
})
} catch (error) {
if (![404, 422].includes(error.status)) {
throw error
}
}
closed.push(pr.number)
}
core.info(`Closed ${closed.length} stale PR(s).`)
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ steps.prepared-inputs.outputs.branch }}
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
persist-credentials: false
- name: Validate branch exists
env:
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
run: |
BRANCH="$TARGET_BRANCH"
BRANCH="${{ github.event.inputs.branch }}"
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
echo "❌ Branch '$BRANCH' does not exist"
echo ""
@@ -142,7 +51,7 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
uses: pnpm/action-setup@v4
with:
version: 10
@@ -153,31 +62,16 @@ jobs:
- name: Bump version
id: bump-version
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
run: |
set -euo pipefail
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
exit 1
fi
if [[ -n "$PRE_RELEASE" ]]; then
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
else
pnpm version "$VERSION_TYPE" --no-git-tag-version
fi
pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Format PR string
id: capitalised
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
run: |
CAPITALISED_TYPE="$VERSION_TYPE"
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
@@ -188,8 +82,8 @@ jobs:
body: |
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
**Base branch:** `${{ github.event.inputs.branch }}`
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: ${{ steps.prepared-inputs.outputs.branch }}
base: ${{ github.event.inputs.branch }}
labels: |
Release

View File

@@ -2,98 +2,25 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"components.d.ts",
"lint-staged.config.js",
"vitest.setup.ts",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",
"coverage/*",
"dist/*",
"packages/registry-types/src/comfyRegistryTypes.ts",
"playwright-report/*",
"src/extensions/core/*",
"src/scripts/*",
"src/types/generatedManagerTypes.ts",
"src/types/vue-shim.d.ts",
"test-results/*",
"vitest.setup.ts"
],
"plugins": [
"eslint",
"import",
"oxc",
"typescript",
"unicorn",
"vitest",
"vue"
"src/types/vue-shim.d.ts"
],
"rules": {
"no-async-promise-executor": "off",
"no-console": [
"error",
{
"allow": [
"warn",
"error"
]
}
],
"no-control-regex": "off",
"no-eval": "off",
"no-redeclare": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "primevue/calendar",
"message": "Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from 'primevue/datepicker'"
},
{
"name": "primevue/dropdown",
"message": "Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from 'primevue/select'"
},
{
"name": "primevue/inputswitch",
"message": "InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from 'primevue/toggleswitch'"
},
{
"name": "primevue/overlaypanel",
"message": "OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from 'primevue/popover'"
},
{
"name": "primevue/sidebar",
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
},
{
"name": "@/i18n--to-enable",
"importNames": [
"st",
"t",
"te",
"d"
],
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
}
]
}
],
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
"import/default": "error",
"import/export": "error",
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": [
"error",
"prefer-top-level"
],
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-this-alias": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
@@ -112,18 +39,6 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
"overrides": [
{
"files": [
"**/*.{stories,test,spec}.ts",
"**/*.stories.vue"
],
"rules": {
"no-console": "allow"
}
}
]
"vue/no-import-compiler-macros": "error"
}
}

View File

@@ -12,9 +12,6 @@
"declaration-property-value-no-unknown": [
true,
{
"typesSyntax": {
"radial-gradient()": "| <any-value>"
},
"ignoreProperties": {
"speak": ["none"],
"app-region": ["drag", "no-drag"],
@@ -59,7 +56,10 @@
"function-no-unknown": [
true,
{
"ignoreFunctions": ["theme", "v-bind"]
"ignoreFunctions": [
"theme",
"v-bind"
]
}
]
},

View File

@@ -51,6 +51,8 @@ defineProps<{
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()

View File

@@ -1,150 +0,0 @@
{
"id": "e0cb1d7e-5437-4911-b574-c9603dfbeaee",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "8bfe4227-f272-49e1-a892-0a972a86867c",
"pos": [
-317,
-336
],
"size": [
210,
58
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
[
"-1",
"batch_size"
]
]
},
"widgets_values": [
1
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "8bfe4227-f272-49e1-a892-0a972a86867c",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
-562,
-358,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
-52,
-358,
120,
40
]
},
"inputs": [
{
"id": "b4a8bc2a-8e9f-41aa-938d-c567a11d2c00",
"name": "batch_size",
"type": "INT",
"linkIds": [
1
],
"pos": [
-462,
-338
]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "EmptyLatentImage",
"pos": [
-382,
-376
],
"size": [
270,
106
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.35.1"
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -13,7 +13,6 @@ import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { QueueList } from './components/QueueList'
import { SettingDialog } from './components/SettingDialog'
import {
NodeLibrarySidebarTab,
@@ -127,6 +126,20 @@ class ConfirmDialog {
const loc = this[locator]
await expect(loc).toBeVisible()
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() => window['app']?.extensionManager?.workflow?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
@@ -152,7 +165,6 @@ export class ComfyPage {
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly queueList: QueueList
public readonly menu: ComfyMenu
public readonly actionbar: ComfyActionbar
public readonly templates: ComfyTemplates
@@ -185,7 +197,6 @@ export class ComfyPage {
this.visibleToasts = page.locator('.p-toast-message:visible')
this.searchBox = new ComfyNodeSearchBox(page)
this.queueList = new QueueList(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
@@ -245,6 +256,9 @@ export class ComfyPage {
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.nextFrame()
}
async setupUser(username: string) {
@@ -571,15 +585,9 @@ export class ComfyPage {
fileName?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
} = {}
) {
const {
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false
} = options
const { dropPosition = { x: 100, y: 100 }, fileName, url } = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
@@ -616,14 +624,6 @@ export class ComfyPage {
// Dropping a URL (e.g., dropping image across browser tabs in Firefox)
if (url) evaluateParams.url = url
// Set up response waiter for file uploads before triggering the drop
const uploadResponsePromise = waitForUpload
? this.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10000 }
)
: null
// Execute the drag and drop in the browser
await this.page.evaluate(async (params) => {
const dataTransfer = new DataTransfer()
@@ -690,17 +690,12 @@ export class ComfyPage {
}
}, evaluateParams)
// Wait for file upload to complete
if (uploadResponsePromise) {
await uploadResponsePromise
}
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
options: { dropPosition?: Position } = {}
) {
return this.dragAndDropExternalResource({ fileName, ...options })
}

View File

@@ -160,7 +160,7 @@ export class VueNodeHelpers {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').nth(1)
decrementButton: widget.locator('button').last()
}
}
}

View File

@@ -1,57 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
export class QueueList {
constructor(public readonly page: Page) {}
get toggleButton() {
return this.page.getByTestId('queue-toggle-button')
}
get inlineProgress() {
return this.page.getByTestId('queue-inline-progress')
}
get overlay() {
return this.page.getByTestId('queue-overlay')
}
get closeButton() {
return this.page.getByTestId('queue-overlay-close-button')
}
get jobItems() {
return this.page.getByTestId('queue-job-item')
}
get clearHistoryButton() {
return this.page.getByRole('button', { name: /Clear History/i })
}
async open() {
if (!(await this.overlay.isVisible())) {
await this.toggleButton.click()
await expect(this.overlay).toBeVisible()
}
}
async close() {
if (await this.overlay.isVisible()) {
await this.closeButton.click()
await expect(this.overlay).not.toBeVisible()
}
}
async getJobCount(state?: string) {
if (state) {
return await this.page
.locator(`[data-testid="queue-job-item"][data-job-state="${state}"]`)
.count()
}
return await this.jobItems.count()
}
getJobAction(actionKey: string) {
return this.page.getByTestId(`job-action-${actionKey}`)
}
}

View File

@@ -1,11 +1,7 @@
import { test as base } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
export type WsMessage = { type: 'status'; data: StatusWsMessage }
export const webSocketFixture = base.extend<{
ws: { trigger(data: WsMessage, url?: string): Promise<void> }
ws: { trigger(data: any, url?: string): Promise<void> }
}>({
ws: [
async ({ page }, use) => {

View File

@@ -1,5 +1,4 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import path from 'path'
import type {
@@ -9,20 +8,9 @@ import type {
export class ComfyTemplates {
readonly content: Locator
readonly allTemplateCards: Locator
constructor(readonly page: Page) {
this.content = page.getByTestId('template-workflows-content')
this.allTemplateCards = page.locator('[data-testid^="template-workflow-"]')
}
async waitForMinimumCardCount(count: number) {
return await expect(async () => {
const cardCount = await this.allTemplateCards.count()
expect(cardCount).toBeGreaterThanOrEqual(count)
}).toPass({
timeout: 1_000
})
}
async loadTemplate(id: string) {

View File

@@ -1,9 +1,9 @@
import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import type { WsMessage } from '../fixtures/ws'
import type { StatusWsMessage } from '../../src/schemas/apiSchema.ts'
import { comfyPageFixture } from '../fixtures/ComfyPage.ts'
import { webSocketFixture } from '../fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -61,7 +61,7 @@ test.describe('Actionbar', () => {
// Trigger a status websocket message
const triggerStatus = async (queueSize: number) => {
const message = {
await ws.trigger({
type: 'status',
data: {
status: {
@@ -70,9 +70,7 @@ test.describe('Actionbar', () => {
}
}
}
} satisfies WsMessage
await ws.trigger(message)
} as StatusWsMessage)
}
// Extract the width from the queue response

View File

@@ -77,7 +77,8 @@ test.describe('Background Image Upload', () => {
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
const inputValue = await urlInput.inputValue()
expect(inputValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')

View File

@@ -36,10 +36,9 @@ test.describe('Execute to selected output nodes', () => {
await output1.click('title')
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect(async () => {
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
}).toPass({ timeout: 2_000 })
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
})
})

View File

@@ -306,16 +306,14 @@ test.describe('Node Interaction', () => {
await comfyPage.canvas.click({
position: numberWidgetPos
})
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeVisible()
await comfyPage.delay(300)
await expect(comfyPage.canvas).toHaveScreenshot('prompt-dialog-opened.png')
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden()
await expect(comfyPage.canvas).toHaveScreenshot('prompt-dialog-closed.png')
})
test('Can close prompt dialog with canvas click (text widget)', async ({
@@ -329,16 +327,18 @@ test.describe('Node Interaction', () => {
await comfyPage.canvas.click({
position: textWidgetPos
})
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeVisible()
await comfyPage.delay(300)
await expect(comfyPage.canvas).toHaveScreenshot(
'prompt-dialog-opened-text.png'
)
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden()
await expect(comfyPage.canvas).toHaveScreenshot(
'prompt-dialog-closed-text.png'
)
})
test('Can double click node title to edit', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -12,7 +12,6 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -1,29 +0,0 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { expect } from '@playwright/test'
test.describe('Mobile Baseline Snapshots', () => {
test('@mobile empty canvas', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 256 })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})
test('@mobile default workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-default-workflow.png'
)
})
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -260,12 +260,6 @@ test.describe('Release context menu', () => {
test('Can trigger on link release', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
// Wait for context menu with correct title (slot name | slot type)
// The title shows the output slot name and type from the disconnected link
await expect(contextMenu.locator('.litemenu-title')).toContainText(
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(

View File

@@ -1,157 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture } from '../../fixtures/ComfyPage'
import { webSocketFixture } from '../../fixtures/ws'
import type { WsMessage } from '../../fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
type QueueState = {
running: QueueJob[]
pending: QueueJob[]
}
type QueueJob = [
string,
string,
Record<string, unknown>,
Record<string, unknown>,
string[]
]
type QueueController = {
state: QueueState
sync: (
ws: { trigger(data: WsMessage, url?: string): Promise<void> },
nextState: Partial<QueueState>
) => Promise<void>
}
test.describe('Queue UI', () => {
let queue: QueueController
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.route('**/api/prompt', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: 'mock-prompt-id',
number: 1,
node_errors: {}
})
})
})
// Mock history to avoid pulling real data
await comfyPage.page.route('**/api/history**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ History: [] })
})
})
queue = await createQueueController(comfyPage)
})
test('toggles overlay and updates count from status events', async ({
comfyPage,
ws
}) => {
await queue.sync(ws, { running: [], pending: [] })
await expect(comfyPage.queueList.toggleButton).toContainText('0')
await expect(comfyPage.queueList.toggleButton).toContainText(/queued/i)
await expect(comfyPage.queueList.overlay).toBeHidden()
await queue.sync(ws, {
pending: [queueJob('1', 'mock-pending', 'client-a')]
})
await expect(comfyPage.queueList.toggleButton).toContainText('1')
await expect(comfyPage.queueList.toggleButton).toContainText(/queued/i)
await comfyPage.queueList.open()
await expect(comfyPage.queueList.overlay).toBeVisible()
await expect(comfyPage.queueList.jobItems).toHaveCount(1)
await comfyPage.queueList.close()
await expect(comfyPage.queueList.overlay).toBeHidden()
})
test('displays running and pending jobs via status updates', async ({
comfyPage,
ws
}) => {
await queue.sync(ws, {
running: [queueJob('2', 'mock-running', 'client-b')],
pending: [queueJob('3', 'mock-pending', 'client-c')]
})
await comfyPage.queueList.open()
await expect(comfyPage.queueList.jobItems).toHaveCount(2)
const firstJob = comfyPage.queueList.jobItems.first()
await firstJob.hover()
const cancelAction = firstJob
.getByTestId('job-action-cancel-running')
.or(firstJob.getByTestId('job-action-cancel-hover'))
await expect(cancelAction).toBeVisible()
})
})
const queueJob = (
queueIndex: string,
promptId: string,
clientId: string
): QueueJob => [
queueIndex,
promptId,
{ client_id: clientId },
{ class_type: 'Note' },
['output']
]
const createQueueController = async (
comfyPage: ComfyPage
): Promise<QueueController> => {
const state: QueueState = { running: [], pending: [] }
// Single queue handler reads the latest in-memory state
await comfyPage.page.route('**/api/queue', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
queue_running: state.running,
queue_pending: state.pending
})
})
})
const sync = async (
ws: { trigger(data: WsMessage, url?: string): Promise<void> },
nextState: Partial<QueueState>
) => {
if (nextState.running) state.running = nextState.running
if (nextState.pending) state.pending = nextState.pending
const total = state.running.length + state.pending.length
const queueResponse = comfyPage.page.waitForResponse('**/api/queue')
await ws.trigger({
type: 'status',
data: {
status: { exec_info: { queue_remaining: total } }
}
})
await queueResponse
}
return { state, sync }
}

View File

@@ -212,12 +212,8 @@ test.describe('Remote COMBO Widget', () => {
// Click on the canvas to trigger widget refresh
await comfyPage.page.mouse.click(400, 300)
await expect(async () => {
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
expect(refreshedOptions).not.toEqual(initialOptions)
}).toPass({
timeout: 2_000
})
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
expect(refreshedOptions).not.toEqual(initialOptions)
})
test('does not refresh when TTL is not set', async ({ comfyPage }) => {
@@ -325,12 +321,8 @@ test.describe('Remote COMBO Widget', () => {
await clickRefreshButton(comfyPage, nodeName)
// Verify the selected value of the widget is the first option in the refreshed list
await expect(async () => {
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
expect(refreshedValue).toEqual('new first option')
}).toPass({
timeout: 2_000
})
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
expect(refreshedValue).toEqual('new first option')
})
})

View File

@@ -290,20 +290,16 @@ test.describe('Node library sidebar', () => {
await comfyPage.page.keyboard.insertText('bar')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await expect(async () => {
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['bar/'])
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
).toEqual({
'bar/': {
icon: 'pi-folder',
color: '#007bff'
}
})
}).toPass({
timeout: 2_000
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['bar/'])
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
).toEqual({
'bar/': {
icon: 'pi-folder',
color: '#007bff'
}
})
})

View File

@@ -329,15 +329,6 @@ test.describe('Subgraph Operations', () => {
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can create widget from link with compressed target_slot', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/subgraph-compressed-target-slot')
const step = await comfyPage.page.evaluate(() => {
return window['app'].graph.nodes[0].widgets[0].options.step
})
expect(step).toBe(10)
})
})
test.describe('Subgraph Creation and Deletion', () => {

View File

@@ -188,19 +188,22 @@ test.describe('Templates', () => {
.locator('header')
.filter({ hasText: 'Templates' })
await comfyPage.templates.waitForMinimumCardCount(1)
const cardCount = await comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.count()
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(nav).toBeVisible() // Nav should be visible at desktop size
const mobileSize = { width: 640, height: 800 }
await comfyPage.page.setViewportSize(mobileSize)
await comfyPage.templates.waitForMinimumCardCount(1)
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
const tabletSize = { width: 1024, height: 800 }
await comfyPage.page.setViewportSize(tabletSize)
await comfyPage.templates.waitForMinimumCardCount(1)
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,144 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import { fitToViewInstant } from '../../../../helpers/fitToView'
test.describe('Vue Node Bring to Front', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
/**
* Helper to get the z-index of a node by its title
*/
async function getNodeZIndex(
comfyPage: ComfyPage,
title: string
): Promise<number> {
const node = comfyPage.vueNodes.getNodeByTitle(title)
const style = await node.getAttribute('style')
if (!style) {
throw new Error(
`Node "${title}" has no style attribute (observed: ${style})`
)
}
const match = style.match(/z-index:\s*(\d+)/)
if (!match) {
throw new Error(
`Node "${title}" has no z-index in style (observed: "${style}")`
)
}
return parseInt(match[1], 10)
}
/**
* Helper to get the bounding box center of a node
*/
async function getNodeCenter(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const node = comfyPage.vueNodes.getNodeByTitle(title)
const box = await node.boundingBox()
if (!box) throw new Error(`Node "${title}" not found`)
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
test('should bring overlapped node to front when clicking on it', async ({
comfyPage
}) => {
// Get initial positions
const clipCenter = await getNodeCenter(comfyPage, 'CLIP Text Encode')
const ksamplerHeader = await comfyPage.page
.getByText('KSampler')
.boundingBox()
if (!ksamplerHeader) throw new Error('KSampler header not found')
// Drag KSampler on top of CLIP Text Encode
await comfyPage.dragAndDrop(
{ x: ksamplerHeader.x + 50, y: ksamplerHeader.y + 10 },
clipCenter
)
await comfyPage.nextFrame()
// Screenshot showing KSampler on top of CLIP
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-overlapped-before.png'
)
// KSampler should be on top (higher z-index) after being dragged
const ksamplerZIndexBefore = await getNodeZIndex(comfyPage, 'KSampler')
const clipZIndexBefore = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
expect(ksamplerZIndexBefore).toBeGreaterThan(clipZIndexBefore)
// Click on CLIP Text Encode (underneath) - need to click on a visible part
// Since KSampler is on top, we click on the edge of CLIP that should still be visible
const clipNode = comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode')
const clipBox = await clipNode.boundingBox()
if (!clipBox) throw new Error('CLIP node not found')
// Click on a visible edge of CLIP
await comfyPage.page.mouse.click(clipBox.x + 30, clipBox.y + 10)
await comfyPage.nextFrame()
// CLIP should now be on top - compare post-action z-indices
const clipZIndexAfter = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
const ksamplerZIndexAfter = await getNodeZIndex(comfyPage, 'KSampler')
expect(clipZIndexAfter).toBeGreaterThan(ksamplerZIndexAfter)
// Screenshot showing CLIP now on top
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-overlapped-after.png'
)
})
test('should bring overlapped node to front when clicking on its widget', async ({
comfyPage
}) => {
// Get CLIP Text Encode position (it has a text widget)
const clipCenter = await getNodeCenter(comfyPage, 'CLIP Text Encode')
// Get VAE Decode position and drag it on top of CLIP
const vaeHeader = await comfyPage.page.getByText('VAE Decode').boundingBox()
if (!vaeHeader) throw new Error('VAE Decode header not found')
await comfyPage.dragAndDrop(
{ x: vaeHeader.x + 50, y: vaeHeader.y + 10 },
{ x: clipCenter.x - 50, y: clipCenter.y }
)
await comfyPage.nextFrame()
// VAE should be on top after drag
const vaeZIndexBefore = await getNodeZIndex(comfyPage, 'VAE Decode')
const clipZIndexBefore = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
expect(vaeZIndexBefore).toBeGreaterThan(clipZIndexBefore)
// Screenshot showing VAE on top
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-widget-overlapped-before.png'
)
// Click on the text widget of CLIP Text Encode
const clipNode = comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode')
const clipBox = await clipNode.boundingBox()
if (!clipBox) throw new Error('CLIP node not found')
await comfyPage.page.mouse.click(clipBox.x + 170, clipBox.y + 80)
await comfyPage.nextFrame()
// CLIP should now be on top - compare post-action z-indices
const clipZIndexAfter = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
const vaeZIndexAfter = await getNodeZIndex(comfyPage, 'VAE Decode')
expect(clipZIndexAfter).toBeGreaterThan(vaeZIndexAfter)
// Screenshot showing CLIP now on top after widget click
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-widget-overlapped-after.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -15,9 +15,7 @@ test.describe('Vue Integer Widget', () => {
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
await comfyPage.vueNodes.waitForNodes()
const seedWidget = comfyPage.vueNodes
.getWidgetByName('KSampler', 'seed')
.first()
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
const initialValue = Number(await controls.input.inputValue())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -252,8 +252,7 @@ test.describe('Animated image widget', () => {
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y },
waitForUpload: true
dropPosition: { x, y }
})
// Expect the filename combo value to be updated

View File

@@ -62,20 +62,16 @@ export default defineConfig([
{
ignores: [
'.i18nrc.cjs',
'.nx/*',
'components.d.ts',
'lint-staged.config.js',
'vitest.setup.ts',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'components.d.ts',
'coverage/*',
'dist/*',
'packages/registry-types/src/comfyRegistryTypes.ts',
'playwright-report/*',
'src/extensions/core/*',
'src/scripts/*',
'src/types/generatedManagerTypes.ts',
'src/types/vue-shim.d.ts',
'test-results/*',
'vitest.setup.ts'
'src/types/vue-shim.d.ts'
]
},
{
@@ -107,17 +103,24 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Bad types in the plugin
pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
storybook.configs['flat/recommended'],
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.recommended,
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.typescript,
{
plugins: {
'unused-imports': unusedImports,
// @ts-expect-error Type incompatibility in i18n plugin
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility in i18n plugin
'@intlify/vue-i18n': pluginI18n
},
rules: {
@@ -132,24 +135,59 @@ export default defineConfig([
allowInterfaces: 'always'
}
],
'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'],
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
'no-console': ['error', { allow: ['warn', 'error'] }],
'vue/no-v-html': 'off',
// Prohibit dark-theme: and dark: prefixes
'vue/no-restricted-class': ['error', '/^dark(-theme)?:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/match-component-import-name': 'error',
/* Toggle on to do additional until we can clean up existing violations.
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-useless-mustaches': 'error',
'vue/no-useless-v-bind': 'error',
'vue/no-unused-emit-declarations': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/one-component-per-file': 'error',
'vue/no-useless-v-bind': 'error',
// */
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'primevue/calendar',
message:
'Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from "primevue/datepicker"'
},
{
name: 'primevue/dropdown',
message:
'Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from "primevue/select"'
},
{
name: 'primevue/inputswitch',
message:
'InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from "primevue/toggleswitch"'
},
{
name: 'primevue/overlaypanel',
message:
'OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from "primevue/popover"'
},
{
name: 'primevue/sidebar',
message:
'Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from "primevue/drawer"'
}
]
}
],
// i18n rules
'@intlify/vue-i18n/no-raw-text': [
'error',
@@ -237,6 +275,12 @@ export default defineConfig([
]
}
},
{
files: ['**/*.{test,spec,stories}.ts', '**/*.stories.vue'],
rules: {
'no-console': 'off'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {
@@ -253,14 +297,5 @@ export default defineConfig([
// Turn off ESLint rules that are already handled by oxlint
...oxlint.buildFromOxlintConfigFile(
path.resolve(import.meta.dirname, '.oxlintrc.json')
),
{
rules: {
'import-x/default': 'off',
'import-x/export': 'off',
'import-x/namespace': 'off',
'import-x/no-duplicates': 'off',
'import-x/consistent-type-specifier-style': 'off'
}
}
)
])

17
lint-staged.config.js Normal file
View File

@@ -0,0 +1,17 @@
export default {
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
}
function formatAndEslint(fileNames) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => f.replace(process.cwd() + '/', ''))
return [
`pnpm exec eslint --cache --fix ${relativePaths.join(' ')}`,
`pnpm exec prettier --cache --write ${relativePaths.join(' ')}`
]
}

View File

@@ -1,21 +0,0 @@
import path from 'node:path'
export default {
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
}
function formatAndEslint(fileNames: string[]) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec prettier --cache --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.35.6",
"version": "1.35.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -98,6 +98,7 @@
--color-bypass: #6a246a;
--color-error: #962a2a;
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--color-interface-panel-job-progress-primary: var(--color-azure-300);
--color-interface-panel-job-progress-secondary: var(--color-alpha-azure-600-30);
@@ -260,8 +261,6 @@
--component-node-widget-background-selected: var(--secondary-background-selected);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-ash-500);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-400);
/* Default UI element color palette variables */
--palette-contrast-mix-color: #fff;
@@ -385,8 +384,6 @@
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
--component-node-widget-background-highlighted: var(--color-graphite-400);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-600);
--modal-card-background: var(--secondary-background);
--modal-card-background-hovered: var(--secondary-background-hover);
@@ -437,11 +434,7 @@
--color-interface-button-hover-surface: var(
--interface-button-hover-surface
);
--color-comfy-input: var(--comfy-input-bg);
--color-comfy-input-foreground: var(--input-text);
--color-comfy-menu-bg: var(--comfy-menu-bg);
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);
@@ -497,8 +490,6 @@
--color-component-node-widget-background-selected: var(--component-node-widget-background-selected);
--color-component-node-widget-background-disabled: var(--component-node-widget-background-disabled);
--color-component-node-widget-background-highlighted: var(--component-node-widget-background-highlighted);
--color-component-node-widget-promoted: var(--component-node-widget-promoted);
--color-component-node-widget-advanced: var(--component-node-widget-advanced);
/* Semantic tokens */
--color-base-foreground: var(--base-foreground);
@@ -1328,15 +1319,6 @@ audio.comfy-audio.empty-audio-widget {
font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
.transform-pane--interacting .lg-node * {
transition: none !important;
}
.transform-pane--interacting .lg-node {
will-change: transform;
}
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {

View File

@@ -1945,40 +1945,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/omni-image": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** KlingAI Create Omni-Image Task */
post: operations["klingCreateOmniImage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/omni-image/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** KlingAI Query Single Omni-Image Task */
get: operations["klingOmniImageQuerySingleTask"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/kolors-virtual-try-on": {
parameters: {
query?: never;
@@ -3910,7 +3876,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO";
FeaturesResponse: {
/**
* @description The conversion rate for partner nodes
@@ -5130,71 +5096,6 @@ export interface components {
};
};
};
KlingOmniImageRequest: {
/**
* @description Model Name
* @default kling-image-o1
* @enum {string}
*/
model_name: "kling-image-o1";
/** @description Text prompt words, which can include positive and negative descriptions. Must not exceed 2,500 characters. The Omni model can achieve various capabilities through Prompt with elements and images. Specify an image in the format of <<<>>>, such as <<<image_1>>>. */
prompt: string;
/** @description Reference Image List. Supports inputting image Base64 encoding or image URL (ensure accessibility). Supported formats include .jpg/.jpeg/.png. File size cannot exceed 10MB. Width and height dimensions shall not be less than 300px, aspect ratio between 1:2.5 ~ 2.5:1. Maximum 10 images. */
image_list?: {
/** @description Image Base64 encoding or image URL (ensure accessibility) */
image?: string;
}[];
/**
* @description Image generation resolution. 1k is 1K standard, 2k is 2K high-res, 4k is 4K high-res.
* @default 1k
* @enum {string}
*/
resolution: "1k" | "2k" | "4k";
/**
* @description Number of generated images. Value range [1,9].
* @default 1
*/
n: number;
/**
* @description Aspect ratio of the generated images (width:height). auto is to intelligently generate images based on incoming content.
* @default auto
* @enum {string}
*/
aspect_ratio: "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "21:9" | "auto";
/**
* Format: uri
* @description The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes.
*/
callback_url?: string;
/** @description Customized Task ID. Must be unique within a single user account. */
external_task_id?: string;
};
KlingOmniImageResponse: {
/** @description Error code */
code?: number;
/** @description Error message */
message?: string;
/** @description Request ID */
request_id?: string;
data?: {
/** @description Task ID */
task_id?: string;
task_status?: components["schemas"]["KlingTaskStatus"];
/** @description Task status information, displaying the failure reason when the task fails (such as triggering the content risk control of the platform, etc.) */
task_status_msg?: string;
task_info?: {
/** @description Customer-defined task ID */
external_task_id?: string;
};
/** @description Task creation time, Unix timestamp in milliseconds */
created_at?: number;
/** @description Task update time, Unix timestamp in milliseconds */
updated_at?: number;
task_result?: {
images?: components["schemas"]["KlingImageResult"][];
};
};
};
KlingLipSyncInputObject: {
/** @description The ID of the video generated by Kling AI. Only supports 5-second and 10-second videos generated within the last 30 days. */
video_id?: string;
@@ -10164,7 +10065,7 @@ export interface components {
};
BytePlusImageGenerationRequest: {
/** @enum {string} */
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828" | "seedream-4-5-251128";
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828";
/** @description Text description for image generation or transformation */
prompt: string;
/**
@@ -10269,10 +10170,10 @@ export interface components {
};
BytePlusVideoGenerationRequest: {
/**
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-pro-fast-251015, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
* @enum {string}
*/
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428" | "seedance-1-0-pro-fast-251015";
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428";
/** @description The input content for the model to generate a video */
content: components["schemas"]["BytePlusVideoGenerationContent"][];
/**
@@ -14046,15 +13947,6 @@ export interface operations {
"application/json": components["schemas"]["Node"];
};
};
/** @description Redirect to node with normalized name match */
302: {
headers: {
/** @description URL of the node with the correct ID */
Location?: string;
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
@@ -18453,198 +18345,6 @@ export interface operations {
};
};
};
klingCreateOmniImage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Create task for generating omni-image */
requestBody: {
content: {
"application/json": components["schemas"]["KlingOmniImageRequest"];
};
};
responses: {
/** @description Successful response (Request successful) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingOmniImageResponse"];
};
};
/** @description Invalid request parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Unauthorized access to requested resource */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Account exception or Rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Service temporarily unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Server timeout */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
};
};
klingOmniImageQuerySingleTask: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID) */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful response (Request successful) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingOmniImageResponse"];
};
};
/** @description Invalid request parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Unauthorized access to requested resource */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Account exception or Rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Service temporarily unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Server timeout */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
};
};
klingVirtualTryOnQueryTaskList: {
parameters: {
query?: {

1648
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.39.1
'@eslint/js': ^9.35.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind': ^1.1.3
@@ -17,7 +17,7 @@ catalog:
'@nx/vite': 21.4.1
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.52.0
'@prettier/plugin-oxc': ^0.1.3
'@prettier/plugin-oxc': ^0.0.4
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
@@ -48,15 +48,15 @@ catalog:
axios: ^1.8.2
cross-env: ^10.1.0
dotenv: ^16.4.5
eslint: ^9.39.1
eslint: ^9.34.0
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-prettier: ^5.5.4
eslint-plugin-storybook: ^9.1.16
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
eslint-plugin-storybook: ^9.1.6
eslint-plugin-unused-imports: ^4.2.0
eslint-plugin-vue: ^10.4.0
firebase: ^11.6.0
globals: ^15.9.0
happy-dom: ^15.11.0
@@ -64,29 +64,29 @@ catalog:
jiti: 2.4.2
jsdom: ^26.1.0
knip: ^5.62.0
lint-staged: ^15.5.2
lint-staged: ^15.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 21.4.1
oxlint: ^1.32.0
oxlint-tsgolint: ^0.8.4
oxlint: ^1.25.0
oxlint-tsgolint: ^0.4.0
picocolors: ^1.1.1
pinia: ^2.1.7
postcss-html: ^1.8.0
prettier: ^3.7.4
prettier: ^3.6.2
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4
storybook: ^9.1.16
stylelint: ^16.26.1
storybook: ^9.1.6
stylelint: ^16.24.0
tailwindcss: ^4.1.12
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.49.0
typescript: ^5.9.2
typescript-eslint: ^8.44.0
unplugin-icons: ^0.22.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^0.28.0
@@ -100,7 +100,7 @@ catalog:
vue-eslint-parser: ^10.2.0
vue-i18n: ^9.14.3
vue-router: ^4.4.3
vue-tsc: ^3.1.8
vue-tsc: ^3.0.7
vuefire: ^3.2.1
yjs: ^13.6.27
zod: ^3.23.8

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "$ROOT_DIR"
if ! command -v shellcheck >/dev/null 2>&1; then
echo "Error: shellcheck is required but not installed" >&2
exit 127
fi
mapfile -t shell_files < <(git ls-files -- '*.sh')
if [[ ${#shell_files[@]} -eq 0 ]]; then
echo 'No shell scripts found.'
exit 0
fi
shellcheck --format=gcc "${shell_files[@]}"

View File

@@ -74,7 +74,7 @@ deploy_report() {
# Project name with dots converted to dashes for Cloudflare
sanitized_browser="${browser//./-}"
sanitized_browser=$(echo "$browser" | sed 's/\./-/g')
project="comfyui-playwright-${sanitized_browser}"
echo "Deploying $browser to project $project on branch $branch..." >&2
@@ -208,7 +208,7 @@ else
# Wait for all deployments to complete
for pid in $pids; do
wait "$pid"
wait $pid
done
# Collect URLs and counts in order
@@ -254,9 +254,9 @@ else
total_tests=0
# Parse counts and calculate totals
IFS='|' read -r -a counts_array <<< "$all_counts"
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] && continue
IFS='|'
set -- $all_counts
for counts_json; do
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
# Parse JSON counts using simple grep/sed if jq is not available
if command -v jq > /dev/null 2>&1; then
@@ -324,12 +324,13 @@ $status_icon **$status_text**
# Add browser results with individual counts
i=0
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
IFS=' ' read -r -a url_array <<< "$urls"
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] && { i=$((i + 1)); continue; }
browser="${browser_array[$i]:-}"
url="${url_array[$i]:-}"
IFS='|'
set -- $all_counts
for counts_json; do
# Get browser name
browser=$(echo "$BROWSERS" | cut -d' ' -f$((i + 1)))
# Get URL at position i
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
if [ "$url" != "failed" ] && [ -n "$url" ]; then
# Parse individual browser counts
@@ -373,4 +374,4 @@ $status_icon **$status_text**
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi
fi

View File

@@ -264,7 +264,7 @@ if (!releaseInfo) {
}
// Output as JSON for GitHub Actions
// eslint-disable-next-line no-console
console.log(JSON.stringify(releaseInfo, null, 2))
export { resolveRelease }

View File

@@ -1,56 +1,67 @@
<template>
<div v-if="!workspaceStore.focusMode" class="ml-1 flex flex-col gap-1 pt-1">
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex h-12 items-center overflow-hidden rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
v-model:docked="isActionbarDocked"
v-model:queue-overlay-expanded="isQueueOverlayExpanded"
:top-menu-container="actionbarContainerRef"
/>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
</div>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex gap-x-0.5 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div>
<QueueInlineProgressSummary
v-if="!isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
<div class="mx-1 flex flex-col items-end gap-1">
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -58,39 +69,33 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import IconButton from '@/components/button/IconButton.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingsStore = useSettingStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const isQueueOverlayExpanded = ref(false)
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
const queueStore = useQueueStore()
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
@@ -103,6 +108,10 @@ onMounted(() => {
legacyCommandsContainerRef.value.appendChild(app.menu.element)
}
})
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
</script>
<style scoped>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex h-full items-center">
<div
v-if="isDragging && !docked"
v-if="isDragging && !isDocked"
:class="actionbarClass"
@mouseenter="onMouseEnterDropZone"
@mouseleave="onMouseLeaveDropZone"
@@ -9,101 +9,45 @@
{{ t('actionbar.dockToTop') }}
</div>
<div
ref="actionbarWrapperRef"
:class="panelClass"
<Panel
class="pointer-events-auto"
:style="style"
class="flex flex-col items-stretch"
:class="panelClass"
:pt="{
header: { class: 'hidden' },
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<Panel
:class="
cn(
panelRootClass,
isDragging ? 'pointer-events-none' : 'pointer-events-auto'
)
"
:pt="{
header: { class: 'hidden' },
content: { class: 'p-0' }
}"
>
<div
ref="panelRef"
:class="cn('flex flex-col', docked ? 'p-0' : 'p-1')"
>
<div class="flex items-center select-none">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<IconTextButton
v-tooltip.bottom="queueHistoryTooltipConfig"
size="sm"
type="secondary"
icon-position="right"
data-testid="queue-toggle-button"
class="ml-2 h-8 border-0 px-3 text-sm font-medium text-base-foreground cursor-pointer"
:aria-pressed="props.queueOverlayExpanded"
:aria-label="queueToggleLabel"
:label="queueToggleLabel"
@click="toggleQueueOverlay"
>
<!-- Custom implementation for static 1-2 digit shifts -->
<span class="flex items-center gap-1">
<span
class="inline-flex min-w-[2ch] justify-center tabular-nums text-center"
>
{{ queuedCount }}
</span>
<span>{{ queuedSuffix }}</span>
</span>
<template #icon>
<i class="icon-[lucide--chevron-down] size-4" />
</template>
</IconTextButton>
</div>
</div>
</Panel>
<div v-if="isFloating" class="flex justify-end pt-1 pr-1">
<QueueInlineProgressSummary
class="pr-1"
:hidden="props.queueOverlayExpanded"
<div ref="panelRef" class="flex items-center select-none">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
</div>
</div>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="props.queueOverlayExpanded"
data-testid="queue-inline-progress"
/>
</Teleport>
<ComfyRunButton />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
</div>
</Panel>
</div>
</template>
<script lang="ts" setup>
import {
unrefElement,
useDraggable,
useEventListener,
useLocalStorage,
@@ -112,60 +56,35 @@ import {
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const props = defineProps<{
queueOverlayExpanded: boolean
topMenuContainer?: HTMLElement | null
}>()
const emit = defineEmits<{
(e: 'update:queueOverlayExpanded', value: boolean): void
}>()
const { t } = useI18n()
const settingsStore = useSettingStore()
const executionStore = useExecutionStore()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const tabContainer = document.querySelector('.workflow-tabs-container')
const actionbarWrapperRef = ref<HTMLElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const docked = defineModel<boolean>('docked', { default: false })
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const wrapperElement = computed(() => {
const element = unrefElement(actionbarWrapperRef)
return element instanceof HTMLElement ? element : null
})
const panelElement = computed(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const { x, y, style, isDragging } = useDraggable(wrapperElement, {
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body,
@@ -178,33 +97,6 @@ const { x, y, style, isDragging } = useDraggable(wrapperElement, {
}
})
// Queue and Execution logic
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueToggleLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.toggleLabel', {
count: queuedCount.value
})
)
const queuedSuffix = computed(() =>
t('sideToolbar.queueProgressOverlay.queuedSuffix')
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const toggleQueueOverlay = () => {
emit('update:queueOverlayExpanded', !props.queueOverlayExpanded)
}
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
// Update storedPosition when x or y changes
watchDebounced(
[x, y],
@@ -216,12 +108,11 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
const containerEl = wrapperElement.value
if (containerEl) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = containerEl.offsetWidth
const menuHeight = containerEl.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -248,14 +139,7 @@ const setInitialPosition = () => {
}
}
}
//The ComfyRunButton is a dynamic import. Which means it will not be loaded onMount in this component.
//So we must use suspense resolve to ensure that is has loaded and updated the DOM before calling setInitialPosition()
async function comfyRunButtonResolved() {
await nextTick()
setInitialPosition()
}
onMounted(setInitialPosition)
watch(visible, async (newVisible) => {
if (newVisible) {
await nextTick(setInitialPosition)
@@ -297,12 +181,11 @@ watch(
)
const adjustMenuPosition = () => {
const containerEl = wrapperElement.value
if (containerEl) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = containerEl.offsetWidth
const menuHeight = containerEl.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -373,27 +256,31 @@ const onMouseLeaveDropZone = () => {
watch(isDragging, (dragging) => {
if (dragging) {
// Starting to drag - undock if docked
if (docked.value) {
docked.value = false
if (isDocked.value) {
isDocked.value = false
}
} else {
// Stopped dragging - dock if mouse is over drop zone
if (isMouseOverDropZone.value) {
docked.value = true
isDocked.value = true
}
// Reset drop zone state
isMouseOverDropZone.value = false
}
})
const isFloating = computed(() => visible.value && !docked.value)
const inlineProgressTarget = computed(() => {
if (!visible.value) return null
if (isFloating.value) return panelElement.value
return props.topMenuContainer ?? null
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const actionbarClass = computed(() =>
cn(
'w-[300px] border-dashed border-blue-500 opacity-80',
'w-[200px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:w-50 before:-ml-50 before:h-full',
'pointer-events-auto',
@@ -403,21 +290,11 @@ const actionbarClass = computed(() =>
)
const panelClass = computed(() =>
cn(
'actionbar z-1300 overflow-hidden rounded-[var(--p-panel-border-radius)]',
docked.value ? 'p-0 static mr-2 border-none bg-transparent' : 'fixed',
isDragging.value ? 'select-none pointer-events-none' : 'pointer-events-auto'
'actionbar pointer-events-auto z-1300',
isDragging.value && 'select-none pointer-events-none',
isDocked.value
? 'p-0 static mr-2 border-none bg-transparent'
: 'fixed shadow-interface'
)
)
const panelRootClass = computed(() =>
cn(
'relative overflow-hidden rounded-[var(--p-panel-border-radius)]',
docked.value
? 'border-none shadow-none bg-transparent'
: 'border border-interface-stroke shadow-interface'
)
)
defineExpose({
isFloating
})
</script>

View File

@@ -58,7 +58,7 @@ const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()

View File

@@ -55,6 +55,7 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()
const { subcategories } = defineProps<{
commands: ComfyCommandImpl[]
subcategories: Record<string, ComfyCommandImpl[]>
}>()

View File

@@ -21,7 +21,7 @@
class="icon-[lucide--triangle-alert] text-warning-background"
/>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
@@ -83,7 +83,7 @@ const props = withDefaults(defineProps<Props>(), {
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()

View File

@@ -7,15 +7,14 @@
@click="onClick"
>
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
<slot v-if="hasDefaultSlot"></slot>
<span v-else>{{ label }}</span>
<span>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, useSlots } from 'vue'
import { computed } from 'vue'
import type { BaseButtonProps } from '@/types/buttonTypes'
import {
@@ -47,9 +46,6 @@ const {
onClick
} = defineProps<IconTextButtonProps>()
const slots = useSlots()
const hasDefaultSlot = computed(() => Boolean(slots.default?.().length))
const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const sizeClasses = getButtonSizeClasses(size)

View File

@@ -41,7 +41,7 @@ const {
inputAttrs?: Record<string, string>
}>()
const emit = defineEmits(['edit', 'cancel'])
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const isCanceling = ref(false)

View File

@@ -11,6 +11,7 @@ import InputText from 'primevue/inputtext'
const modelValue = defineModel<string>('modelValue')
defineProps<{
defaultValue?: string
label?: string
}>()

Some files were not shown because too many files have changed in this diff Show More