Compare commits
145 Commits
backport-7
...
backport-7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10e5bc872e | ||
|
|
9ec20f26d4 | ||
|
|
c6ae695799 | ||
|
|
d73b35938b | ||
|
|
e7afb2b0d7 | ||
|
|
9d721c96ec | ||
|
|
ea564170d1 | ||
|
|
8080dc7cd7 | ||
|
|
ce18d4dfc6 | ||
|
|
884f0a73c0 | ||
|
|
408b188a37 | ||
|
|
9a9239c161 | ||
|
|
a8d0cfe058 | ||
|
|
b7b4608e7b | ||
|
|
d2281f64c9 | ||
|
|
463a04eb5a | ||
|
|
dfc58c0c1d | ||
|
|
59966243fc | ||
|
|
1393e438d3 | ||
|
|
574e5bd991 | ||
|
|
903c830dd0 | ||
|
|
8c651f22a0 | ||
|
|
36027a858f | ||
|
|
a26fc1cd8f | ||
|
|
0371dca1b6 | ||
|
|
1c914c7dff | ||
|
|
a68c169179 | ||
|
|
0fed12a62d | ||
|
|
8238afacf2 | ||
|
|
14c12350eb | ||
|
|
292fd5eb68 | ||
|
|
47aab2c8e1 | ||
|
|
c5312d6963 | ||
|
|
43e11c6873 | ||
|
|
4a041bead2 | ||
|
|
eb04dc20f3 | ||
|
|
194fbdf520 | ||
|
|
5f20d554f3 | ||
|
|
fd9747375d | ||
|
|
5187a77234 | ||
|
|
b22ba97a13 | ||
|
|
aab9a30511 | ||
|
|
b0f5a9ffe2 | ||
|
|
3f777fb54f | ||
|
|
6a73288ce3 | ||
|
|
d21ea0f65b | ||
|
|
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
|
||||
|
||||
124
.github/workflows/release-version-bump.yaml
vendored
@@ -20,6 +20,13 @@ on:
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
schedule:
|
||||
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
concurrency:
|
||||
group: release-version-bump
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
@@ -29,15 +36,99 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Prepare inputs
|
||||
id: prepared-inputs
|
||||
shell: bash
|
||||
env:
|
||||
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
|
||||
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
|
||||
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION_TYPE="$RAW_VERSION_TYPE"
|
||||
PRE_RELEASE="$RAW_PRE_RELEASE"
|
||||
TARGET_BRANCH="$RAW_BRANCH"
|
||||
|
||||
if [[ -z "$VERSION_TYPE" ]]; then
|
||||
VERSION_TYPE='patch'
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET_BRANCH" ]]; then
|
||||
TARGET_BRANCH='main'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version_type=$VERSION_TYPE"
|
||||
echo "pre_release=$PRE_RELEASE"
|
||||
echo "branch=$TARGET_BRANCH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Close stale nightly version bump PRs
|
||||
if: github.event_name == 'schedule'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const prefix = 'version-bump-'
|
||||
const closed = []
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
})
|
||||
|
||||
for (const pr of prs) {
|
||||
if (!pr.head?.ref?.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (pr.user?.login !== 'github-actions[bot]') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only clean up stale nightly PRs targeting main.
|
||||
// Adjust here if other target branches should be cleaned.
|
||||
if (pr.base?.ref !== 'main') {
|
||||
continue
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
})
|
||||
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `heads/${pr.head.ref}`
|
||||
})
|
||||
} catch (error) {
|
||||
if (![404, 422].includes(error.status)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
closed.push(pr.number)
|
||||
}
|
||||
|
||||
core.info(`Closed ${closed.length} stale PR(s).`)
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
ref: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate branch exists
|
||||
env:
|
||||
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
run: |
|
||||
BRANCH="${{ github.event.inputs.branch }}"
|
||||
BRANCH="$TARGET_BRANCH"
|
||||
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
|
||||
echo "❌ Branch '$BRANCH' does not exist"
|
||||
echo ""
|
||||
@@ -51,7 +142,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -62,16 +153,31 @@ jobs:
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
env:
|
||||
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
|
||||
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
|
||||
run: |
|
||||
pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
|
||||
set -euo pipefail
|
||||
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
|
||||
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$PRE_RELEASE" ]]; then
|
||||
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
|
||||
else
|
||||
pnpm version "$VERSION_TYPE" --no-git-tag-version
|
||||
fi
|
||||
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Format PR string
|
||||
id: capitalised
|
||||
env:
|
||||
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
|
||||
run: |
|
||||
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
|
||||
CAPITALISED_TYPE="$VERSION_TYPE"
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
@@ -82,8 +188,8 @@ jobs:
|
||||
body: |
|
||||
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
|
||||
**Base branch:** `${{ github.event.inputs.branch }}`
|
||||
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
|
||||
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
base: ${{ github.event.inputs.branch }}
|
||||
base: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
labels: |
|
||||
Release
|
||||
|
||||
1
.npmrc
@@ -1,2 +1,3 @@
|
||||
ignore-workspace-root-check=true
|
||||
catalog-mode=prefer
|
||||
public-hoist-pattern[]=@parcel/watcher
|
||||
|
||||
@@ -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<{
|
||||
@@ -50,8 +51,6 @@ defineProps<{
|
||||
canProceed: boolean
|
||||
/** Whether the location step should be disabled */
|
||||
disableLocationStep: boolean
|
||||
/** Whether the migration step should be disabled */
|
||||
disableMigrationStep: boolean
|
||||
/** Whether the settings step should be disabled */
|
||||
disableSettingsStep: boolean
|
||||
}>()
|
||||
|
||||
@@ -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">
|
||||
|
||||
92
browser_tests/assets/groups/nested-groups-1-inner-node.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
|
||||
"revision": 0,
|
||||
"last_node_id": 17,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 17,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
318.8446183157076,
|
||||
355.3961392345528
|
||||
],
|
||||
"size": [
|
||||
225,
|
||||
102
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Outer Group",
|
||||
"bounding": [
|
||||
-46.25245366331014,
|
||||
-150.82497138023245,
|
||||
1034.4034361963616,
|
||||
1007.338460439933
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Inner Group",
|
||||
"bounding": [
|
||||
80.96059074101554,
|
||||
28.123757436778178,
|
||||
718.286373661183,
|
||||
691.2397164539732
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7121393732101533,
|
||||
"offset": [
|
||||
289.18242848011835,
|
||||
367.0747755524199
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.35.5",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true,
|
||||
"workflowRendererVersion": "Vue"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 422 B |
|
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 })
|
||||
}
|
||||
@@ -1617,6 +1653,55 @@ export class ComfyPage {
|
||||
}, focusMode)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of a group by title.
|
||||
* @param title The title of the group to find
|
||||
* @returns The group's canvas position
|
||||
* @throws Error if group not found
|
||||
*/
|
||||
async getGroupPosition(title: string): Promise<Position> {
|
||||
const pos = await this.page.evaluate((title) => {
|
||||
const groups = window['app'].graph.groups
|
||||
const group = groups.find((g: { title: string }) => g.title === title)
|
||||
if (!group) return null
|
||||
return { x: group.pos[0], y: group.pos[1] }
|
||||
}, title)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a group by its title.
|
||||
* @param options.name The title of the group to drag
|
||||
* @param options.deltaX Horizontal drag distance in screen pixels
|
||||
* @param options.deltaY Vertical drag distance in screen pixels
|
||||
*/
|
||||
async dragGroup(options: {
|
||||
name: string
|
||||
deltaX: number
|
||||
deltaY: number
|
||||
}): Promise<void> {
|
||||
const { name, deltaX, deltaY } = options
|
||||
const screenPos = await this.page.evaluate((title) => {
|
||||
const app = window['app']
|
||||
const groups = app.graph.groups
|
||||
const group = groups.find((g: { title: string }) => g.title === title)
|
||||
if (!group) return null
|
||||
// Position in the title area of the group
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
}, name)
|
||||
if (!screenPos) throw new Error(`Group "${name}" not found`)
|
||||
|
||||
await this.dragAndDrop(screenPos, {
|
||||
x: screenPos.x + deltaX,
|
||||
y: screenPos.y + deltaY
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const testComfySnapToGridGridSize = 50
|
||||
|
||||
@@ -160,7 +160,7 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
incrementButton: widget.locator('button').first(),
|
||||
decrementButton: widget.locator('button').last()
|
||||
decrementButton: widget.locator('button').nth(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)')
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 79 KiB |
@@ -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 }) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ test.describe('Load Workflow in Media', () => {
|
||||
'edited_workflow.webp',
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp',
|
||||
'workflow_prompt_parameters.png',
|
||||
'workflow.webm',
|
||||
// Skipped due to 3d widget unstable visual result.
|
||||
// 3d widget shows grid after fully loaded.
|
||||
|
||||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 44 KiB |
29
browser_tests/tests/mobileBaseline.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
test.describe('Mobile Baseline Snapshots', () => {
|
||||
test('@mobile empty canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 256 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
})
|
||||
|
||||
test('@mobile default workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-default-workflow.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 97 KiB |
@@ -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
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 75 KiB |
@@ -32,4 +32,42 @@ test.describe('Vue Node Groups', () => {
|
||||
'vue-groups-fit-to-contents.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should move nested groups together when dragging outer group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
|
||||
// Get initial positions with null guards
|
||||
const outerInitial = await comfyPage.getGroupPosition('Outer Group')
|
||||
const innerInitial = await comfyPage.getGroupPosition('Inner Group')
|
||||
|
||||
const initialOffsetX = innerInitial.x - outerInitial.x
|
||||
const initialOffsetY = innerInitial.y - outerInitial.y
|
||||
|
||||
// Drag the outer group
|
||||
const dragDelta = { x: 100, y: 80 }
|
||||
await comfyPage.dragGroup({
|
||||
name: 'Outer Group',
|
||||
deltaX: dragDelta.x,
|
||||
deltaY: dragDelta.y
|
||||
})
|
||||
|
||||
// Use retrying assertion to wait for positions to update
|
||||
await expect(async () => {
|
||||
const outerFinal = await comfyPage.getGroupPosition('Outer Group')
|
||||
const innerFinal = await comfyPage.getGroupPosition('Inner Group')
|
||||
|
||||
const finalOffsetX = innerFinal.x - outerFinal.x
|
||||
const finalOffsetY = innerFinal.y - outerFinal.y
|
||||
|
||||
// Both groups should have moved
|
||||
expect(outerFinal.x).not.toBe(outerInitial.x)
|
||||
expect(innerFinal.x).not.toBe(innerInitial.x)
|
||||
|
||||
// The relative offset should be maintained (inner group moved with outer)
|
||||
expect(finalOffsetX).toBeCloseTo(initialOffsetX, 0)
|
||||
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 27 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: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 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: 59 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 106 KiB |
@@ -15,7 +15,9 @@ test.describe('Vue Integer Widget', () => {
|
||||
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
|
||||
const seedWidget = comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'seed')
|
||||
.first()
|
||||
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 80 KiB |
@@ -205,6 +205,32 @@ test.describe('Image widget', () => {
|
||||
const filename = await fileComboWidget.getValue()
|
||||
expect(filename).toBe('image32x32.webp')
|
||||
})
|
||||
test('Displays buttons when viewing single image of batch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const [x, y] = await comfyPage.page.evaluate(() => {
|
||||
const src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='768' height='512' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' stroke='black'/%3E%3C/svg%3E"
|
||||
const image1 = new Image()
|
||||
image1.src = src
|
||||
const image2 = new Image()
|
||||
image2.src = src
|
||||
const targetNode = graph.nodes[6]
|
||||
targetNode.imgs = [image1, image2]
|
||||
targetNode.imageIndex = 1
|
||||
app.canvas.setDirty(true)
|
||||
|
||||
const x = targetNode.pos[0] + targetNode.size[0] - 41
|
||||
const y = targetNode.pos[1] + targetNode.widgets.at(-1).last_y + 30
|
||||
return app.canvasPosToClientPos([x, y])
|
||||
})
|
||||
|
||||
const clip = { x, y, width: 35, height: 35 }
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'image_preview_close_button.png',
|
||||
{ clip }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Animated image widget', () => {
|
||||
@@ -252,7 +278,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
|
||||
|
||||
|
After Width: | Height: | Size: 423 B |
@@ -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
|
||||
|
||||