Compare commits
245 Commits
bl-move-qu
...
drjkl/debu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f2736095c | ||
|
|
16b285a79c | ||
|
|
35eb0286e5 | ||
|
|
c171227d28 | ||
|
|
51bce9239e | ||
|
|
52483b34ed | ||
|
|
80d58ddc8b | ||
|
|
3c0b227f94 | ||
|
|
0a73c5a131 | ||
|
|
326154f2b2 | ||
|
|
33c457aca0 | ||
|
|
8afb45db9c | ||
|
|
702c917e57 | ||
|
|
7b0830a4ca | ||
|
|
4771565486 | ||
|
|
aa6f9b7009 | ||
|
|
133427b08e | ||
|
|
e8022f9dee | ||
|
|
3bfd62b9fc | ||
|
|
15655ddb76 | ||
|
|
e5583fe955 | ||
|
|
b1d8bf0b13 | ||
|
|
6b6b467e68 | ||
|
|
ef2d34c560 | ||
|
|
1b1356951e | ||
|
|
3e9a390c25 | ||
|
|
7c61dadaf2 | ||
|
|
7246ec7f1c | ||
|
|
2d0980cb1c | ||
|
|
d9e1122677 | ||
|
|
69b57eaba5 | ||
|
|
f647c8f9ee | ||
|
|
7952eb477e | ||
|
|
df85c4d463 | ||
|
|
6bbea48d8e | ||
|
|
219d86db2c | ||
|
|
941cd2b4a5 | ||
|
|
9efcbe682f | ||
|
|
4b1a30e330 | ||
|
|
524c7e9b95 | ||
|
|
a08ccb55c1 | ||
|
|
5c142275ad | ||
|
|
4a5e7c8bcb | ||
|
|
df93277802 | ||
|
|
db16cfbb2b | ||
|
|
92357445e4 | ||
|
|
93e7a4f9f9 | ||
|
|
8261e1d187 | ||
|
|
e4d2bc2b59 | ||
|
|
482159957e | ||
|
|
f08b0f44ef | ||
|
|
f1d1747582 | ||
|
|
d12c6d7814 | ||
|
|
2db246f494 | ||
|
|
66e6f24980 | ||
|
|
fcf2e0e639 | ||
|
|
7b701ad07b | ||
|
|
9a6ead37cb | ||
|
|
7a1a2c1abb | ||
|
|
e503482c6f | ||
|
|
c7b5f47055 | ||
|
|
d9e5b07c73 | ||
|
|
73cfbd9833 | ||
|
|
9669100c14 | ||
|
|
ac825bdb87 | ||
|
|
327e37db04 | ||
|
|
47714c2740 | ||
|
|
f7ac0aa39e | ||
|
|
e83f33aeb2 | ||
|
|
b1dfbfaa09 | ||
|
|
e6ef99e92c | ||
|
|
f5a784e561 | ||
|
|
e8b45204f2 | ||
|
|
5df793b721 | ||
|
|
79d3b2c291 | ||
|
|
916c1248e3 | ||
|
|
b5f91977c8 | ||
|
|
a8b4928acc | ||
|
|
7f25280da4 | ||
|
|
4bf9b94cd4 | ||
|
|
a2246cce7a | ||
|
|
d73e8e4aa3 | ||
|
|
7ef4ea6f25 | ||
|
|
d5f17f7d9f | ||
|
|
0d0576faab | ||
|
|
b0d7a7f0f4 | ||
|
|
12ee5de73b | ||
|
|
6db4750d96 | ||
|
|
30907f99f1 | ||
|
|
284bdce61b | ||
|
|
7fcef2ba89 | ||
|
|
54db655a23 | ||
|
|
82c3cd3cd2 | ||
|
|
c9d74777ba | ||
|
|
be8916b4ce | ||
|
|
de2e37ec8e | ||
|
|
e5ff329008 | ||
|
|
d3bd85db7f | ||
|
|
94706b5b04 | ||
|
|
851e8beb29 | ||
|
|
7b3a9b40a5 | ||
|
|
3bbae61763 | ||
|
|
3fcebe758b | ||
|
|
0439f744b9 | ||
|
|
9d8e54a51c | ||
|
|
69512b9b28 | ||
|
|
afe8ce720e | ||
|
|
e4308a7258 | ||
|
|
cd4999209b | ||
|
|
7556e3ef39 | ||
|
|
d93c02c437 | ||
|
|
b979ba8992 | ||
|
|
0288b02113 | ||
|
|
ddac3dca1d | ||
|
|
6714b958c7 | ||
|
|
a6b6857e37 | ||
|
|
c0a649ef43 | ||
|
|
089295606a | ||
|
|
6048fab239 | ||
|
|
5409bf86a9 | ||
|
|
c56e8425d4 | ||
|
|
0d5ca96a2b | ||
|
|
aff7f2a296 | ||
|
|
23694f37bf | ||
|
|
2466949192 | ||
|
|
f0702793bc | ||
|
|
efc242b968 | ||
|
|
ed3c855eb6 | ||
|
|
29f727a946 | ||
|
|
538f007f1d | ||
|
|
3069c24f81 | ||
|
|
97b1a48a25 | ||
|
|
8497836811 | ||
|
|
946429f2ff | ||
|
|
3d332ff0d7 | ||
|
|
45d95728f3 | ||
|
|
81c66822f5 | ||
|
|
3bb8e94247 | ||
|
|
6382b1e099 | ||
|
|
25afd39d2b | ||
|
|
773f5f5cd9 | ||
|
|
ebca0cb1e0 | ||
|
|
b1b2fd8a4f | ||
|
|
069e94b325 | ||
|
|
2901e1e403 | ||
|
|
3412a0908d | ||
|
|
81df0102f8 | ||
|
|
84662cb94c | ||
|
|
eb213d0ad3 | ||
|
|
971316205f | ||
|
|
20d06f92ca | ||
|
|
97a78f4a35 | ||
|
|
4b2e4c59af | ||
|
|
6cbb83a1e2 | ||
|
|
14866ac11b | ||
|
|
6f3855f5f1 | ||
|
|
0f1e54530c | ||
|
|
3377844408 | ||
|
|
a166ec91a6 | ||
|
|
c6f2ae3130 | ||
|
|
dfb78b2e87 | ||
|
|
965ab674d5 | ||
|
|
05f1dfe921 | ||
|
|
b86eee8494 | ||
|
|
f6d39dbfc8 | ||
|
|
97ca9f489e | ||
|
|
7b274b74f1 | ||
|
|
44c317fd05 | ||
|
|
23e9b39593 | ||
|
|
dcfa53fd7d | ||
|
|
2d5d18c020 | ||
|
|
6883241e50 | ||
|
|
e3906a0656 | ||
|
|
3ce588ad42 | ||
|
|
818c5c32e5 | ||
|
|
dbb0bd961f | ||
|
|
11bd9022c8 | ||
|
|
df1eb32907 | ||
|
|
52b94e06a1 | ||
|
|
7bc6334065 | ||
|
|
8086f977c9 | ||
|
|
f843d779c2 | ||
|
|
bce4f876f4 | ||
|
|
4b095f3701 | ||
|
|
c8e181c841 | ||
|
|
41ffb7c627 | ||
|
|
5029a0b32c | ||
|
|
f240ecaaff | ||
|
|
6a1da7a7af | ||
|
|
a6ca2bcd42 | ||
|
|
886fe07de9 | ||
|
|
43c162a862 | ||
|
|
92f21c14d4 | ||
|
|
1bf5b5397d | ||
|
|
51a7654a39 | ||
|
|
644a8bc60c | ||
|
|
9e434a1002 | ||
|
|
a2e0c3d596 | ||
|
|
e26e1f0c9e | ||
|
|
af094ebefc | ||
|
|
eea24166e0 | ||
|
|
3bd74dcf39 | ||
|
|
405e756d4c | ||
|
|
0ca27f3d9b | ||
|
|
b54ed97557 | ||
|
|
15a05afc27 | ||
|
|
1bde87838d | ||
|
|
99cb7a2da1 | ||
|
|
b3d87673ec | ||
|
|
6a733918a7 | ||
|
|
a87d2cf1bd | ||
|
|
a1d689d3b3 | ||
|
|
dc64e16f7c | ||
|
|
c19a004f0d | ||
|
|
626d8dac70 | ||
|
|
b6a12ddae1 | ||
|
|
11f8cdb9bd | ||
|
|
dcf0886d89 | ||
|
|
ab6678534f | ||
|
|
ea3b3ceb00 | ||
|
|
2356b0bc9e | ||
|
|
dad1eafecc | ||
|
|
6e5dfc0109 | ||
|
|
43f0ac2e8f | ||
|
|
76a0b0b4b4 | ||
|
|
e6e93f2ebf | ||
|
|
372890811d | ||
|
|
14d0ec73f6 | ||
|
|
fbdaf5d7f3 | ||
|
|
a7d0825a14 | ||
|
|
10feb1fd5b | ||
|
|
832588c7a9 | ||
|
|
005633716e | ||
|
|
a326cb36a1 | ||
|
|
a13aa90875 | ||
|
|
05028894e5 | ||
|
|
87f560c713 | ||
|
|
7fcfa4c201 | ||
|
|
8d1f8edc5a | ||
|
|
825ec722c3 | ||
|
|
664aafb705 | ||
|
|
675a67cfda | ||
|
|
4c955f6725 | ||
|
|
5e932bb1e8 | ||
|
|
91f7a64513 |
@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
|
||||
echo " pnpm test:unit - Run unit tests"
|
||||
echo " pnpm typecheck - Run TypeScript checks"
|
||||
echo " pnpm lint - Run ESLint"
|
||||
echo " pnpm format - Format code with Prettier"
|
||||
echo " pnpm format - Format code with oxfmt"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Run 'pnpm dev' to start developing"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
description: Creating unit tests
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Creating unit tests
|
||||
|
||||
- This project uses `vitest` for unit testing
|
||||
- Tests are stored in the `test/` directory
|
||||
- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
|
||||
- e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
|
||||
- Tests should be mocked properly
|
||||
- Mocks should be cleanly written and easy to understand
|
||||
- Mocks should be re-usable where possible
|
||||
|
||||
## Unit test style
|
||||
|
||||
- Prefer the use of `test.extend` over loose variables
|
||||
- To achieve this, import `test as baseTest` from `vitest`
|
||||
- Never use `it`; `test` should be used in place of this
|
||||
14
.github/AGENTS.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# PR Review Context
|
||||
|
||||
Context for automated PR review system.
|
||||
|
||||
## Review Scope
|
||||
|
||||
This automated review performs comprehensive analysis:
|
||||
- Architecture and design patterns
|
||||
- Security vulnerabilities
|
||||
- Performance implications
|
||||
- Code quality and maintainability
|
||||
- Integration concerns
|
||||
|
||||
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
|
||||
39
.github/CLAUDE.md
vendored
@@ -1,36 +1,3 @@
|
||||
# ComfyUI Frontend - Claude Review Context
|
||||
|
||||
This file provides additional context for the automated PR review system.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### PrimeVue Component Migrations
|
||||
|
||||
When reviewing, flag these deprecated components:
|
||||
- `Dropdown` → Use `Select` from 'primevue/select'
|
||||
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
|
||||
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
|
||||
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
|
||||
- `Sidebar` → Use `Drawer` from 'primevue/drawer'
|
||||
- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
|
||||
- `TabMenu` → Use `Tabs` without panels
|
||||
- `Steps` → Use `Stepper` without panels
|
||||
- `InlineMessage` → Use `Message` component
|
||||
|
||||
### API Utilities Reference
|
||||
|
||||
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
|
||||
- `api.fileURL()` - Static file access (templates, extensions)
|
||||
- `$t()` / `i18n.global.t()` - Internationalization
|
||||
- `DOMPurify.sanitize()` - HTML sanitization
|
||||
|
||||
## Review Scope
|
||||
|
||||
This automated review performs comprehensive analysis including:
|
||||
- Architecture and design patterns
|
||||
- Security vulnerabilities
|
||||
- Performance implications
|
||||
- Code quality and maintainability
|
||||
- Integration concerns
|
||||
|
||||
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
|
||||
<!-- A rose by any other name would smell as sweet,
|
||||
But Claude insists on files named for its own conceit. -->
|
||||
@AGENTS.md
|
||||
|
||||
6
.github/workflows/ci-lint-format.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Run Stylelint with auto-fix
|
||||
run: pnpm stylelint:fix
|
||||
|
||||
- name: Run Prettier with auto-format
|
||||
- name: Run oxfmt with auto-format
|
||||
run: pnpm format
|
||||
|
||||
- name: Check for changes
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add .
|
||||
git commit -m "[automated] Apply ESLint and Prettier fixes"
|
||||
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
|
||||
git push
|
||||
|
||||
- name: Final validation
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
|
||||
})
|
||||
|
||||
- name: Comment on PR about manual fix needed
|
||||
|
||||
5
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -1,9 +1,9 @@
|
||||
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
|
||||
name: "CI: Tests E2E (Deploy for Forks)"
|
||||
name: 'CI: Tests E2E (Deploy for Forks)'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI: Tests E2E"]
|
||||
workflows: ['CI: Tests E2E']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
# Rename merged report if exists
|
||||
[ -d "reports/playwright-report-chromium-merged" ] && \
|
||||
|
||||
18
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -1,5 +1,5 @@
|
||||
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
|
||||
name: "CI: Tests E2E"
|
||||
name: 'CI: Tests E2E'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -144,9 +144,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup pnpm/node to run playwright merge-reports (no browsers needed)
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -158,10 +159,10 @@ jobs:
|
||||
- name: Merge into HTML Report
|
||||
run: |
|
||||
# Generate HTML report
|
||||
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
|
||||
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports
|
||||
# Generate JSON report separately with explicit output path
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
|
||||
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -222,6 +223,7 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
12
.github/workflows/release-biweekly-comfyui.yaml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
- name: Checkout ComfyUI (sparse)
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: comfyanonymous/ComfyUI
|
||||
repository: Comfy-Org/ComfyUI
|
||||
sparse-checkout: |
|
||||
requirements.txt
|
||||
path: comfyui
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
# Note: This only affects the local checkout, NOT the fork's master branch
|
||||
# We only push the automation branch, leaving the fork's master untouched
|
||||
echo "Fetching upstream master..."
|
||||
if ! git fetch https://github.com/comfyanonymous/ComfyUI.git master; then
|
||||
if ! git fetch https://github.com/Comfy-Org/ComfyUI.git master; then
|
||||
echo "Failed to fetch upstream master"
|
||||
exit 1
|
||||
fi
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
# Extract fork owner from repository name
|
||||
FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1)
|
||||
|
||||
echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/ComfyUI"
|
||||
echo "Creating PR from ${COMFYUI_FORK} to Comfy-Org/ComfyUI"
|
||||
|
||||
# Configure git
|
||||
git config user.name "github-actions[bot]"
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
|
||||
# Try to create PR, ignore error if it already exists
|
||||
if ! gh pr create \
|
||||
--repo comfyanonymous/ComfyUI \
|
||||
--repo Comfy-Org/ComfyUI \
|
||||
--head "${FORK_OWNER}:${BRANCH}" \
|
||||
--base master \
|
||||
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
|
||||
# Check if PR already exists
|
||||
set +e
|
||||
EXISTING_PR=$(gh pr list --repo comfyanonymous/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
|
||||
EXISTING_PR=$(gh pr list --repo Comfy-Org/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
|
||||
PR_LIST_EXIT=$?
|
||||
set -e
|
||||
|
||||
@@ -318,7 +318,7 @@ jobs:
|
||||
run: |
|
||||
echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Draft PR created in Comfy-Org/ComfyUI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
|
||||
cat pr-body.txt >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
25
.i18nrc.cjs
@@ -1,15 +1,28 @@
|
||||
// This file is intentionally kept in CommonJS format (.cjs)
|
||||
// to resolve compatibility issues with dependencies that require CommonJS.
|
||||
// Do not convert this file to ESModule format unless all dependencies support it.
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
const { defineConfig } = require('@lobehub/i18n-cli')
|
||||
|
||||
module.exports = defineConfig({
|
||||
modelName: 'gpt-4.1',
|
||||
splitToken: 1024,
|
||||
saveImmediately: true,
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
|
||||
outputLocales: [
|
||||
'zh',
|
||||
'zh-TW',
|
||||
'ru',
|
||||
'ja',
|
||||
'ko',
|
||||
'fr',
|
||||
'es',
|
||||
'ar',
|
||||
'tr',
|
||||
'pt-BR',
|
||||
'fa'
|
||||
],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
@@ -18,5 +31,11 @@ module.exports = defineConfig({
|
||||
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
|
||||
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
|
||||
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
|
||||
|
||||
IMPORTANT Persian Translation Guidelines:
|
||||
- For 'fa' locale: Use formal Persian (فارسی رسمی) for professional tone throughout the UI.
|
||||
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
`
|
||||
});
|
||||
})
|
||||
|
||||
20
.oxfmtrc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80,
|
||||
"ignorePatterns": [
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/*.md",
|
||||
"**/*.json",
|
||||
"**/*.css",
|
||||
"**/*.yaml",
|
||||
"**/*.yml",
|
||||
"**/*.html",
|
||||
"**/*.svg",
|
||||
"**/*.xml"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
packages/registry-types/src/comfyRegistryTypes.ts
|
||||
src/types/generatedManagerTypes.ts
|
||||
11
.prettierrc
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80,
|
||||
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
|
||||
}
|
||||
17
.storybook/AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Storybook Guidelines
|
||||
|
||||
See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`).
|
||||
|
||||
## Available Context
|
||||
|
||||
Stories have access to:
|
||||
- All ComfyUI stores
|
||||
- PrimeVue with ComfyUI theming
|
||||
- i18n system
|
||||
- CSS variables and styling
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Import Errors**: Verify `@/` alias works
|
||||
2. **Missing Styles**: Check CSS imports in `preview.ts`
|
||||
3. **Store Errors**: Check store initialization in setup
|
||||
@@ -1,197 +1,3 @@
|
||||
# Storybook Development Guidelines for Claude
|
||||
|
||||
## Quick Commands
|
||||
|
||||
- `pnpm storybook`: Start Storybook development server
|
||||
- `pnpm build-storybook`: Build static Storybook
|
||||
- `pnpm test:unit`: Run unit tests (includes Storybook components)
|
||||
|
||||
## Development Workflow for Storybook
|
||||
|
||||
1. **Creating New Stories**:
|
||||
- Place `*.stories.ts` files alongside components
|
||||
- Follow the naming pattern: `ComponentName.stories.ts`
|
||||
- Use realistic mock data that matches ComfyUI schemas
|
||||
|
||||
2. **Testing Stories**:
|
||||
- Verify stories render correctly in Storybook UI
|
||||
- Test different component states and edge cases
|
||||
- Ensure proper theming and styling
|
||||
|
||||
3. **Code Quality**:
|
||||
- Run `pnpm typecheck` to verify TypeScript
|
||||
- Run `pnpm lint` to check for linting issues
|
||||
- Follow existing story patterns and conventions
|
||||
|
||||
## Story Creation Guidelines
|
||||
|
||||
### Basic Story Structure
|
||||
|
||||
```typescript
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import ComponentName from './ComponentName.vue'
|
||||
|
||||
const meta: Meta<typeof ComponentName> = {
|
||||
title: 'Category/ComponentName',
|
||||
component: ComponentName,
|
||||
parameters: {
|
||||
layout: 'centered' // or 'fullscreen', 'padded'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// Component props
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mock Data Patterns
|
||||
|
||||
For ComfyUI components, use realistic mock data:
|
||||
|
||||
```typescript
|
||||
// Node definition mock
|
||||
const mockNodeDef = {
|
||||
input: {
|
||||
required: {
|
||||
prompt: ["STRING", { multiline: true }]
|
||||
}
|
||||
},
|
||||
output: ["CONDITIONING"],
|
||||
output_is_list: [false],
|
||||
category: "conditioning"
|
||||
}
|
||||
|
||||
// Component instance mock
|
||||
const mockComponent = {
|
||||
id: "1",
|
||||
type: "CLIPTextEncode",
|
||||
// ... other properties
|
||||
}
|
||||
```
|
||||
|
||||
### Common Story Variants
|
||||
|
||||
Always include these story variants when applicable:
|
||||
|
||||
- **Default**: Basic component with minimal props
|
||||
- **WithData**: Component with realistic data
|
||||
- **Loading**: Component in loading state
|
||||
- **Error**: Component with error state
|
||||
- **LongContent**: Component with edge case content
|
||||
- **Empty**: Component with no data
|
||||
|
||||
### Storybook-Specific Code Patterns
|
||||
|
||||
#### Store Access
|
||||
```typescript
|
||||
// In stories, access stores through the setup function
|
||||
export const WithStore: Story = {
|
||||
render: () => ({
|
||||
setup() {
|
||||
const store = useMyStore()
|
||||
return { store }
|
||||
},
|
||||
template: '<MyComponent :data="store.data" />'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### Event Testing
|
||||
```typescript
|
||||
export const WithEvents: Story = {
|
||||
args: {
|
||||
onUpdate: fn() // Use Storybook's fn() for action logging
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Notes
|
||||
|
||||
### Vue App Setup
|
||||
The Storybook preview is configured with:
|
||||
- Pinia stores initialized
|
||||
- PrimeVue with ComfyUI theme
|
||||
- i18n internationalization
|
||||
- All necessary CSS imports
|
||||
|
||||
### Build Configuration
|
||||
- Vite integration with proper alias resolution
|
||||
- Manual chunking for better performance
|
||||
- TypeScript support with strict checking
|
||||
- CSS processing for Vue components
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Import Errors**: Verify `@/` alias is working correctly
|
||||
2. **Missing Styles**: Ensure CSS imports are in `preview.ts`
|
||||
3. **Store Errors**: Check store initialization in setup
|
||||
4. **Type Errors**: Use proper TypeScript types for story args
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check TypeScript issues
|
||||
pnpm typecheck
|
||||
|
||||
# Lint Storybook files
|
||||
pnpm lint .storybook/
|
||||
|
||||
# Build to check for production issues
|
||||
pnpm build-storybook
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
.storybook/
|
||||
├── main.ts # Core configuration
|
||||
├── preview.ts # Global setup and decorators
|
||||
├── README.md # User documentation
|
||||
└── CLAUDE.md # This file - Claude guidelines
|
||||
|
||||
src/
|
||||
├── components/
|
||||
│ └── MyComponent/
|
||||
│ ├── MyComponent.vue
|
||||
│ └── MyComponent.stories.ts
|
||||
```
|
||||
|
||||
## Integration with ComfyUI
|
||||
|
||||
### Available Context
|
||||
|
||||
Stories have access to:
|
||||
- All ComfyUI stores (widgetStore, colorPaletteStore, etc.)
|
||||
- PrimeVue components with ComfyUI theming
|
||||
- Internationalization system
|
||||
- ComfyUI CSS variables and styling
|
||||
|
||||
### Testing Components
|
||||
|
||||
When testing ComfyUI-specific components:
|
||||
1. Use realistic node definitions and data structures
|
||||
2. Test with different node types (sampling, conditioning, etc.)
|
||||
3. Verify proper CSS theming and dark/light modes
|
||||
4. Check component behavior with various input combinations
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Use manual chunking for large dependencies
|
||||
- Minimize bundle size by avoiding unnecessary imports
|
||||
- Leverage Storybook's lazy loading capabilities
|
||||
- Profile build times and optimize as needed
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep Stories Focused**: Each story should demonstrate one specific use case
|
||||
2. **Use Descriptive Names**: Story names should clearly indicate what they show
|
||||
3. **Document Complex Props**: Use JSDoc comments for complex prop types
|
||||
4. **Test Edge Cases**: Create stories for unusual but valid use cases
|
||||
5. **Maintain Consistency**: Follow established patterns in existing stories
|
||||
<!-- Though standards bloom in open fields so wide,
|
||||
Anthropic walks a path of lonely pride. -->
|
||||
@AGENTS.md
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: ['@storybook/addon-docs'],
|
||||
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {}
|
||||
@@ -69,19 +69,42 @@ const config: StorybookConfig = {
|
||||
allowedHosts: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': process.cwd() + '/src'
|
||||
}
|
||||
},
|
||||
esbuild: {
|
||||
// Prevent minification of identifiers to preserve _sfc_main
|
||||
minifyIdentifiers: false,
|
||||
keepNames: true
|
||||
alias: [
|
||||
{
|
||||
find: '@/composables/queue/useJobList',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/formatUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/networkUtil',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/networkUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@',
|
||||
replacement: process.cwd() + '/src'
|
||||
}
|
||||
]
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
|
||||
rolldownOptions: {
|
||||
experimental: {
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
treeshake: false,
|
||||
output: {
|
||||
keepNames: true
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
if (
|
||||
|
||||
15
.vscode/extensions.json
vendored
@@ -1,25 +1,22 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"antfu.vite",
|
||||
"austenc.tailwind-docs",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"donjayamanne.githistory",
|
||||
"eamodio.gitlens",
|
||||
"esbenp.prettier-vscode",
|
||||
"figma.figma-vscode-extension",
|
||||
"github.vscode-github-actions",
|
||||
"github.vscode-pull-request-github",
|
||||
"hbenl.vscode-test-explorer",
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"lokalise.i18n-ally",
|
||||
"ms-playwright.playwright",
|
||||
"oxc.oxc-vscode",
|
||||
"sonarsource.sonarlint-vscode",
|
||||
"vitest.explorer",
|
||||
"vue.volar",
|
||||
"sonarsource.sonarlint-vscode",
|
||||
"deque-systems.vscode-axe-linter",
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"donjayamanne.githistory",
|
||||
"wix.vscode-import-cost",
|
||||
"prograhammer.tslint-vue",
|
||||
"antfu.vite"
|
||||
"wix.vscode-import-cost"
|
||||
]
|
||||
}
|
||||
|
||||
45
AGENTS.md
@@ -1,5 +1,7 @@
|
||||
# Repository Guidelines
|
||||
|
||||
See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob).
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source: `src/`
|
||||
@@ -25,10 +27,10 @@
|
||||
- Build output: `dist/`
|
||||
- Configs
|
||||
- `vite.config.mts`
|
||||
- `vitest.config.ts`
|
||||
- `playwright.config.ts`
|
||||
- `eslint.config.ts`
|
||||
- `.prettierrc`
|
||||
- `.oxfmtrc.json`
|
||||
- `.oxlintrc.json`
|
||||
- etc.
|
||||
|
||||
## Monorepo Architecture
|
||||
@@ -44,8 +46,23 @@ The project uses **Nx** for build orchestration and task management
|
||||
- `pnpm test:unit`: Run Vitest unit tests
|
||||
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||
- `pnpm format` / `pnpm format:check`: Prettier
|
||||
- `pnpm format` / `pnpm format:check`: oxfmt
|
||||
- `pnpm typecheck`: Vue TSC type checking
|
||||
- `pnpm storybook`: Start Storybook development server
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Make code changes
|
||||
2. Run relevant tests
|
||||
3. Run `pnpm typecheck`, `pnpm lint`, `pnpm format`
|
||||
4. Check if README updates are needed
|
||||
5. Suggest docs.comfy.org updates for user-facing changes
|
||||
|
||||
## Git Conventions
|
||||
|
||||
- Use `prefix:` format: `feat:`, `fix:`, `test:`
|
||||
- Add "Fixes #n" to PR descriptions
|
||||
- Never mention Claude/AI in commits
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
@@ -55,7 +72,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Composition API only
|
||||
- Tailwind 4 styling
|
||||
- Avoid `<style>` blocks
|
||||
- Style: (see `.prettierrc`)
|
||||
- Style: (see `.oxfmtrc.json`)
|
||||
- Indent 2 spaces
|
||||
- single quotes
|
||||
- no trailing semicolons
|
||||
@@ -63,6 +80,9 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Imports:
|
||||
- sorted/grouped by plugin
|
||||
- run `pnpm format` before committing
|
||||
- use separate `import type` statements, not inline `type` in mixed imports
|
||||
- ✅ `import type { Foo } from './foo'` + `import { bar } from './foo'`
|
||||
- ❌ `import { bar, type Foo } from './foo'`
|
||||
- ESLint:
|
||||
- Vue + TS rules
|
||||
- no floating promises
|
||||
@@ -119,7 +139,10 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Prefer reactive props destructuring to `const props = defineProps<...>`
|
||||
- Do not use `withDefaults` or runtime props declaration
|
||||
- Do not import Vue macros unnecessarily
|
||||
- Prefer `useModel` to separately defining a prop and emit
|
||||
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
|
||||
- Define slots via template usage, not `defineSlots`
|
||||
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
|
||||
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
|
||||
- Be judicious with addition of new refs or other state
|
||||
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
|
||||
- If it's possible to use the `ref` or prop directly, don't add a `computed`
|
||||
@@ -137,7 +160,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
8. Implement proper error handling
|
||||
9. Follow Vue 3 style guide and naming conventions
|
||||
10. Use Vite for fast development and building
|
||||
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
|
||||
12. Avoid new usage of PrimeVue components
|
||||
13. Write tests for all changes, especially bug fixes to catch future regressions
|
||||
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
|
||||
@@ -155,6 +178,8 @@ The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
See @docs/testing/*.md for detailed patterns.
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
@@ -268,11 +293,19 @@ When referencing Comfy-Org repos:
|
||||
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
|
||||
- NEVER use `!important` or the `!` important prefix for tailwind classes
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
Rules for agent-based coding tasks.
|
||||
|
||||
### Chrome DevTools MCP
|
||||
|
||||
When using `take_snapshot` to inspect dropdowns, listboxes, or other components with dynamic options:
|
||||
- Use `verbose: true` to see the full accessibility tree including list items
|
||||
- Non-verbose snapshots often omit nested options in comboboxes/listboxes
|
||||
|
||||
### Temporary Files
|
||||
|
||||
- Put planning documents under `/temp/plans/`
|
||||
|
||||
31
CLAUDE.md
@@ -1,30 +1 @@
|
||||
# Claude Code specific instructions
|
||||
|
||||
@Agents.md
|
||||
|
||||
## Repository Setup
|
||||
|
||||
For first-time setup, use the Claude command:
|
||||
|
||||
```sh
|
||||
/setup_repo
|
||||
```
|
||||
|
||||
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
|
||||
|
||||
**Prerequisites:** Node.js >= 24, Git repository, available ports for dev server, storybook, etc.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **First-time setup**: Run `/setup_repo` Claude command
|
||||
2. Make code changes
|
||||
3. Run tests (see subdirectory CLAUDE.md files)
|
||||
4. Run typecheck, lint, format
|
||||
5. Check README updates
|
||||
6. Consider docs.comfy.org updates
|
||||
|
||||
## Git Conventions
|
||||
|
||||
- Use `prefix:` format: `feat:`, `fix:`, `test:`
|
||||
- Add "Fixes #n" to PR descriptions
|
||||
- Never mention Claude/AI in commits
|
||||
@AGENTS.md
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
@@ -55,8 +55,7 @@
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
|
||||
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
1
THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1 @@
|
||||
AMD and the AMD Arrow logo are trademarks of Advanced Micro Devices, Inc.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/desktop-ui",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"nx": {
|
||||
"tags": [
|
||||
|
||||
BIN
apps/desktop-ui/public/assets/images/amd-rocm-logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
84
apps/desktop-ui/src/components/install/GpuPicker.stories.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import type {
|
||||
ElectronAPI,
|
||||
TorchDeviceType
|
||||
} from '@comfyorg/comfyui-electron-types'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import GpuPicker from './GpuPicker.vue'
|
||||
|
||||
type Platform = ReturnType<ElectronAPI['getPlatform']>
|
||||
type ElectronAPIStub = Pick<ElectronAPI, 'getPlatform'>
|
||||
type WindowWithElectron = Window & { electronAPI?: ElectronAPIStub }
|
||||
|
||||
const meta: Meta<typeof GpuPicker> = {
|
||||
title: 'Desktop/Components/GpuPicker',
|
||||
component: GpuPicker,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function createElectronDecorator(platform: Platform) {
|
||||
function getPlatform() {
|
||||
return platform
|
||||
}
|
||||
|
||||
return function ElectronDecorator() {
|
||||
const windowWithElectron = window as WindowWithElectron
|
||||
windowWithElectron.electronAPI = { getPlatform }
|
||||
return { template: '<story />' }
|
||||
}
|
||||
}
|
||||
|
||||
function renderWithDevice(device: TorchDeviceType | null) {
|
||||
return function Render() {
|
||||
return {
|
||||
components: { GpuPicker },
|
||||
setup() {
|
||||
const selected = ref<TorchDeviceType | null>(device)
|
||||
return { selected }
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8">
|
||||
<GpuPicker v-model:device="selected" />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const windowsDecorator = createElectronDecorator('win32')
|
||||
const macDecorator = createElectronDecorator('darwin')
|
||||
|
||||
export const WindowsNvidiaSelected: Story = {
|
||||
decorators: [windowsDecorator],
|
||||
render: renderWithDevice('nvidia')
|
||||
}
|
||||
|
||||
export const WindowsAmdSelected: Story = {
|
||||
decorators: [windowsDecorator],
|
||||
render: renderWithDevice('amd')
|
||||
}
|
||||
|
||||
export const WindowsCpuSelected: Story = {
|
||||
decorators: [windowsDecorator],
|
||||
render: renderWithDevice('cpu')
|
||||
}
|
||||
|
||||
export const MacMpsSelected: Story = {
|
||||
decorators: [macDecorator],
|
||||
render: renderWithDevice('mps')
|
||||
}
|
||||
@@ -11,29 +11,32 @@
|
||||
<!-- Apple Metal / NVIDIA -->
|
||||
<HardwareOption
|
||||
v-if="platform === 'darwin'"
|
||||
:image-path="'./assets/images/apple-mps-logo.png'"
|
||||
image-path="./assets/images/apple-mps-logo.png"
|
||||
placeholder-text="Apple Metal"
|
||||
subtitle="Apple Metal"
|
||||
:value="'mps'"
|
||||
:selected="selected === 'mps'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('mps')"
|
||||
/>
|
||||
<HardwareOption
|
||||
v-else
|
||||
:image-path="'./assets/images/nvidia-logo-square.jpg'"
|
||||
placeholder-text="NVIDIA"
|
||||
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
|
||||
:value="'nvidia'"
|
||||
:selected="selected === 'nvidia'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('nvidia')"
|
||||
/>
|
||||
<template v-else>
|
||||
<HardwareOption
|
||||
image-path="./assets/images/nvidia-logo-square.jpg"
|
||||
placeholder-text="NVIDIA"
|
||||
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
|
||||
:selected="selected === 'nvidia'"
|
||||
@click="pickGpu('nvidia')"
|
||||
/>
|
||||
<HardwareOption
|
||||
image-path="./assets/images/amd-rocm-logo.png"
|
||||
placeholder-text="AMD"
|
||||
:subtitle="$t('install.gpuPicker.amdSubtitle')"
|
||||
:selected="selected === 'amd'"
|
||||
@click="pickGpu('amd')"
|
||||
/>
|
||||
</template>
|
||||
<!-- CPU -->
|
||||
<HardwareOption
|
||||
placeholder-text="CPU"
|
||||
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
|
||||
:value="'cpu'"
|
||||
:selected="selected === 'cpu'"
|
||||
@click="pickGpu('cpu')"
|
||||
/>
|
||||
@@ -41,7 +44,6 @@
|
||||
<HardwareOption
|
||||
placeholder-text="Manual Install"
|
||||
:subtitle="$t('install.gpuPicker.manualSubtitle')"
|
||||
:value="'unsupported'"
|
||||
:selected="selected === 'unsupported'"
|
||||
@click="pickGpu('unsupported')"
|
||||
/>
|
||||
@@ -81,13 +83,15 @@ const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
const electron = electronAPI()
|
||||
const platform = electron.getPlatform()
|
||||
|
||||
const showRecommendedBadge = computed(
|
||||
() => selected.value === 'mps' || selected.value === 'nvidia'
|
||||
const recommendedDevices: TorchDeviceType[] = ['mps', 'nvidia', 'amd']
|
||||
const showRecommendedBadge = computed(() =>
|
||||
selected.value ? recommendedDevices.includes(selected.value) : false
|
||||
)
|
||||
|
||||
const descriptionKeys = {
|
||||
mps: 'appleMetal',
|
||||
nvidia: 'nvidia',
|
||||
amd: 'amd',
|
||||
cpu: 'cpu',
|
||||
unsupported: 'manual'
|
||||
} as const
|
||||
@@ -97,7 +101,7 @@ const descriptionText = computed(() => {
|
||||
return st(`install.gpuPicker.${key}Description`, '')
|
||||
})
|
||||
|
||||
const pickGpu = (value: TorchDeviceType) => {
|
||||
function pickGpu(value: TorchDeviceType) {
|
||||
selected.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,7 +29,6 @@ export const AppleMetalSelected: Story = {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
@@ -39,7 +38,6 @@ export const AppleMetalUnselected: Story = {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -48,7 +46,6 @@ export const CPUOption: Story = {
|
||||
args: {
|
||||
placeholderText: 'CPU',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'cpu',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -57,7 +54,6 @@ export const ManualInstall: Story = {
|
||||
args: {
|
||||
placeholderText: 'Manual Install',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'unsupported',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -67,7 +63,6 @@ export const NvidiaSelected: Story = {
|
||||
imagePath: '/assets/images/nvidia-logo-square.jpg',
|
||||
placeholderText: 'NVIDIA',
|
||||
subtitle: 'NVIDIA',
|
||||
value: 'nvidia',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface Props {
|
||||
imagePath?: string
|
||||
placeholderText: string
|
||||
subtitle?: string
|
||||
value: TorchDeviceType
|
||||
selected?: boolean
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { isInChina } from '@comfyorg/shared-frontend-utils/networkUtil'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
@@ -155,7 +155,7 @@ const activeAccordionIndex = ref<string[] | undefined>(undefined)
|
||||
const electron = electronAPI()
|
||||
|
||||
// Mirror configuration logic
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
@@ -170,6 +170,7 @@ const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'amd':
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -63,7 +63,6 @@ const taskStore = useMaintenanceTaskStore()
|
||||
defineProps<{
|
||||
displayAsList: string
|
||||
filter: MaintenanceFilter
|
||||
isRefreshing: boolean
|
||||
}>()
|
||||
|
||||
const executeTask = async (task: MaintenanceTask) => {
|
||||
|
||||
@@ -143,6 +143,8 @@ const goToPreviousStep = () => {
|
||||
const electron = electronAPI()
|
||||
const router = useRouter()
|
||||
const install = async () => {
|
||||
if (!device.value) return
|
||||
|
||||
const options: InstallOptions = {
|
||||
installPath: installPath.value,
|
||||
autoUpdate: autoUpdate.value,
|
||||
@@ -152,7 +154,6 @@ const install = async () => {
|
||||
pythonMirror: pythonMirror.value,
|
||||
pypiMirror: pypiMirror.value,
|
||||
torchMirror: torchMirror.value,
|
||||
// @ts-expect-error fixme ts strict error
|
||||
device: device.value
|
||||
}
|
||||
electron.installComfyUI(options)
|
||||
@@ -166,7 +167,11 @@ onMounted(async () => {
|
||||
if (!electron) return
|
||||
|
||||
const detectedGpu = await electron.Config.getDetectedGpu()
|
||||
if (detectedGpu === 'mps' || detectedGpu === 'nvidia') {
|
||||
if (
|
||||
detectedGpu === 'mps' ||
|
||||
detectedGpu === 'nvidia' ||
|
||||
detectedGpu === 'amd'
|
||||
) {
|
||||
device.value = detectedGpu
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
||||
:filter
|
||||
:display-as-list
|
||||
:is-refreshing
|
||||
/>
|
||||
|
||||
<!-- Actions -->
|
||||
|
||||
@@ -64,7 +64,7 @@ export default defineConfig(() => {
|
||||
})
|
||||
],
|
||||
build: {
|
||||
minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
|
||||
minify: SHOULD_MINIFY,
|
||||
target: 'es2022',
|
||||
sourcemap: true
|
||||
}
|
||||
|
||||
8
browser_tests/AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# E2E Testing Guidelines
|
||||
|
||||
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `assets/` - Test data (JSON workflows, fixtures)
|
||||
- Tests use premade JSON workflows to load desired graph state
|
||||
@@ -1,17 +1,3 @@
|
||||
# E2E Testing Guidelines
|
||||
|
||||
## Browser Tests
|
||||
- Test user workflows
|
||||
- Use Playwright fixtures
|
||||
- Follow naming conventions
|
||||
|
||||
## Best Practices
|
||||
- Check assets/ for test data
|
||||
- Prefer specific selectors
|
||||
- Test across viewports
|
||||
|
||||
## Testing Process
|
||||
After code changes:
|
||||
1. Create browser tests as appropriate
|
||||
2. Run tests until passing
|
||||
3. Then run typecheck, lint, format
|
||||
<!-- In gardens where the agents freely play,
|
||||
One stubborn flower turns the other way. -->
|
||||
@AGENTS.md
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test as base, expect } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import * as fs from 'fs'
|
||||
|
||||
import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
|
||||
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { Topbar } from './components/Topbar'
|
||||
import type { Position, Size } from './types'
|
||||
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
|
||||
import TaskHistory from './utils/taskHistory'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
@@ -146,8 +145,6 @@ class ConfirmDialog {
|
||||
}
|
||||
|
||||
export class ComfyPage {
|
||||
private _history: TaskHistory | null = null
|
||||
|
||||
public readonly url: string
|
||||
// All canvas position operations are based on default view of canvas.
|
||||
public readonly canvas: Locator
|
||||
@@ -301,11 +298,6 @@ export class ComfyPage {
|
||||
}
|
||||
}
|
||||
|
||||
setupHistory(): TaskHistory {
|
||||
this._history ??= new TaskHistory(this)
|
||||
return this._history
|
||||
}
|
||||
|
||||
async setup({
|
||||
clearStorage = true,
|
||||
mockReleases = true
|
||||
@@ -1591,14 +1583,29 @@ export class ComfyPage {
|
||||
return window['app'].graph.nodes
|
||||
})
|
||||
}
|
||||
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
|
||||
async waitForGraphNodes(count: number) {
|
||||
await this.page.waitForFunction((count) => {
|
||||
return window['app']?.canvas.graph?.nodes?.length === count
|
||||
}, count)
|
||||
}
|
||||
async getNodeRefsByType(
|
||||
type: string,
|
||||
includeSubgraph: boolean = false
|
||||
): Promise<NodeReference[]> {
|
||||
return Promise.all(
|
||||
(
|
||||
await this.page.evaluate((type) => {
|
||||
return window['app'].graph.nodes
|
||||
.filter((n: LGraphNode) => n.type === type)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
}, type)
|
||||
await this.page.evaluate(
|
||||
({ type, includeSubgraph }) => {
|
||||
const graph = (
|
||||
includeSubgraph ? window['app'].canvas.graph : window['app'].graph
|
||||
) as LGraph
|
||||
const nodes = graph.nodes
|
||||
return nodes
|
||||
.filter((n: LGraphNode) => n.type === type)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
},
|
||||
{ type, includeSubgraph }
|
||||
)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,8 +159,18 @@ export class VueNodeHelpers {
|
||||
getInputNumberControls(widget: Locator) {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
incrementButton: widget.locator('button').first(),
|
||||
decrementButton: widget.locator('button').nth(1)
|
||||
decrementButton: widget.getByTestId('decrement'),
|
||||
incrementButton: widget.getByTestId('increment')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId('subgraph-enter-button')
|
||||
await editButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,48 +79,15 @@ export class SubgraphSlotReference {
|
||||
|
||||
const node =
|
||||
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
|
||||
const slots =
|
||||
type === 'input' ? currentGraph.inputs : currentGraph.outputs
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`No ${type} node found in subgraph`)
|
||||
}
|
||||
|
||||
// Calculate position for next available slot
|
||||
// const nextSlotIndex = slots?.length || 0
|
||||
// const slotHeight = 20
|
||||
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
|
||||
|
||||
// Find last slot position
|
||||
const lastSlot = slots.at(-1)
|
||||
let slotX: number
|
||||
let slotY: number
|
||||
|
||||
if (lastSlot) {
|
||||
// If there are existing slots, position the new one below the last one
|
||||
const gapHeight = 20
|
||||
slotX = lastSlot.pos[0]
|
||||
slotY = lastSlot.pos[1] + gapHeight
|
||||
} else {
|
||||
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
|
||||
if (currentGraph.slotAnchorX !== undefined) {
|
||||
// The actual slot X position seems to be slotAnchorX - 10
|
||||
slotX = currentGraph.slotAnchorX - 10
|
||||
} else {
|
||||
// Fallback: calculate from node edge
|
||||
slotX =
|
||||
type === 'input'
|
||||
? node.pos[0] + node.size[0] - 10 // Right edge for input node
|
||||
: node.pos[0] + 10 // Left edge for output node
|
||||
}
|
||||
// For Y position when no slots exist, use middle of node
|
||||
slotY = node.pos[1] + node.size[1] / 2
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
slotX,
|
||||
slotY
|
||||
node.emptySlot.pos[0],
|
||||
node.emptySlot.pos[1]
|
||||
])
|
||||
return canvasPos
|
||||
},
|
||||
@@ -152,8 +119,7 @@ class NodeSlotReference {
|
||||
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||
|
||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
console.warn(
|
||||
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
||||
{
|
||||
nodePos: [node.pos[0], node.pos[1]],
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import type { Request, Route } from '@playwright/test'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import type {
|
||||
HistoryTaskItem,
|
||||
TaskItem,
|
||||
TaskOutput
|
||||
} from '../../../src/schemas/apiSchema'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
/** keyof TaskOutput[string] */
|
||||
type OutputFileType = 'images' | 'audio' | 'animated'
|
||||
|
||||
const DEFAULT_IMAGE = 'example.webp'
|
||||
|
||||
const getFilenameParam = (request: Request) => {
|
||||
const url = new URL(request.url())
|
||||
return url.searchParams.get('filename') || DEFAULT_IMAGE
|
||||
}
|
||||
|
||||
const getContentType = (filename: string, fileType: OutputFileType) => {
|
||||
const subtype = path.extname(filename).slice(1)
|
||||
switch (fileType) {
|
||||
case 'images':
|
||||
return `image/${subtype}`
|
||||
case 'audio':
|
||||
return `audio/${subtype}`
|
||||
case 'animated':
|
||||
return `video/${subtype}`
|
||||
}
|
||||
}
|
||||
|
||||
const setQueueIndex = (task: TaskItem) => {
|
||||
task.prompt[0] = TaskHistory.queueIndex++
|
||||
}
|
||||
|
||||
const setPromptId = (task: TaskItem) => {
|
||||
task.prompt[1] = uuidv4()
|
||||
}
|
||||
|
||||
export default class TaskHistory {
|
||||
static queueIndex = 0
|
||||
static readonly defaultTask: Readonly<HistoryTaskItem> = {
|
||||
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
},
|
||||
taskType: 'History'
|
||||
}
|
||||
private tasks: HistoryTaskItem[] = []
|
||||
private outputContentTypes: Map<string, string> = new Map()
|
||||
|
||||
constructor(readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private loadAsset: (filename: string) => Buffer = _.memoize(
|
||||
(filename: string) => {
|
||||
const filePath = this.comfyPage.assetPath(filename)
|
||||
return fs.readFileSync(filePath)
|
||||
}
|
||||
)
|
||||
|
||||
private async handleGetHistory(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.tasks)
|
||||
})
|
||||
}
|
||||
|
||||
private async handleGetView(route: Route) {
|
||||
const fileName = getFilenameParam(route.request())
|
||||
if (!this.outputContentTypes.has(fileName)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
const asset = this.loadAsset(fileName)
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: this.outputContentTypes.get(fileName),
|
||||
body: asset,
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Content-Length': asset.byteLength.toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async setupRoutes() {
|
||||
return this.comfyPage.page.route(
|
||||
/.*\/api\/(view|history)(\?.*)?$/,
|
||||
async (route) => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
|
||||
const isViewReq = request.url().includes('view') && method === 'GET'
|
||||
if (isViewReq) return this.handleGetView(route)
|
||||
|
||||
const isHistoryPath = request.url().includes('history')
|
||||
const isGetHistoryReq = isHistoryPath && method === 'GET'
|
||||
if (isGetHistoryReq) return this.handleGetHistory(route)
|
||||
|
||||
const isClearReq =
|
||||
method === 'POST' &&
|
||||
isHistoryPath &&
|
||||
request.postDataJSON()?.clear === true
|
||||
if (isClearReq) return this.clearTasks()
|
||||
|
||||
return route.continue()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private createOutputs(
|
||||
filenames: string[],
|
||||
filetype: OutputFileType
|
||||
): TaskOutput {
|
||||
return filenames.reduce((outputs, filename, i) => {
|
||||
const nodeId = `${i + 1}`
|
||||
outputs[nodeId] = {
|
||||
[filetype]: [{ filename, subfolder: '', type: 'output' }]
|
||||
}
|
||||
const contentType = getContentType(filename, filetype)
|
||||
this.outputContentTypes.set(filename, contentType)
|
||||
return outputs
|
||||
}, {})
|
||||
}
|
||||
|
||||
private addTask(task: HistoryTaskItem) {
|
||||
setPromptId(task)
|
||||
setQueueIndex(task)
|
||||
this.tasks.unshift(task) // Tasks are added to the front of the queue
|
||||
}
|
||||
|
||||
clearTasks(): this {
|
||||
this.tasks = []
|
||||
return this
|
||||
}
|
||||
|
||||
withTask(
|
||||
outputFilenames: string[],
|
||||
outputFiletype: OutputFileType = 'images',
|
||||
overrides: Partial<HistoryTaskItem> = {}
|
||||
): this {
|
||||
this.addTask({
|
||||
...TaskHistory.defaultTask,
|
||||
outputs: this.createOutputs(outputFilenames, outputFiletype),
|
||||
...overrides
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/** Repeats the last task in the task history a specified number of times. */
|
||||
repeat(n: number): this {
|
||||
for (let i = 0; i < n; i++)
|
||||
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ test.describe('Feature Flags', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.type === 'feature_flags') {
|
||||
window.__capturedMessages.clientFeatureFlags = parsed
|
||||
window.__capturedMessages!.clientFeatureFlags = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, ignore
|
||||
@@ -41,7 +41,7 @@ test.describe('Feature Flags', () => {
|
||||
window['app']?.api?.serverFeatureFlags &&
|
||||
Object.keys(window['app'].api.serverFeatureFlags).length > 0
|
||||
) {
|
||||
window.__capturedMessages.serverFeatureFlags =
|
||||
window.__capturedMessages!.serverFeatureFlags =
|
||||
window['app'].api.serverFeatureFlags
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
@@ -57,8 +57,8 @@ test.describe('Feature Flags', () => {
|
||||
// Wait for both client and server feature flags
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.__capturedMessages.clientFeatureFlags !== null &&
|
||||
window.__capturedMessages.serverFeatureFlags !== null,
|
||||
window.__capturedMessages!.clientFeatureFlags !== null &&
|
||||
window.__capturedMessages!.serverFeatureFlags !== null,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
@@ -66,27 +66,27 @@ test.describe('Feature Flags', () => {
|
||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
||||
|
||||
// Verify client sent feature flags
|
||||
expect(messages.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages.clientFeatureFlags).toHaveProperty('data')
|
||||
expect(messages.clientFeatureFlags.data).toHaveProperty(
|
||||
expect(messages!.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('data')
|
||||
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(
|
||||
typeof messages.clientFeatureFlags.data.supports_preview_metadata
|
||||
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
|
||||
).toBe('boolean')
|
||||
|
||||
// Verify server sent feature flags back
|
||||
expect(messages.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages.serverFeatureFlags).toHaveProperty(
|
||||
expect(messages!.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
|
||||
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverFeatureFlags
|
||||
return window['app']!.api.serverFeatureFlags
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
@@ -115,7 +115,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test serverSupportsFeature with real backend flags
|
||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverSupportsFeature(
|
||||
return window['app']!.api.serverSupportsFeature(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
})
|
||||
@@ -124,15 +124,17 @@ test.describe('Feature Flags', () => {
|
||||
|
||||
// Test non-existent feature - should always return false
|
||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
|
||||
return window['app']!.api.serverSupportsFeature(
|
||||
'non_existent_feature_xyz'
|
||||
)
|
||||
})
|
||||
expect(supportsNonExistent).toBe(false)
|
||||
|
||||
// Test that the method only returns true for boolean true values
|
||||
const testResults = await comfyPage.page.evaluate(() => {
|
||||
// Temporarily modify serverFeatureFlags to test behavior
|
||||
const original = window['app'].api.serverFeatureFlags
|
||||
window['app'].api.serverFeatureFlags = {
|
||||
const original = window['app']!.api.serverFeatureFlags
|
||||
window['app']!.api.serverFeatureFlags = {
|
||||
bool_true: true,
|
||||
bool_false: false,
|
||||
string_value: 'yes',
|
||||
@@ -141,15 +143,15 @@ test.describe('Feature Flags', () => {
|
||||
}
|
||||
|
||||
const results = {
|
||||
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
|
||||
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
|
||||
string_value: window['app'].api.serverSupportsFeature('string_value'),
|
||||
number_value: window['app'].api.serverSupportsFeature('number_value'),
|
||||
null_value: window['app'].api.serverSupportsFeature('null_value')
|
||||
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
|
||||
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
|
||||
string_value: window['app']!.api.serverSupportsFeature('string_value'),
|
||||
number_value: window['app']!.api.serverSupportsFeature('number_value'),
|
||||
null_value: window['app']!.api.serverSupportsFeature('null_value')
|
||||
}
|
||||
|
||||
// Restore original
|
||||
window['app'].api.serverFeatureFlags = original
|
||||
window['app']!.api.serverFeatureFlags = original
|
||||
return results
|
||||
})
|
||||
|
||||
@@ -166,20 +168,20 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test getServerFeature method
|
||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature('supports_preview_metadata')
|
||||
return window['app']!.api.getServerFeature('supports_preview_metadata')
|
||||
})
|
||||
expect(typeof previewMetadataValue).toBe('boolean')
|
||||
|
||||
// Test getting max_upload_size
|
||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature('max_upload_size')
|
||||
return window['app']!.api.getServerFeature('max_upload_size')
|
||||
})
|
||||
expect(typeof maxUploadSize).toBe('number')
|
||||
expect(maxUploadSize).toBeGreaterThan(0)
|
||||
|
||||
// Test getServerFeature with default value for non-existent feature
|
||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature(
|
||||
return window['app']!.api.getServerFeature(
|
||||
'non_existent_feature_xyz',
|
||||
'default'
|
||||
)
|
||||
@@ -192,7 +194,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test getServerFeatures returns all flags
|
||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeatures()
|
||||
return window['app']!.api.getServerFeatures()
|
||||
})
|
||||
|
||||
expect(allFeatures).toBeTruthy()
|
||||
@@ -205,14 +207,14 @@ test.describe('Feature Flags', () => {
|
||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||
// Test that getClientFeatureFlags returns a copy
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
const flags1 = window['app'].api.getClientFeatureFlags()
|
||||
const flags2 = window['app'].api.getClientFeatureFlags()
|
||||
const flags1 = window['app']!.api.getClientFeatureFlags()
|
||||
const flags2 = window['app']!.api.getClientFeatureFlags()
|
||||
|
||||
// Modify the first object
|
||||
flags1.test_modification = true
|
||||
|
||||
// Get flags again to check if original was modified
|
||||
const flags3 = window['app'].api.getClientFeatureFlags()
|
||||
const flags3 = window['app']!.api.getClientFeatureFlags()
|
||||
|
||||
return {
|
||||
areEqual: flags1 === flags2,
|
||||
@@ -238,14 +240,14 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
// Get a copy of server features
|
||||
const features1 = window['app'].api.getServerFeatures()
|
||||
const features1 = window['app']!.api.getServerFeatures()
|
||||
|
||||
// Try to modify it
|
||||
features1.supports_preview_metadata = false
|
||||
features1.new_feature = 'added'
|
||||
|
||||
// Get another copy
|
||||
const features2 = window['app'].api.getServerFeatures()
|
||||
const features2 = window['app']!.api.getServerFeatures()
|
||||
|
||||
return {
|
||||
modifiedValue: features1.supports_preview_metadata,
|
||||
@@ -274,7 +276,8 @@ test.describe('Feature Flags', () => {
|
||||
// Set up monitoring before navigation
|
||||
await newPage.addInitScript(() => {
|
||||
// Track when various app components are ready
|
||||
;(window as any).__appReadiness = {
|
||||
|
||||
window.__appReadiness = {
|
||||
featureFlagsReceived: false,
|
||||
apiInitialized: false,
|
||||
appInitialized: false
|
||||
@@ -286,7 +289,10 @@ test.describe('Feature Flags', () => {
|
||||
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined
|
||||
) {
|
||||
;(window as any).__appReadiness.featureFlagsReceived = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
featureFlagsReceived: true
|
||||
}
|
||||
clearInterval(checkFeatureFlags)
|
||||
}
|
||||
}, 10)
|
||||
@@ -294,7 +300,10 @@ test.describe('Feature Flags', () => {
|
||||
// Monitor API initialization
|
||||
const checkApi = setInterval(() => {
|
||||
if (window['app']?.api) {
|
||||
;(window as any).__appReadiness.apiInitialized = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
apiInitialized: true
|
||||
}
|
||||
clearInterval(checkApi)
|
||||
}
|
||||
}, 10)
|
||||
@@ -302,7 +311,10 @@ test.describe('Feature Flags', () => {
|
||||
// Monitor app initialization
|
||||
const checkApp = setInterval(() => {
|
||||
if (window['app']?.graph) {
|
||||
;(window as any).__appReadiness.appInitialized = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
appInitialized: true
|
||||
}
|
||||
clearInterval(checkApp)
|
||||
}
|
||||
}, 10)
|
||||
@@ -331,8 +343,8 @@ test.describe('Feature Flags', () => {
|
||||
// Get readiness state
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...(window as any).__appReadiness,
|
||||
currentFlags: window['app'].api.serverFeatureFlags
|
||||
...window.__appReadiness,
|
||||
currentFlags: window['app']!.api.serverFeatureFlags
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ test.describe('Graph', () => {
|
||||
})
|
||||
|
||||
test('Validate workflow links', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Validation.Workflows', true)
|
||||
await comfyPage.loadWorkflow('links/bad_link')
|
||||
await expect(comfyPage.getVisibleToastCount()).resolves.toBe(2)
|
||||
})
|
||||
|
||||
@@ -133,8 +133,11 @@ test.describe('Menu', () => {
|
||||
// Checkmark should be invisible again (panel is hidden)
|
||||
await expect(checkmark).toHaveClass(/invisible/)
|
||||
|
||||
// Click outside to close menu
|
||||
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
|
||||
// Click in top-right corner to close menu (avoid hamburger menu at top-left)
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
await comfyPage.page
|
||||
.locator('body')
|
||||
.click({ position: { x: viewport.width - 10, y: 10 } })
|
||||
|
||||
// Verify menu is now closed
|
||||
await expect(menu).not.toBeVisible()
|
||||
|
||||
@@ -22,8 +22,14 @@ test.describe('Mobile Baseline Snapshots', () => {
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png'
|
||||
'mobile-settings-dialog.png',
|
||||
{
|
||||
mask: [
|
||||
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 18 KiB |
@@ -2,15 +2,17 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
// Helper function to pan canvas and select node
|
||||
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
|
||||
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const app = window['app']
|
||||
const app = window['app']!
|
||||
const canvas = app.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
||||
@@ -345,7 +347,7 @@ This is documentation for a custom node.
|
||||
|
||||
// Find and select a custom/group node
|
||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.nodes.map((n: any) => n.id)
|
||||
return window['app']!.graph!.nodes.map((n) => n.id)
|
||||
})
|
||||
if (nodeRefs.length > 0) {
|
||||
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
@@ -8,13 +8,11 @@ test.describe('Properties panel', () => {
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText(
|
||||
'No node(s) selected'
|
||||
)
|
||||
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
|
||||
|
||||
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 nodes selected')
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
|
||||
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -15,7 +16,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = async (comfyPage: any) => {
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
|
||||
@@ -82,9 +82,7 @@ test.describe('Templates', () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.locator(
|
||||
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
|
||||
)
|
||||
.getByRole('button', { name: 'Getting Started' })
|
||||
.click()
|
||||
await comfyPage.templates.loadTemplate('default')
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
@@ -189,9 +187,7 @@ test.describe('Templates', () => {
|
||||
const templateGrid = comfyPage.page.locator(
|
||||
'[data-testid="template-workflows-content"]'
|
||||
)
|
||||
const nav = comfyPage.page
|
||||
.locator('header')
|
||||
.filter({ hasText: 'Templates' })
|
||||
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
|
||||
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
@@ -201,7 +197,8 @@ test.describe('Templates', () => {
|
||||
await comfyPage.page.setViewportSize(mobileSize)
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
|
||||
// Nav header is clipped by overflow-hidden parent at mobile size
|
||||
await expect(nav).not.toBeInViewport()
|
||||
|
||||
const tabletSize = { width: 1024, height: 800 }
|
||||
await comfyPage.page.setViewportSize(tabletSize)
|
||||
|
||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 27 KiB |
@@ -102,7 +102,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
// await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
@@ -419,7 +419,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
@@ -505,7 +505,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
@@ -993,4 +993,51 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
expect(linked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('Dragging from subgraph input connects to correct slot', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
// Setup workflow with a KSampler node
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.waitForGraphNodes(0)
|
||||
await comfyPage.executeCommand('Workspace.SearchBox.Toggle')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
await comfyPage.waitForGraphNodes(1)
|
||||
|
||||
// Convert the KSampler node to a subgraph
|
||||
let ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler'))?.[0]
|
||||
await comfyPage.vueNodes.selectNode(String(ksamplerNode.id))
|
||||
await comfyPage.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
|
||||
// Enter the subgraph
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Get the KSampler node inside the subgraph
|
||||
ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler', true))?.[0]
|
||||
const positiveInput = await ksamplerNode.getInput(1)
|
||||
const negativeInput = await ksamplerNode.getInput(2)
|
||||
|
||||
const positiveInputPos = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
ksamplerNode.id,
|
||||
1,
|
||||
true
|
||||
)
|
||||
|
||||
const sourceSlot = await comfyPage.getSubgraphInputSlot()
|
||||
const calculatedSourcePos = await sourceSlot.getOpenSlotPosition()
|
||||
|
||||
await comfyMouse.move(calculatedSourcePos)
|
||||
await comfyMouse.drag(positiveInputPos)
|
||||
await comfyMouse.drop()
|
||||
|
||||
// Verify connection went to the correct slot
|
||||
const positiveLinks = await positiveInput.getLinkCount()
|
||||
const negativeLinks = await negativeInput.getLinkCount()
|
||||
expect(positiveLinks).toBe(1)
|
||||
expect(negativeLinks).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -194,7 +194,10 @@ test.describe('Image widget', () => {
|
||||
const comboEntry = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'image32x32.webp'
|
||||
})
|
||||
await comboEntry.click({ noWaitAfter: true })
|
||||
await comboEntry.click()
|
||||
|
||||
// Stabilization for the image swap
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 46 KiB |
@@ -76,6 +76,7 @@ function getModuleName(id: string): string {
|
||||
export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
return {
|
||||
name: 'comfy-api-plugin',
|
||||
apply: 'build',
|
||||
transform(code: string, id: string) {
|
||||
if (isDev) return null
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
66
docs/TEMPLATE_RANKING.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Template Ranking System
|
||||
|
||||
Usage-based ordering for workflow templates with position bias normalization.
|
||||
|
||||
Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search).
|
||||
|
||||
## Sort Modes
|
||||
|
||||
| Mode | Formula | Description |
|
||||
| -------------- | ------------------------------------------------ | ---------------------- |
|
||||
| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation |
|
||||
| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven |
|
||||
| `newest` | Date sort | Existing |
|
||||
| `alphabetical` | Name sort | Existing |
|
||||
|
||||
Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1.
|
||||
|
||||
## Data Files
|
||||
|
||||
**Usage scores** (generated from Mixpanel):
|
||||
|
||||
```json
|
||||
// In templates/index.json, add to any template:
|
||||
{
|
||||
"name": "some_template",
|
||||
"usage": 1000,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Search rank** (set per-template in workflow_templates repo):
|
||||
|
||||
```json
|
||||
// In templates/index.json, add to any template:
|
||||
{
|
||||
"name": "some_template",
|
||||
"searchRank": 8, // Scale 1-10, default 5
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
| searchRank | Effect |
|
||||
| ---------- | ---------------------------- |
|
||||
| 1-4 | Demote (bury in results) |
|
||||
| 5 | Neutral (default if not set) |
|
||||
| 6-10 | Promote (boost in results) |
|
||||
|
||||
## Position Bias Correction
|
||||
|
||||
Raw usage reflects true preference AND UI position bias. We use linear interpolation:
|
||||
|
||||
```
|
||||
correction = 1 + (position - 1) / (maxPosition - 1)
|
||||
normalizedUsage = rawUsage × correction
|
||||
```
|
||||
|
||||
| Position | Boost |
|
||||
| -------- | ----- |
|
||||
| 1 | 1.0× |
|
||||
| 50 | 1.28× |
|
||||
| 100 | 1.57× |
|
||||
| 175 | 2.0× |
|
||||
|
||||
Templates buried at the bottom get up to 2× boost to compensate for reduced visibility.
|
||||
|
||||
---
|
||||
33
docs/guidance/playwright.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.spec.ts'
|
||||
---
|
||||
|
||||
# Playwright E2E Test Conventions
|
||||
|
||||
See `docs/testing/*.md` for detailed patterns.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
|
||||
- Prefer specific selectors (role, label, test-id)
|
||||
- Test across viewports
|
||||
|
||||
## Test Tags
|
||||
|
||||
Tags are respected by config:
|
||||
- `@mobile` - Mobile viewport tests
|
||||
- `@2x` - High DPI tests
|
||||
|
||||
## Test Data
|
||||
|
||||
- Check `browser_tests/assets/` for test data and fixtures
|
||||
- Use realistic ComfyUI workflows for E2E tests
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
pnpm test:browser # Run all E2E tests
|
||||
pnpm test:browser -- --ui # Interactive UI mode
|
||||
```
|
||||
55
docs/guidance/storybook.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.stories.ts'
|
||||
---
|
||||
|
||||
# Storybook Conventions
|
||||
|
||||
## File Placement
|
||||
|
||||
Place `*.stories.ts` files alongside their components:
|
||||
```
|
||||
src/components/MyComponent/
|
||||
├── MyComponent.vue
|
||||
└── MyComponent.stories.ts
|
||||
```
|
||||
|
||||
## Story Structure
|
||||
|
||||
```typescript
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import ComponentName from './ComponentName.vue'
|
||||
|
||||
const meta: Meta<typeof ComponentName> = {
|
||||
title: 'Category/ComponentName',
|
||||
component: ComponentName,
|
||||
parameters: { layout: 'centered' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { /* props */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Required Story Variants
|
||||
|
||||
Include when applicable:
|
||||
- **Default** - Minimal props
|
||||
- **WithData** - Realistic data
|
||||
- **Loading** - Loading state
|
||||
- **Error** - Error state
|
||||
- **Empty** - No data
|
||||
|
||||
## Mock Data
|
||||
|
||||
Use realistic ComfyUI schemas for mocks (node definitions, components).
|
||||
|
||||
## Running Storybook
|
||||
|
||||
```bash
|
||||
pnpm storybook # Development server
|
||||
pnpm build-storybook # Production build
|
||||
```
|
||||
37
docs/guidance/typescript.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- '**/*.vue'
|
||||
---
|
||||
|
||||
# TypeScript Conventions
|
||||
|
||||
## Type Safety
|
||||
|
||||
- Never use `any` type - use proper TypeScript types
|
||||
- Never use `as any` type assertions - fix the underlying type issue
|
||||
- Type assertions are a last resort; they lead to brittle code
|
||||
- Avoid `@ts-expect-error` - fix the underlying issue instead
|
||||
|
||||
## Utility Libraries
|
||||
|
||||
- Use `es-toolkit` for utility functions (not lodash)
|
||||
|
||||
## API Utilities
|
||||
|
||||
When making API calls in `src/`:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct - use api helpers
|
||||
const response = await api.get(api.apiURL('/prompt'))
|
||||
const template = await fetch(api.fileURL('/templates/default.json'))
|
||||
|
||||
// ❌ Wrong - direct URL construction
|
||||
const response = await fetch('/api/prompt')
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- Sanitize HTML with `DOMPurify.sanitize()`
|
||||
- Never log secrets or sensitive data
|
||||
36
docs/guidance/vitest.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.test.ts'
|
||||
---
|
||||
|
||||
# Vitest Unit Test Conventions
|
||||
|
||||
See `docs/testing/*.md` for detailed patterns.
|
||||
|
||||
## Test Quality
|
||||
|
||||
- Do not write change detector tests (tests that just assert defaults)
|
||||
- Do not write tests dependent on non-behavioral features (styles, classes)
|
||||
- Do not write tests that just test mocks - ensure real code is exercised
|
||||
- Be parsimonious; avoid redundant tests
|
||||
|
||||
## Mocking
|
||||
|
||||
- Use Vitest's mocking utilities (`vi.mock`, `vi.spyOn`)
|
||||
- Keep module mocks contained - no global mutable state
|
||||
- Use `vi.hoisted()` for per-test mock manipulation
|
||||
- Don't mock what you don't own
|
||||
|
||||
## Component Testing
|
||||
|
||||
- Use Vue Test Utils for component tests
|
||||
- Follow advice about making components easy to test
|
||||
- Wait for reactivity with `await nextTick()` after state changes
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
pnpm test:unit # Run all unit tests
|
||||
pnpm test:unit -- path/to/file # Run specific test
|
||||
pnpm test:unit -- --watch # Watch mode
|
||||
```
|
||||
46
docs/guidance/vue-components.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.vue'
|
||||
---
|
||||
|
||||
# Vue Component Conventions
|
||||
|
||||
Applies to all `.vue` files anywhere in the codebase.
|
||||
|
||||
## Vue 3 Composition API
|
||||
|
||||
- Use `<script setup lang="ts">` for component logic
|
||||
- Destructure props (Vue 3.5 style with defaults) like `const { color = 'blue' } = defineProps<...>()`
|
||||
- Use `ref`/`reactive` for state
|
||||
- Use `computed()` for derived state
|
||||
- Use lifecycle hooks: `onMounted`, `onUpdated`, etc.
|
||||
|
||||
## Component Communication
|
||||
|
||||
- Prefer `emit/@event-name` for state changes (promotes loose coupling)
|
||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||
- Proper props and emits definitions
|
||||
|
||||
## VueUse Composables
|
||||
|
||||
Prefer VueUse composables over manual event handling:
|
||||
|
||||
- `useElementHover` instead of manual mouseover/mouseout listeners
|
||||
- `useIntersectionObserver` for visibility detection instead of scroll handlers
|
||||
- `useFocusTrap` for modal/dialog focus management
|
||||
- `useEventListener` for auto-cleanup event listeners
|
||||
|
||||
Prefer Vue native options when available:
|
||||
|
||||
- `defineModel` instead of `useVModel` for two-way binding with props
|
||||
|
||||
## Styling
|
||||
|
||||
- Use inline Tailwind CSS only (no `<style>` blocks)
|
||||
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
|
||||
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Extract complex conditionals to `computed`
|
||||
- In unmounted hooks, implement cleanup for async operations
|
||||
@@ -12,12 +12,17 @@ Documentation for unit tests is organized into three guides:
|
||||
|
||||
## Testing Structure
|
||||
|
||||
The ComfyUI Frontend project uses a mixed approach to unit test organization:
|
||||
The ComfyUI Frontend project uses **colocated tests** - test files are placed alongside their source files:
|
||||
|
||||
- **Component Tests**: Located directly alongside their components with a `.spec.ts` extension
|
||||
- **Unit Tests**: Located in the `tests-ui/tests/` directory
|
||||
- **Store Tests**: Located in the `tests-ui/tests/store/` directory
|
||||
- **Browser Tests**: These are located in the `browser_tests/` directory. There is a dedicated README in the `browser_tests/` directory, so it will not be covered here.
|
||||
- **Component Tests**: Located directly alongside their components (e.g., `MyComponent.test.ts` next to `MyComponent.vue`)
|
||||
- **Unit Tests**: Located alongside their source files (e.g., `myUtil.test.ts` next to `myUtil.ts`)
|
||||
- **Store Tests**: Located in `src/stores/` alongside their store files
|
||||
- **Browser Tests**: Located in the `browser_tests/` directory (see dedicated README there)
|
||||
|
||||
### Test File Naming
|
||||
|
||||
- Use `.test.ts` extension for test files
|
||||
- Name tests after their source file: `sourceFile.test.ts`
|
||||
|
||||
## Test Frameworks and Libraries
|
||||
|
||||
@@ -35,8 +40,11 @@ To run the tests locally:
|
||||
# Run unit tests
|
||||
pnpm test:unit
|
||||
|
||||
# Run a specific test file
|
||||
pnpm test:unit -- src/path/to/file.test.ts
|
||||
|
||||
# Run unit tests in watch mode
|
||||
pnpm test:unit -- --watch
|
||||
```
|
||||
|
||||
Refer to the specific guides for more detailed information on each testing type.
|
||||
Refer to the specific guides for more detailed information on each testing type.
|
||||
142
docs/testing/vitest-patterns.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.test.ts'
|
||||
- '**/*.spec.ts'
|
||||
---
|
||||
|
||||
# Vitest Patterns
|
||||
|
||||
## Setup
|
||||
|
||||
Use `createTestingPinia` from `@pinia/testing`, not `createPinia`:
|
||||
|
||||
```typescript
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('MyStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||
|
||||
## i18n in Component Tests
|
||||
|
||||
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
|
||||
|
||||
## Mock Patterns
|
||||
|
||||
### Reset all mocks at once
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks() // Not individual mock.mockReset() calls
|
||||
})
|
||||
```
|
||||
|
||||
### Module mocks with vi.mock()
|
||||
|
||||
```typescript
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
fetchData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/myService', () => ({
|
||||
myService: {
|
||||
doThing: vi.fn()
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### Configure mocks in tests
|
||||
|
||||
```typescript
|
||||
import { api } from '@/scripts/api'
|
||||
import { myService } from '@/services/myService'
|
||||
|
||||
it('handles success', () => {
|
||||
vi.mocked(myService.doThing).mockResolvedValue({ data: 'test' })
|
||||
// ... test code
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Event Listeners
|
||||
|
||||
When a store registers event listeners at module load time:
|
||||
|
||||
```typescript
|
||||
function getEventHandler() {
|
||||
const call = vi.mocked(api.addEventListener).mock.calls.find(
|
||||
([event]) => event === 'my_event'
|
||||
)
|
||||
return call?.[1] as (e: CustomEvent<MyEventType>) => void
|
||||
}
|
||||
|
||||
function dispatch(data: MyEventType) {
|
||||
const handler = getEventHandler()
|
||||
handler(new CustomEvent('my_event', { detail: data }))
|
||||
}
|
||||
|
||||
it('handles events', () => {
|
||||
const store = useMyStore()
|
||||
dispatch({ field: 'value' })
|
||||
expect(store.items).toHaveLength(1)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with Fake Timers
|
||||
|
||||
For stores with intervals, timeouts, or polling:
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('polls after delay', async () => {
|
||||
const store = useMyStore()
|
||||
store.startPolling()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
|
||||
expect(mockService.fetch).toHaveBeenCalled()
|
||||
})
|
||||
```
|
||||
|
||||
## Assertion Style
|
||||
|
||||
Prefer `.toHaveLength()` over `.length.toBe()`:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
expect(store.items).toHaveLength(1)
|
||||
|
||||
// Avoid
|
||||
expect(store.items.length).toBe(1)
|
||||
```
|
||||
|
||||
Use `.toMatchObject()` for partial matching:
|
||||
|
||||
```typescript
|
||||
expect(store.completedItems[0]).toMatchObject({
|
||||
id: 'task-123',
|
||||
status: 'done'
|
||||
})
|
||||
```
|
||||
@@ -4,9 +4,7 @@ import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
import { importX } from 'eslint-plugin-import-x'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
// WORKAROUND: eslint-plugin-prettier causes segfault on Node.js 24 + Windows
|
||||
// See: https://github.com/nodejs/node/issues/58690
|
||||
// Prettier is still run separately in lint-staged, so this is safe to disable
|
||||
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
@@ -111,7 +109,7 @@ export default defineConfig([
|
||||
tseslintConfigs.recommended,
|
||||
// Difference in typecheck on CI vs Local
|
||||
pluginVue.configs['flat/recommended'],
|
||||
// Use eslint-config-prettier instead of eslint-plugin-prettier to avoid Node 24 segfault
|
||||
// Disables ESLint rules that conflict with formatters
|
||||
eslintConfigPrettier,
|
||||
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
|
||||
storybookConfigs['flat/recommended'],
|
||||
|
||||
16
index.html
@@ -10,7 +10,21 @@
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<!-- Status bar style (eg. black or transparent) -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
/* Setting it early for background during load */
|
||||
--bg-color: #202020;
|
||||
}
|
||||
}
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
background-image: var(--bg-img);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
|
||||
|
||||