Compare commits
101 Commits
cloud/1.34
...
graph-stat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71401b1059 | ||
|
|
076acf1b31 | ||
|
|
7613e70f63 | ||
|
|
56b67085d0 | ||
|
|
4a91330e30 | ||
|
|
a1a507ed09 | ||
|
|
a7c694f248 | ||
|
|
0646bb368a | ||
|
|
a8ef7a602f | ||
|
|
9c157296be | ||
|
|
3e97225ff6 | ||
|
|
1cdea3063d | ||
|
|
b5ab45673a | ||
|
|
7d326cbc14 | ||
|
|
6b1bd4be16 | ||
|
|
57eee5c218 | ||
|
|
caca6c4163 | ||
|
|
0385a7de9b | ||
|
|
e41c6934db | ||
|
|
2a68dbff5a | ||
|
|
2957d9897f | ||
|
|
f2a0e5102e | ||
|
|
88bdc605a7 | ||
|
|
c1808db7c4 | ||
|
|
514c437a38 | ||
|
|
18b133d22f | ||
|
|
3e8a83547d | ||
|
|
91adcaf276 | ||
|
|
03e9dd4789 | ||
|
|
ac8c3847d2 | ||
|
|
c88fc99a86 | ||
|
|
3dd805a30e | ||
|
|
7c830a2f0b | ||
|
|
e7756eb6dd | ||
|
|
c83f3ff1a7 | ||
|
|
bf8d9de1c1 | ||
|
|
b9f75b6cc8 | ||
|
|
3c8b7b015c | ||
|
|
97fa128999 | ||
|
|
1e22c9067d | ||
|
|
273e39bbd1 | ||
|
|
ca5f24fcd9 | ||
|
|
8ba8b21fa0 | ||
|
|
1522622427 | ||
|
|
d83c3122ab | ||
|
|
c99865ce7f | ||
|
|
29af56c154 | ||
|
|
a65e63a322 | ||
|
|
8e28dda85c | ||
|
|
a7de97470b | ||
|
|
5fad29ed37 | ||
|
|
ea59fb5fc3 | ||
|
|
5cba1e3f88 | ||
|
|
c8f88d5ba7 | ||
|
|
f5f0e20332 | ||
|
|
b6efc52bf8 | ||
|
|
1b2df19f1b | ||
|
|
0eba638a0f | ||
|
|
d60ecbb3c3 | ||
|
|
969466c0a0 | ||
|
|
87244a6954 | ||
|
|
0eb2b9171a | ||
|
|
24ee353465 | ||
|
|
73e09a7fff | ||
|
|
987dcb189d | ||
|
|
fceb0017ce | ||
|
|
6156e22bac | ||
|
|
62f9e91724 | ||
|
|
e83cf0f5f6 | ||
|
|
c24e2ab5ba | ||
|
|
72b5444d5a | ||
|
|
b52b2bbc30 | ||
|
|
424bd21559 | ||
|
|
04286c033a | ||
|
|
59429cbe56 | ||
|
|
eb04178e33 | ||
|
|
b88d96d6cc | ||
|
|
dedc77786f | ||
|
|
356ebe538f | ||
|
|
2c06c58621 | ||
|
|
c13343b8fb | ||
|
|
ce4837a57c | ||
|
|
2903560416 | ||
|
|
6850c45d63 | ||
|
|
2b9f7ecedf | ||
|
|
73b08acfe0 | ||
|
|
c524ce3a2f | ||
|
|
aef40834f3 | ||
|
|
8209f5a108 | ||
|
|
77e453db36 | ||
|
|
d3e9e15f07 | ||
|
|
4d3f918e8e | ||
|
|
82fc96155f | ||
|
|
79a6421329 | ||
|
|
42314d227f | ||
|
|
8f300c7163 | ||
|
|
5b91434ac4 | ||
|
|
51a336fd36 | ||
|
|
ad630cfbfe | ||
|
|
41eb45754b | ||
|
|
f0a99a0a75 |
@@ -42,3 +42,7 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
# SENTRY_PROJECT=cloud-frontend-staging
|
||||
|
||||
# Stripe pricing table configuration (used by feature-flagged subscription tiers UI)
|
||||
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123
|
||||
# VITE_STRIPE_PRICING_TABLE_ID=prctbl_123
|
||||
|
||||
26
.github/workflows/ci-shell-validation.yaml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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
|
||||
4
.github/workflows/pr-backport.yaml
vendored
@@ -16,6 +16,10 @@ 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: >
|
||||
|
||||
@@ -124,12 +124,16 @@ 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
|
||||
changed_files=$(git diff --name-only browser_tests/ 2>/dev/null | grep -E '\-snapshots/' || 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 )
|
||||
|
||||
if [ -z "$changed_files" ]; then
|
||||
echo "No snapshot changes in this shard"
|
||||
@@ -151,6 +155,11 @@ 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
|
||||
@@ -261,11 +270,19 @@ jobs:
|
||||
echo "CHANGES SUMMARY"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
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"
|
||||
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
|
||||
|
||||
- name: Commit updated expectations
|
||||
id: commit
|
||||
@@ -273,7 +290,7 @@ jobs:
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
|
||||
if git diff --quiet browser_tests/; then
|
||||
if [ -z "$(git status --porcelain=v1 --untracked-files=all -- browser_tests/)" ]; then
|
||||
echo "No changes to commit"
|
||||
echo "has-changes=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
|
||||
@@ -2,25 +2,98 @@
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
".i18nrc.cjs",
|
||||
"components.d.ts",
|
||||
"lint-staged.config.js",
|
||||
"vitest.setup.ts",
|
||||
".nx/*",
|
||||
"**/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"
|
||||
"src/types/vue-shim.d.ts",
|
||||
"test-results/*",
|
||||
"vitest.setup.ts"
|
||||
],
|
||||
"plugins": [
|
||||
"eslint",
|
||||
"import",
|
||||
"oxc",
|
||||
"typescript",
|
||||
"unicorn",
|
||||
"vitest",
|
||||
"vue"
|
||||
],
|
||||
"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",
|
||||
@@ -39,6 +112,18 @@
|
||||
"typescript/restrict-template-expressions": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
}
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.{stories,test,spec}.ts",
|
||||
"**/*.stories.vue"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "allow"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,9 @@
|
||||
"declaration-property-value-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"typesSyntax": {
|
||||
"radial-gradient()": "| <any-value>"
|
||||
},
|
||||
"ignoreProperties": {
|
||||
"speak": ["none"],
|
||||
"app-region": ["drag", "no-drag"],
|
||||
@@ -56,10 +59,7 @@
|
||||
"function-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreFunctions": [
|
||||
"theme",
|
||||
"v-bind"
|
||||
]
|
||||
"ignoreFunctions": ["theme", "v-bind"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
57
AGENTS.md
@@ -73,24 +73,6 @@ The project uses **Nx** for build orchestration and task management
|
||||
- composables `useXyz.ts`
|
||||
- Pinia stores `*Store.ts`
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
- Test files:
|
||||
- Unit/Component: `**/*.test.ts`
|
||||
- E2E: `browser_tests/**/*.spec.ts`
|
||||
- Litegraph Specific: `src/lib/litegraph/test/`
|
||||
- Coverage: text/json/html reporters enabled
|
||||
- aim to cover critical logic and new features
|
||||
- Playwright:
|
||||
- optional tags like `@mobile`, `@2x` are respected by config
|
||||
- Tests to avoid
|
||||
- Change detector tests
|
||||
- e.g. a test that just asserts that the defaults are certain values
|
||||
- Tests that are dependent on non-behavioral features like utility classes or styles
|
||||
- Redundant tests
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
@@ -161,7 +143,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
|
||||
15. Do not add or retain redundant comments, clean as you go
|
||||
16. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
|
||||
17. Refactoring should be used to make complex code simpler
|
||||
17. [Refactoring](https://refactoring.com/catalog/) should be used to make complex code simpler
|
||||
18. Try to minimize the surface area (exported values) of each module and composable
|
||||
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`
|
||||
20. Keep functions short and functional
|
||||
@@ -170,6 +152,42 @@ The project uses **Nx** for build orchestration and task management
|
||||
23. Favor pure functions (especially testable ones)
|
||||
24. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
- Test files:
|
||||
- Unit/Component: `**/*.test.ts`
|
||||
- E2E: `browser_tests/**/*.spec.ts`
|
||||
- Litegraph Specific: `src/lib/litegraph/test/`
|
||||
|
||||
### General
|
||||
|
||||
1. Do not write change detector tests
|
||||
e.g. a test that just asserts that the defaults are certain values
|
||||
2. Do not write tests that are dependent on non-behavioral features like utility classes or styles
|
||||
3. Be parsimonious in testing, do not write redundant tests
|
||||
See <https://tidyfirst.substack.com/p/composable-tests>
|
||||
4. [Don’t Mock What You Don’t Own](https://hynek.me/articles/what-to-mock-in-5-mins/)
|
||||
|
||||
### Vitest / Unit Tests
|
||||
|
||||
1. Do not write tests that just test the mocks
|
||||
Ensure that the tests fail when the code itself would behave in a way that was not expected or desired
|
||||
2. For mocking, leverage [Vitest's utilities](https://vitest.dev/guide/mocking.html) where possible
|
||||
3. Keep your module mocks contained
|
||||
Do not use global mutable state within the test file
|
||||
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
|
||||
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
5. Aim for behavioral coverage of critical and new features
|
||||
|
||||
### Playwright / Browser / E2E Tests
|
||||
|
||||
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
|
||||
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
|
||||
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
|
||||
|
||||
## External Resources
|
||||
|
||||
- Vue: <https://vuejs.org/api/>
|
||||
@@ -182,6 +200,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
|
||||
@@ -87,6 +87,8 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
},
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { PassThrough } from '@primevue/core'
|
||||
import Button from 'primevue/button'
|
||||
import Step, { type StepPassThroughOptions } from 'primevue/step'
|
||||
import Step from 'primevue/step'
|
||||
import type { StepPassThroughOptions } from 'primevue/step'
|
||||
import StepList from 'primevue/steplist'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -155,12 +155,14 @@ export async function loadLocale(locale: string): Promise<void> {
|
||||
}
|
||||
|
||||
// Only include English in the initial bundle
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings)
|
||||
}
|
||||
const enMessages = buildLocale(en, enNodes, enCommands, enSettings)
|
||||
|
||||
// Type for locale messages - inferred from the English locale structure
|
||||
type LocaleMessages = typeof messages.en
|
||||
type LocaleMessages = typeof enMessages
|
||||
|
||||
const messages: Record<string, LocaleMessages> = {
|
||||
en: enMessages
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { type Ref, computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.
|
||||
|
||||
@@ -29,7 +29,8 @@ import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { type DialogAction, getDialog } from '@/constants/desktopDialogs'
|
||||
import { getDialog } from '@/constants/desktopDialogs'
|
||||
import type { DialogAction } from '@/constants/desktopDialogs'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img
|
||||
class="sad-girl"
|
||||
src="/assets/images/sad_girl.png"
|
||||
alt="Sad girl illustration"
|
||||
:alt="$t('notSupported.illustrationAlt')"
|
||||
/>
|
||||
|
||||
<div class="no-drag sad-text flex items-center">
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 82 KiB |
@@ -126,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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,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) {
|
||||
@@ -568,9 +585,15 @@ export class ComfyPage {
|
||||
fileName?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const { dropPosition = { x: 100, y: 100 }, fileName, url } = options
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
url,
|
||||
waitForUpload = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
throw new Error('Must provide either fileName or url')
|
||||
@@ -607,6 +630,14 @@ 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()
|
||||
@@ -673,12 +704,17 @@ export class ComfyPage {
|
||||
}
|
||||
}, evaluateParams)
|
||||
|
||||
// Wait for file upload to complete
|
||||
if (uploadResponsePromise) {
|
||||
await uploadResponsePromise
|
||||
}
|
||||
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async dragAndDropFile(
|
||||
fileName: string,
|
||||
options: { dropPosition?: Position } = {}
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
) {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
@@ -137,6 +137,13 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
await this.page.keyboard.type(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for workflow service to finish renaming
|
||||
await this.page.waitForFunction(
|
||||
() => !window['app']?.extensionManager?.workflow?.isBusy,
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
}
|
||||
|
||||
async insertWorkflow(locator: Locator) {
|
||||
|
||||
@@ -92,9 +92,26 @@ export class Topbar {
|
||||
)
|
||||
// Wait for the dialog to close.
|
||||
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
|
||||
|
||||
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
||||
// If so, return early to let the test handle the confirmation
|
||||
const confirmationDialog = this.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
if (await confirmationDialog.isVisible()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async openTopbarMenu() {
|
||||
// If menu is already open, close it first to reset state
|
||||
const isAlreadyOpen = await this.menuLocator.isVisible()
|
||||
if (isAlreadyOpen) {
|
||||
// Click outside the menu to close it properly
|
||||
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
|
||||
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
|
||||
}
|
||||
|
||||
await this.menuTrigger.click()
|
||||
await this.menuLocator.waitFor({ state: 'visible' })
|
||||
return this.menuLocator
|
||||
@@ -162,15 +179,36 @@ export class Topbar {
|
||||
|
||||
await topLevelMenu.hover()
|
||||
|
||||
// Hover over top-level menu with retry logic for flaky submenu appearance
|
||||
const submenu = this.getVisibleSubmenu()
|
||||
try {
|
||||
await submenu.waitFor({ state: 'visible', timeout: 1000 })
|
||||
} catch {
|
||||
// Click outside to reset, then reopen menu
|
||||
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
|
||||
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
|
||||
await this.menuTrigger.click()
|
||||
await this.menuLocator.waitFor({ state: 'visible' })
|
||||
// Re-hover on top-level menu to trigger submenu
|
||||
await topLevelMenu.hover()
|
||||
await submenu.waitFor({ state: 'visible', timeout: 1000 })
|
||||
}
|
||||
|
||||
let currentMenu = topLevelMenu
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const commandName = path[i]
|
||||
const menuItem = currentMenu
|
||||
.locator(
|
||||
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
|
||||
)
|
||||
const menuItem = submenu
|
||||
.locator(`.p-tieredmenu-item:has-text("${commandName}")`)
|
||||
.first()
|
||||
await menuItem.waitFor({ state: 'visible' })
|
||||
|
||||
// For the last item, click it
|
||||
if (i === path.length - 1) {
|
||||
await menuItem.click()
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, hover to open nested submenu
|
||||
await menuItem.hover()
|
||||
currentMenu = menuItem
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import path from 'path'
|
||||
|
||||
import type {
|
||||
@@ -8,9 +9,20 @@ 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) {
|
||||
|
||||
@@ -77,8 +77,7 @@ test.describe('Background Image Upload', () => {
|
||||
|
||||
// Verify the URL input now has an API URL
|
||||
const urlInput = backgroundImageSetting.locator('input[type="text"]')
|
||||
const inputValue = await urlInput.inputValue()
|
||||
expect(inputValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
|
||||
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
|
||||
|
||||
// Verify clear button is now enabled
|
||||
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
|
||||
|
||||
@@ -36,9 +36,10 @@ test.describe('Execute to selected output nodes', () => {
|
||||
await output1.click('title')
|
||||
|
||||
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
|
||||
|
||||
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('')
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -306,14 +306,16 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.canvas.click({
|
||||
position: numberWidgetPos
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('prompt-dialog-opened.png')
|
||||
const legacyPrompt = comfyPage.page.locator('.graphdialog')
|
||||
await expect(legacyPrompt).toBeVisible()
|
||||
await comfyPage.delay(300)
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 10,
|
||||
y: 10
|
||||
}
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('prompt-dialog-closed.png')
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
})
|
||||
|
||||
test('Can close prompt dialog with canvas click (text widget)', async ({
|
||||
@@ -327,18 +329,16 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.canvas.click({
|
||||
position: textWidgetPos
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'prompt-dialog-opened-text.png'
|
||||
)
|
||||
const legacyPrompt = comfyPage.page.locator('.graphdialog')
|
||||
await expect(legacyPrompt).toBeVisible()
|
||||
await comfyPage.delay(300)
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 10,
|
||||
y: 10
|
||||
}
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'prompt-dialog-closed-text.png'
|
||||
)
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
})
|
||||
|
||||
test('Can double click node title to edit', async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 97 KiB |
@@ -260,6 +260,12 @@ 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(
|
||||
|
||||
@@ -50,7 +50,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section shows the release
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Should show the release version
|
||||
@@ -79,7 +79,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section shows no releases
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Should show "No recent releases" message
|
||||
@@ -125,7 +125,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Should show no releases due to error
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(
|
||||
whatsNewSection.locator('text=No recent releases')
|
||||
).toBeVisible()
|
||||
@@ -175,7 +175,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section is hidden
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).not.toBeVisible()
|
||||
|
||||
// Should not show any popups or toasts
|
||||
@@ -260,7 +260,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section is visible
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Should show the release
|
||||
@@ -308,7 +308,7 @@ test.describe('Release Notifications', () => {
|
||||
await helpCenterButton.click()
|
||||
|
||||
// Verify "What's New?" section is visible
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Close help center
|
||||
@@ -359,7 +359,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Section should be hidden regardless of empty releases
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -212,8 +212,12 @@ test.describe('Remote COMBO Widget', () => {
|
||||
// Click on the canvas to trigger widget refresh
|
||||
await comfyPage.page.mouse.click(400, 300)
|
||||
|
||||
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
expect(refreshedOptions).not.toEqual(initialOptions)
|
||||
await expect(async () => {
|
||||
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
expect(refreshedOptions).not.toEqual(initialOptions)
|
||||
}).toPass({
|
||||
timeout: 2_000
|
||||
})
|
||||
})
|
||||
|
||||
test('does not refresh when TTL is not set', async ({ comfyPage }) => {
|
||||
@@ -321,8 +325,12 @@ test.describe('Remote COMBO Widget', () => {
|
||||
await clickRefreshButton(comfyPage, nodeName)
|
||||
|
||||
// Verify the selected value of the widget is the first option in the refreshed list
|
||||
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
|
||||
expect(refreshedValue).toEqual('new first option')
|
||||
await expect(async () => {
|
||||
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
|
||||
expect(refreshedValue).toEqual('new first option')
|
||||
}).toPass({
|
||||
timeout: 2_000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -290,16 +290,20 @@ test.describe('Node library sidebar', () => {
|
||||
await comfyPage.page.keyboard.insertText('bar')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['bar/'])
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({
|
||||
'bar/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -340,6 +340,11 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
|
||||
// Wait for workflow to appear in Browse section after sync
|
||||
const workflowItem =
|
||||
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
|
||||
await expect(workflowItem).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const nodeCount = await comfyPage.getGraphNodesCount()
|
||||
|
||||
// Get the bounding box of the canvas element
|
||||
@@ -358,6 +363,10 @@ test.describe('Workflows sidebar', () => {
|
||||
'#graph-canvas',
|
||||
{ targetPosition }
|
||||
)
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(nodeCount * 2)
|
||||
|
||||
// Wait for nodes to be inserted after drag-drop with retryable assertion
|
||||
await expect
|
||||
.poll(() => comfyPage.getGraphNodesCount(), { timeout: 3000 })
|
||||
.toBe(nodeCount * 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -329,6 +329,15 @@ 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', () => {
|
||||
|
||||
@@ -188,22 +188,19 @@ test.describe('Templates', () => {
|
||||
.locator('header')
|
||||
.filter({ hasText: 'Templates' })
|
||||
|
||||
const cardCount = await comfyPage.page
|
||||
.locator('[data-testid^="template-workflow-"]')
|
||||
.count()
|
||||
expect(cardCount).toBeGreaterThan(0)
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
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)
|
||||
expect(cardCount).toBeGreaterThan(0)
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
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)
|
||||
expect(cardCount).toBeGreaterThan(0)
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 30 KiB |
@@ -828,55 +828,55 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
})
|
||||
|
||||
test.describe('Release actions (Shift-drop)', () => {
|
||||
test.fixme(
|
||||
'Context menu opens and endpoint is pinned on Shift-drop',
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
|
||||
const dropPos = { x: outputCenter.x + 90, y: outputCenter.y - 70 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
)
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
|
||||
comfyPage,
|
||||
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,144 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
@@ -252,7 +252,8 @@ 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 }
|
||||
dropPosition: { x, y },
|
||||
waitForUpload: true
|
||||
})
|
||||
|
||||
// Expect the filename combo value to be updated
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
parser as tseslintParser
|
||||
} from 'typescript-eslint'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
import path from 'node:path'
|
||||
|
||||
const extraFileExtensions = ['.vue']
|
||||
|
||||
@@ -61,16 +62,20 @@ export default defineConfig([
|
||||
{
|
||||
ignores: [
|
||||
'.i18nrc.cjs',
|
||||
'components.d.ts',
|
||||
'lint-staged.config.js',
|
||||
'vitest.setup.ts',
|
||||
'.nx/*',
|
||||
'**/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'
|
||||
'src/types/vue-shim.d.ts',
|
||||
'test-results/*',
|
||||
'vitest.setup.ts'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -102,24 +107,17 @@ 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'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
|
||||
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
|
||||
importX.flatConfigs.recommended,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
|
||||
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
|
||||
importX.flatConfigs.typescript,
|
||||
{
|
||||
plugins: {
|
||||
'unused-imports': unusedImports,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Type incompatibility in i18n plugin
|
||||
// @ts-expect-error Type incompatibility in i18n plugin
|
||||
'@intlify/vue-i18n': pluginI18n
|
||||
},
|
||||
rules: {
|
||||
@@ -134,59 +132,24 @@ 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-use-v-else-with-v-for': 'error',
|
||||
'vue/no-useless-mustaches': 'error',
|
||||
'vue/no-useless-v-bind': 'error',
|
||||
// */
|
||||
'vue/one-component-per-file': 'off', // TODO: fix
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/one-component-per-file': 'error',
|
||||
'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',
|
||||
@@ -274,12 +237,6 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.{test,spec,stories}.ts', '**/*.stories.vue'],
|
||||
rules: {
|
||||
'no-console': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
@@ -292,6 +249,18 @@ export default defineConfig([
|
||||
'no-console': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// Turn off ESLint rules that are already handled by oxlint
|
||||
...oxlint.buildFromOxlintConfigFile('./.oxlintrc.json')
|
||||
...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'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
2
global.d.ts
vendored
@@ -13,6 +13,8 @@ interface Window {
|
||||
max_upload_size?: number
|
||||
comfy_api_base_url?: string
|
||||
comfy_platform_base_url?: string
|
||||
stripe_publishable_key?: string
|
||||
stripe_pricing_table_id?: string
|
||||
firebase_config?: {
|
||||
apiKey: string
|
||||
authDomain: string
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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(' ')}`
|
||||
]
|
||||
}
|
||||
21
lint-staged.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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}`
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.34.7",
|
||||
"version": "1.35.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -34,6 +34,7 @@
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "oxlint src --type-aware && eslint src --cache",
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
"preinstall": "pnpm dlx only-allow pnpm",
|
||||
@@ -46,6 +47,7 @@
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"zipdist": "node scripts/zipdist.js",
|
||||
"clean": "nx reset"
|
||||
},
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
|
||||
|
||||
--color-coral-red-600: #973a40;
|
||||
--color-coral-red-500: #c53f49;
|
||||
--color-coral-red-400: #dd424e;
|
||||
@@ -96,7 +98,6 @@
|
||||
--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);
|
||||
@@ -183,9 +184,13 @@
|
||||
--interface-menu-component-surface-hovered: var(--color-smoke-200);
|
||||
--interface-menu-component-surface-selected: var(--color-smoke-400);
|
||||
--interface-menu-keybind-surface-default: var(--color-smoke-500);
|
||||
--interface-menu-surface: var(--color-white);
|
||||
--interface-menu-stroke: var(--color-smoke-600);
|
||||
--interface-panel-surface: var(--color-white);
|
||||
--interface-stroke: var(--color-smoke-300);
|
||||
|
||||
|
||||
|
||||
--nav-background: var(--color-white);
|
||||
|
||||
--node-border: var(--color-smoke-300);
|
||||
@@ -255,6 +260,8 @@
|
||||
--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;
|
||||
@@ -301,6 +308,8 @@
|
||||
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
|
||||
--interface-menu-component-surface-selected: var(--color-charcoal-300);
|
||||
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
|
||||
--interface-menu-surface: var(--color-charcoal-800);
|
||||
--interface-menu-stroke: var(--color-ash-800);
|
||||
--interface-panel-surface: var(--color-charcoal-800);
|
||||
--interface-stroke: var(--color-charcoal-400);
|
||||
|
||||
@@ -376,6 +385,8 @@
|
||||
--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);
|
||||
@@ -416,6 +427,8 @@
|
||||
--color-interface-menu-keybind-surface-default: var(
|
||||
--interface-menu-keybind-surface-default
|
||||
);
|
||||
--color-interface-menu-surface: var(--interface-menu-surface);
|
||||
--color-interface-menu-stroke: var(--interface-menu-stroke);
|
||||
--color-interface-panel-surface: var(--interface-panel-surface);
|
||||
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
|
||||
--color-interface-panel-selected-surface: var(
|
||||
@@ -424,7 +437,11 @@
|
||||
--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);
|
||||
@@ -480,6 +497,8 @@
|
||||
--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);
|
||||
@@ -1309,6 +1328,15 @@ 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 {
|
||||
|
||||
308
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -1945,6 +1945,40 @@ 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;
|
||||
@@ -3876,7 +3910,7 @@ export interface components {
|
||||
* @description The subscription tier level
|
||||
* @enum {string}
|
||||
*/
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO";
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
FeaturesResponse: {
|
||||
/**
|
||||
* @description The conversion rate for partner nodes
|
||||
@@ -5096,6 +5130,71 @@ 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;
|
||||
@@ -10065,7 +10164,7 @@ export interface components {
|
||||
};
|
||||
BytePlusImageGenerationRequest: {
|
||||
/** @enum {string} */
|
||||
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828";
|
||||
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828" | "seedream-4-5-251128";
|
||||
/** @description Text description for image generation or transformation */
|
||||
prompt: string;
|
||||
/**
|
||||
@@ -10170,10 +10269,10 @@ export interface components {
|
||||
};
|
||||
BytePlusVideoGenerationRequest: {
|
||||
/**
|
||||
* @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
|
||||
* @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
|
||||
* @enum {string}
|
||||
*/
|
||||
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428";
|
||||
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428" | "seedance-1-0-pro-fast-251015";
|
||||
/** @description The input content for the model to generate a video */
|
||||
content: components["schemas"]["BytePlusVideoGenerationContent"][];
|
||||
/**
|
||||
@@ -13947,6 +14046,15 @@ 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: {
|
||||
@@ -18345,6 +18453,198 @@ 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
@@ -5,7 +5,7 @@ packages:
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@comfyorg/comfyui-electron-types': 0.5.5
|
||||
'@eslint/js': ^9.35.0
|
||||
'@eslint/js': ^9.39.1
|
||||
'@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.0.4
|
||||
'@prettier/plugin-oxc': ^0.1.3
|
||||
'@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.34.0
|
||||
eslint: ^9.39.1
|
||||
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.6
|
||||
eslint-plugin-unused-imports: ^4.2.0
|
||||
eslint-plugin-vue: ^10.4.0
|
||||
eslint-plugin-storybook: ^9.1.16
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
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.2.7
|
||||
lint-staged: ^15.5.2
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 21.4.1
|
||||
oxlint: ^1.25.0
|
||||
oxlint-tsgolint: ^0.4.0
|
||||
oxlint: ^1.32.0
|
||||
oxlint-tsgolint: ^0.8.4
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^2.1.7
|
||||
postcss-html: ^1.8.0
|
||||
prettier: ^3.6.2
|
||||
prettier: ^3.7.4
|
||||
pretty-bytes: ^7.1.0
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^9.1.6
|
||||
stylelint: ^16.24.0
|
||||
storybook: ^9.1.16
|
||||
stylelint: ^16.26.1
|
||||
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.2
|
||||
typescript-eslint: ^8.44.0
|
||||
typescript: ^5.9.3
|
||||
typescript-eslint: ^8.49.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.0.7
|
||||
vue-tsc: ^3.1.8
|
||||
vuefire: ^3.2.1
|
||||
yjs: ^13.6.27
|
||||
zod: ^3.23.8
|
||||
|
||||
19
scripts/cicd/check-shell.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/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[@]}"
|
||||
@@ -74,7 +74,7 @@ deploy_report() {
|
||||
|
||||
|
||||
# Project name with dots converted to dashes for Cloudflare
|
||||
sanitized_browser=$(echo "$browser" | sed 's/\./-/g')
|
||||
sanitized_browser="${browser//./-}"
|
||||
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='|'
|
||||
set -- $all_counts
|
||||
for counts_json; do
|
||||
IFS='|' read -r -a counts_array <<< "$all_counts"
|
||||
for counts_json in "${counts_array[@]}"; do
|
||||
[ -z "$counts_json" ] && continue
|
||||
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,13 +324,12 @@ $status_icon **$status_text**
|
||||
|
||||
# Add browser results with individual counts
|
||||
i=0
|
||||
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)))
|
||||
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]:-}"
|
||||
|
||||
if [ "$url" != "failed" ] && [ -n "$url" ]; then
|
||||
# Parse individual browser counts
|
||||
@@ -374,4 +373,4 @@ $status_icon **$status_text**
|
||||
🎉 Click on the links above to view detailed test results for each browser configuration."
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -134,22 +134,38 @@ function resolveRelease(
|
||||
|
||||
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
|
||||
|
||||
// Calculate target minor version (next minor)
|
||||
const targetMinor = currentMinor + 1
|
||||
const targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
// Check if target branch exists in frontend repo
|
||||
// Fetch all branches
|
||||
exec('git fetch origin', frontendRepoPath)
|
||||
const branchExists = exec(
|
||||
|
||||
// Try next minor first, fall back to current minor if not available
|
||||
let targetMinor = currentMinor + 1
|
||||
let targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const nextMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!branchExists) {
|
||||
console.error(
|
||||
`Target branch ${targetBranch} does not exist in frontend repo`
|
||||
if (!nextMinorExists) {
|
||||
// Fall back to current minor for patch releases
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const currentMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!currentMinorExists) {
|
||||
console.error(
|
||||
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for patch release`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Get latest patch tag for target minor
|
||||
|
||||
125
src/base/credits/comfyCredits.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
|
||||
const formatNumber = ({
|
||||
value,
|
||||
locale,
|
||||
options
|
||||
}: {
|
||||
value: number
|
||||
locale?: string
|
||||
options?: Intl.NumberFormatOptions
|
||||
}): string => {
|
||||
const merged: Intl.NumberFormatOptions = {
|
||||
...DEFAULT_NUMBER_FORMAT,
|
||||
...options
|
||||
}
|
||||
|
||||
if (
|
||||
typeof merged.maximumFractionDigits === 'number' &&
|
||||
typeof merged.minimumFractionDigits === 'number' &&
|
||||
merged.maximumFractionDigits < merged.minimumFractionDigits
|
||||
) {
|
||||
merged.minimumFractionDigits = merged.maximumFractionDigits
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, merged).format(value)
|
||||
}
|
||||
|
||||
export const CREDITS_PER_USD = 211
|
||||
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
|
||||
|
||||
export const usdToCents = (usd: number): number => Math.round(usd * 100)
|
||||
|
||||
export const centsToCredits = (cents: number): number =>
|
||||
Math.round(cents * COMFY_CREDIT_RATE_CENTS)
|
||||
|
||||
export const creditsToCents = (credits: number): number =>
|
||||
Math.round(credits / COMFY_CREDIT_RATE_CENTS)
|
||||
|
||||
export const usdToCredits = (usd: number): number =>
|
||||
Math.round(usd * CREDITS_PER_USD)
|
||||
|
||||
export const creditsToUsd = (credits: number): number =>
|
||||
Math.round((credits / CREDITS_PER_USD) * 100) / 100
|
||||
|
||||
export type FormatOptions = {
|
||||
value: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export type FormatFromCentsOptions = {
|
||||
cents: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export type FormatFromUsdOptions = {
|
||||
usd: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export const formatCredits = ({
|
||||
value,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatOptions): string =>
|
||||
formatNumber({ value, locale, options: numberOptions })
|
||||
|
||||
export const formatCreditsFromCents = ({
|
||||
cents,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromCentsOptions): string =>
|
||||
formatCredits({
|
||||
value: centsToCredits(cents),
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
export const formatCreditsFromUsd = ({
|
||||
usd,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromUsdOptions): string =>
|
||||
formatCredits({
|
||||
value: usdToCredits(usd),
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
export const formatUsd = ({
|
||||
value,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatOptions): string =>
|
||||
formatNumber({
|
||||
value,
|
||||
locale,
|
||||
options: numberOptions
|
||||
})
|
||||
|
||||
export const formatUsdFromCents = ({
|
||||
cents,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromCentsOptions): string =>
|
||||
formatUsd({
|
||||
value: cents / 100,
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
/**
|
||||
* Clamps a USD value to the allowed range for credit purchases
|
||||
* @param value - The USD amount to clamp
|
||||
* @returns The clamped value between $1 and $1000, or 0 if NaN
|
||||
*/
|
||||
export const clampUsd = (value: number): number => {
|
||||
if (Number.isNaN(value)) return 0
|
||||
return Math.min(1000, Math.max(1, value))
|
||||
}
|
||||
@@ -20,17 +20,6 @@
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-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>
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
@@ -87,8 +76,6 @@ import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -96,8 +83,6 @@ import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
@@ -108,13 +93,9 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
||||
)
|
||||
@@ -131,11 +112,6 @@ onMounted(() => {
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -28,8 +28,20 @@
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ComfyRunButton />
|
||||
<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>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
@@ -43,17 +55,24 @@ import {
|
||||
watchDebounced
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.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 { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
@@ -121,7 +140,14 @@ const setInitialPosition = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(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()
|
||||
}
|
||||
|
||||
watch(visible, async (newVisible) => {
|
||||
if (newVisible) {
|
||||
await nextTick(setInitialPosition)
|
||||
@@ -250,6 +276,16 @@ watch(isDragging, (dragging) => {
|
||||
isMouseOverDropZone.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
'w-[200px] border-dashed border-blue-500 opacity-80',
|
||||
|
||||
@@ -58,7 +58,7 @@ const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
class="bg-transparent"
|
||||
>
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="tabs-container">
|
||||
<div class="tabs-container font-inter">
|
||||
<Tab
|
||||
v-for="tab in bottomPanelStore.bottomPanelTabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="m-1 mx-2 border-none"
|
||||
class="m-1 mx-2 border-none font-inter"
|
||||
:class="{
|
||||
'tab-list-single-item':
|
||||
bottomPanelStore.bottomPanelTabs.length === 1
|
||||
|
||||
@@ -55,7 +55,6 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { subcategories } = defineProps<{
|
||||
commands: ComfyCommandImpl[]
|
||||
subcategories: Record<string, ComfyCommandImpl[]>
|
||||
}>()
|
||||
|
||||
|
||||
@@ -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.graph, nodeDefStore.nodeDefsByName)
|
||||
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -41,7 +41,7 @@ const {
|
||||
inputAttrs?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
|
||||
const emit = defineEmits(['edit', 'cancel'])
|
||||
const inputValue = ref<string>(modelValue)
|
||||
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
|
||||
const isCanceling = ref(false)
|
||||
|
||||
@@ -11,7 +11,6 @@ import InputText from 'primevue/inputtext'
|
||||
|
||||
const modelValue = defineModel<string>('modelValue')
|
||||
defineProps<{
|
||||
defaultValue?: string
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchBox from './SearchBox.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: Omit<ComponentExposed<C>, 'focus'>
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchBox> = {
|
||||
const meta: GenericMeta<typeof SearchBox> = {
|
||||
title: 'Components/Input/SearchBox',
|
||||
component: SearchBox,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
@@ -19,9 +26,12 @@ const meta: Meta<typeof SearchBox> = {
|
||||
control: 'select',
|
||||
options: ['md', 'lg'],
|
||||
description: 'Size variant of the search box'
|
||||
}
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' },
|
||||
onSearch: { action: 'search' }
|
||||
},
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Search...',
|
||||
showBorder: false,
|
||||
size: 'md'
|
||||