Compare commits

..

16 Commits

Author SHA1 Message Date
Jin Yi
0136c68065 refactor: rename ResultGallery to MediaLightbox and address code review
- Rename ResultGallery to MediaLightbox across all references
  - Replace window keydown listener with @keydown on dialog element
  - Remove redundant toBeVisible check in Playwright test
  - Remove change detector tests (renders close button, prevents default)
  - Dispatch keyboard events on dialog element in unit tests
  - Wire zoom event to lightbox in MediaAssetCard story
  - Add MediaLightbox Storybook story
  - Sort button icon sizes (icon-sm, icon, icon-lg)
2026-03-18 11:49:18 +09:00
Jin Yi
316483fc56 Merge branch 'main' into refactor/result-gallery 2026-03-17 17:33:41 +09:00
Jin Yi
99cf94f5b0 Merge branch 'main' into refactor/result-gallery 2026-03-17 16:57:41 +09:00
Jin Yi
6c699924d6 fix: e2e test 2026-03-17 16:55:51 +09:00
Jin Yi
c16e0cc1ed Merge branch 'main' into refactor/result-gallery 2026-03-17 16:40:11 +09:00
Jin Yi
75276dbf75 test: flasky test @smoke to @slow 2026-03-17 16:38:58 +09:00
Jin Yi
ee5f252d88 chore: outline none 2026-03-17 16:34:44 +09:00
Jin Yi
83e1288a6b test: fix test 2026-03-17 16:25:30 +09:00
Jin Yi
0a31397fde test: add e2e test for ResultGallery using existing LoadImage workflow 2026-03-17 16:04:29 +09:00
Jin Yi
d0757a551b Merge branch 'main' into refactor/result-gallery 2026-03-17 15:47:45 +09:00
Jin Yi
0cf3b1e5e7 test: remove e2e ResultGallery test (CI lacks output images)
Amp-Thread-ID: https://ampcode.com/threads/T-019cf9e3-c466-71ee-9c45-74b32e21ecaf
Co-authored-by: Amp <amp@ampcode.com>
2026-03-17 15:44:26 +09:00
Jin Yi
5012aa42e2 test: scoping logic 2026-03-17 15:21:01 +09:00
GitHub Action
bdd020a218 [automated] Apply ESLint and Oxfmt fixes 2026-03-17 05:38:48 +00:00
Jin Yi
0799f7973f Merge branch 'main' into refactor/result-gallery 2026-03-17 14:35:59 +09:00
Jin Yi
0c18edb026 test: add e2e test for ResultGallery 2026-03-17 14:31:42 +09:00
Jin Yi
81d0ca8781 refactor: replace PrimeVue Galleria with custom overlay in ResultGallery 2026-03-17 14:10:02 +09:00
85 changed files with 1109 additions and 2517 deletions

View File

@@ -1,82 +0,0 @@
---
name: layer-audit
description: 'Detect violations of the layered architecture import rules (base -> platform -> workbench -> renderer). Runs ESLint with the import-x/no-restricted-paths rule and generates a grouped report.'
---
# Layer Architecture Audit
Finds imports that violate the layered architecture boundary rules enforced by `import-x/no-restricted-paths` in `eslint.config.ts`.
## Layer Hierarchy (bottom to top)
```
renderer (top -- can import from all lower layers)
^
workbench
^
platform
^
base (bottom -- cannot import from any upper layer)
```
Each layer may only import from layers below it.
## How to Run
```bash
# Run ESLint filtering for just the layer boundary rule violations
pnpm lint 2>&1 | grep 'import-x/no-restricted-paths' -B1 | head -200
```
To get a full structured report, run:
```bash
# Collect all violations from base/, platform/, workbench/ layers
pnpm eslint src/base/ src/platform/ src/workbench/ --no-error-on-unmatched-pattern --rule '{"import-x/no-restricted-paths": "warn"}' --format compact 2>&1 | grep 'no-restricted-paths' | sort
```
## How to Read Results
Each violation line shows:
- The **file** containing the bad import
- The **import path** crossing the boundary
- The **message** identifying which layer pair is violated
### Grouping by Layer Pair
After collecting violations, group them by the layer pair pattern:
| Layer pair | Meaning |
| --------------------- | ----------------------------------- |
| base -> platform | base/ importing from platform/ |
| base -> workbench | base/ importing from workbench/ |
| base -> renderer | base/ importing from renderer/ |
| platform -> workbench | platform/ importing from workbench/ |
| platform -> renderer | platform/ importing from renderer/ |
| workbench -> renderer | workbench/ importing from renderer/ |
## When to Use
- Before creating a PR that adds imports between `src/base/`, `src/platform/`, `src/workbench/`, or `src/renderer/`
- When auditing the codebase to find and plan migration of existing violations
- After moving files between layers to verify no new violations were introduced
## Fixing Violations
Common strategies to resolve a layer violation:
1. **Move the import target down** -- if the imported module doesn't depend on upper-layer concepts, move it to a lower layer
2. **Introduce an interface** -- define an interface/type in the lower layer and implement it in the upper layer via dependency injection or a registration pattern
3. **Move the importing file up** -- if the file logically belongs in a higher layer, relocate it
4. **Extract shared logic** -- pull the shared functionality into `base/` or a shared utility
## Reference
| Resource | Path |
| ------------------------------- | ------------------ |
| ESLint config (rule definition) | `eslint.config.ts` |
| Base layer | `src/base/` |
| Platform layer | `src/platform/` |
| Workbench layer | `src/workbench/` |
| Renderer layer | `src/renderer/` |

View File

@@ -12,7 +12,7 @@ runs:
# Install pnpm, Node.js, build frontend
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -0,0 +1,109 @@
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Registry API Types'
on:
# Manual trigger
workflow_dispatch:
# Triggered from comfy-api repo
repository_dispatch:
types: [comfy-api-updated]
jobs:
update-registry-types:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Checkout comfy-api repository
uses: actions/checkout@v6
with:
repository: Comfy-Org/comfy-api
path: comfy-api
token: ${{ secrets.COMFY_API_PAT }}
clean: true
- name: Get API commit information
id: api-info
run: |
cd comfy-api
API_COMMIT=$(git rev-parse --short HEAD)
echo "commit=${API_COMMIT}" >> $GITHUB_OUTPUT
cd ..
- name: Generate API types
run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
mkdir -p ./packages/registry-types/src
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
- name: Lint generated types
run: |
echo "Linting generated Comfy Registry API types..."
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
echo "No changes to Comfy Registry API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
else
echo "Changes detected in Comfy Registry API types."
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
title: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
body: |
## Automated API Type Update
This PR updates the Comfy Registry API types from the latest comfy-api OpenAPI specification.
- API commit: ${{ steps.api-info.outputs.commit }}
- Generated on: ${{ github.event.repository.updated_at }}
These types are automatically generated using openapi-typescript.
branch: update-registry-types-${{ steps.api-info.outputs.commit }}
base: main
labels: CNR
delete-branch: true
add-paths: |
packages/registry-types/src/comfyRegistryTypes.ts

View File

@@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
with:
version: 10
@@ -75,7 +75,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
with:
version: 10

View File

@@ -64,7 +64,6 @@ jobs:
mkdir -p temp/perf-meta
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
- name: Upload PR metadata
if: github.event_name == 'pull_request'

View File

@@ -8,10 +8,6 @@ on:
branches:
- main
concurrency:
group: size-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -32,12 +28,11 @@ jobs:
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR metadata
- name: Save PR number & base branch
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
- name: Upload size data
uses: actions/upload-artifact@v6

View File

@@ -144,7 +144,7 @@ jobs:
if: ${{ !cancelled() }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -29,7 +29,7 @@ jobs:
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

148
.github/workflows/pr-perf-report.yaml vendored Normal file
View File

@@ -0,0 +1,148 @@
name: 'PR: Performance Report'
on:
workflow_run:
workflows: ['CI: Performance Report']
types:
- completed
permissions:
contents: read
pull-requests: write
issues: write
actions: read
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Download PR metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: perf-meta
run_id: ${{ github.event.workflow_run.id }}
path: temp/perf-meta/
- name: Resolve and validate PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const artifactPr = Number(fs.readFileSync('temp/perf-meta/number.txt', 'utf8').trim());
const artifactBase = fs.readFileSync('temp/perf-meta/base.txt', 'utf8').trim();
// Resolve PR from trusted workflow context
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.setFailed('Unable to resolve PR from workflow_run context.');
return;
}
if (Number(pr.number) !== artifactPr) {
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
return;
}
const trustedBase = pr.base?.ref;
if (!trustedBase || artifactBase !== trustedBase) {
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
return;
}
core.setOutput('number', String(pr.number));
core.setOutput('base', trustedBase);
- name: Check if results are still current
id: sha-check
uses: actions/github-script@v8
with:
script: |
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const runSha = context.payload.workflow_run.head_sha;
const currentSha = pr.head.sha;
if (runSha !== currentSha) {
core.info(`Skipping stale report: run SHA ${runSha} != current PR SHA ${currentSha}`);
core.setOutput('stale', 'true');
} else {
core.setOutput('stale', 'false');
}
- name: Download PR perf metrics
if: steps.sha-check.outputs.stale != 'true'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: perf-metrics
run_id: ${{ github.event.workflow_run.id }}
path: test-results/
- name: Download baseline perf metrics
if: steps.sha-check.outputs.stale != 'true'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-meta.outputs.base }}
workflow: ci-perf-report.yaml
event: push
name: perf-metrics
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Load historical baselines from perf-data branch
if: steps.sha-check.outputs.stale != 'true'
continue-on-error: true
run: |
mkdir -p temp/perf-history
git fetch origin perf-data 2>/dev/null || {
echo "perf-data branch not found, skipping historical data"
exit 0
}
INDEX=0
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -5); do
DIR="temp/perf-history/$INDEX"
mkdir -p "$DIR"
git show "origin/perf-data:${file}" > "$DIR/perf-metrics.json" 2>/dev/null || true
INDEX=$((INDEX + 1))
done
echo "Loaded $INDEX historical baselines"
- name: Generate perf report
if: steps.sha-check.outputs.stale != 'true'
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
- name: Post PR comment
if: steps.sha-check.outputs.stale != 'true'
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./perf-report.md
comment-marker: '<!-- COMFYUI_FRONTEND_PERF -->'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,220 +0,0 @@
name: 'PR: Unified Report'
on:
workflow_run:
workflows: ['CI: Size Data', 'CI: Performance Report']
types:
- completed
permissions:
contents: read
pull-requests: write
issues: write
actions: read
concurrency:
group: pr-report-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request'
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Resolve PR from workflow_run context
id: pr-meta
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
// Verify the workflow_run head SHA matches the current PR head
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
- name: Find size workflow run for this commit
if: steps.pr-meta.outputs.skip != 'true'
id: find-size
uses: actions/github-script@v8
with:
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-size-data.yaml',
head_sha: headSha,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Find perf workflow run for this commit
if: steps.pr-meta.outputs.skip != 'true'
id: find-perf
uses: actions/github-script@v8
with:
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-perf-report.yaml',
head_sha: headSha,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Download size data (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: size-data
run_id: ${{ steps.find-size.outputs.run-id }}
path: temp/size
- name: Download size baseline
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-meta.outputs.base }}
workflow: ci-size-data.yaml
event: push
name: size-data
path: temp/size-prev
if_no_artifact_found: warn
- name: Download perf metrics (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: perf-metrics
run_id: ${{ steps.find-perf.outputs.run-id }}
path: test-results/
- name: Download perf baseline
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-meta.outputs.base }}
workflow: ci-perf-report.yaml
event: push
name: perf-metrics
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Generate unified report
if: steps.pr-meta.outputs.skip != 'true'
run: >
node scripts/unified-report.js
--size-status=${{ steps.find-size.outputs.status }}
--perf-status=${{ steps.find-perf.outputs.status }}
> pr-report.md
- name: Remove legacy separate comments
if: steps.pr-meta.outputs.skip != 'true'
uses: actions/github-script@v8
with:
script: |
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
const legacyMarkers = [
'<!-- COMFYUI_FRONTEND_SIZE -->',
'<!-- COMFYUI_FRONTEND_PERF -->',
];
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
for (const comment of comments) {
if (legacyMarkers.some(m => comment.body?.includes(m))) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
core.info(`Deleted legacy comment ${comment.id}`);
}
}
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./pr-report.md
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}

133
.github/workflows/pr-size-report.yaml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: 'PR: Size Report'
on:
workflow_run:
workflows: ['CI: Size Data']
types:
- completed
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to report on'
required: true
type: number
run_id:
description: 'Size data workflow run ID'
required: true
type: string
permissions:
contents: read
pull-requests: write
issues: write
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success') ||
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download size data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: size-data
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
path: temp/size
- name: Resolve and validate PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
// workflow_dispatch: validate artifact metadata against API-resolved PR
if (context.eventName === 'workflow_dispatch') {
const pullNumber = Number('${{ inputs.pr_number }}');
const { data: dispatchPr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullNumber,
});
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
if (artifactPr !== dispatchPr.number) {
core.setFailed(`Artifact PR number (${artifactPr}) does not match dispatch PR (${dispatchPr.number}).`);
return;
}
if (artifactBase !== dispatchPr.base.ref) {
core.setFailed(`Artifact base (${artifactBase}) does not match dispatch PR base (${dispatchPr.base.ref}).`);
return;
}
core.setOutput('number', String(dispatchPr.number));
core.setOutput('base', dispatchPr.base.ref);
return;
}
// workflow_run: validate artifact metadata against trusted context
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.setFailed('Unable to resolve PR from workflow_run context.');
return;
}
if (Number(pr.number) !== artifactPr) {
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
return;
}
const trustedBase = pr.base?.ref;
if (!trustedBase || artifactBase !== trustedBase) {
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
return;
}
core.setOutput('number', String(pr.number));
core.setOutput('base', trustedBase);
- name: Download previous size data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-meta.outputs.base }}
workflow: ci-size-data.yaml
event: push
name: size-data
path: temp/size-prev
if_no_artifact_found: warn
- name: Generate size report
run: node scripts/size-report.js > size-report.md
- name: Post PR comment
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./size-report.md
comment-marker: '<!-- COMFYUI_FRONTEND_SIZE -->'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -84,7 +84,7 @@ jobs:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -75,7 +75,7 @@ jobs:
path: comfyui
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
@@ -202,7 +202,7 @@ jobs:
ref: v${{ needs.resolve-version.outputs.target_version }}
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -21,7 +21,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v6

View File

@@ -75,7 +75,7 @@ jobs:
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -17,7 +17,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v6

View File

@@ -142,7 +142,7 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -51,7 +51,7 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -28,7 +28,7 @@ jobs:
ref: main
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -1,102 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
})
test('should show Logs tab when terminal panel opens', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
})
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).not.toBeVisible()
})
test('should switch between shortcuts and terminal panels', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
await bottomPanel.toggleButton.click()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).not.toBeVisible()
})
test('should persist Logs tab content in bottom panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
const isAlreadyActive =
(await logsTab.getAttribute('aria-selected')) === 'true'
if (!isAlreadyActive) {
await logsTab.click()
}
const xtermContainer = bottomPanel.root.locator('.xterm')
await expect(xtermContainer).toBeVisible()
})
test('should render xterm container in terminal panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
const isAlreadyActive =
(await logsTab.getAttribute('aria-selected')) === 'true'
if (!isAlreadyActive) {
await logsTab.click()
}
const xtermScreen = bottomPanel.root.locator('.xterm, .xterm-screen')
await expect(xtermScreen.first()).toBeVisible()
})
})

View File

@@ -1,65 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Focus Mode', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Focus mode hides UI chrome', async ({ comfyPage }) => {
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
})
test('Focus mode restores UI chrome', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await comfyPage.setFocusMode(false)
await expect(comfyPage.menu.sideToolbar).toBeVisible()
})
test('Toggle focus mode command works', async ({ comfyPage }) => {
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.menu.sideToolbar).toBeVisible()
})
test('Focus mode hides topbar', async ({ comfyPage }) => {
const topMenu = comfyPage.page.locator('.comfy-menu-button-wrapper')
await expect(topMenu).toBeVisible()
await comfyPage.setFocusMode(true)
await expect(topMenu).not.toBeVisible()
})
test('Canvas remains visible in focus mode', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.canvas).toBeVisible()
})
test('Focus mode can be toggled multiple times', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await comfyPage.setFocusMode(false)
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
})
})

View File

@@ -1,91 +0,0 @@
import type { Locator } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Job History Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
// Expand the queue overlay so the JobHistoryActionsMenu is visible
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
})
async function openMoreOptionsPopover(comfyPage: {
page: { getByLabel(label: string | RegExp): Locator }
}) {
const moreButton = comfyPage.page.getByLabel(/More options/i).first()
await moreButton.click()
}
test('More options popover opens', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="docked-job-history-action"]')
).toBeVisible()
})
test('Docked job history action is visible with text', async ({
comfyPage
}) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="docked-job-history-action"]'
)
await expect(action).toBeVisible()
await expect(action).not.toBeEmpty()
})
test('Show run progress bar action is visible', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="show-run-progress-bar-action"]')
).toBeVisible()
})
test('Clear history action is visible', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="clear-history-action"]')
).toBeVisible()
})
test('Clicking docked job history closes popover', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="docked-job-history-action"]'
)
await expect(action).toBeVisible()
await action.click()
await expect(action).not.toBeVisible()
})
test('Clicking show run progress bar toggles setting', async ({
comfyPage
}) => {
const settingBefore = await comfyPage.settings.getSetting<boolean>(
'Comfy.Queue.ShowRunProgressBar'
)
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="show-run-progress-bar-action"]'
)
await action.click()
const settingAfter = await comfyPage.settings.getSetting<boolean>(
'Comfy.Queue.ShowRunProgressBar'
)
expect(settingAfter).toBe(!settingBefore)
})
})

View File

@@ -1,143 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.dialog).toBeVisible()
})
test('Escape closes search box without adding node', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newCount).toBe(initialCount)
})
test('Search clears when reopening', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.input).toHaveValue('')
})
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const samplingResults = await searchBoxV2.results.allTextContents()
await searchBoxV2.categoryButton('loaders').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const loaderResults = await searchBoxV2.results.allTextContents()
expect(samplingResults).not.toEqual(loaderResults)
})
})
test.describe('Filter workflow', () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Record initial result text for comparison
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredResults = await searchBoxV2.results.allTextContents()
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.locator(
'[data-testid="filter-chip"]'
)
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
const filteredResults = await searchBoxV2.results.allTextContents()
expect(filteredResults).not.toEqual(unfilteredResults)
// Remove filter by clicking the chip delete button
await filterChip.getByTestId('chip-delete').click()
// Filter chip should be removed
await expect(filterChip).not.toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
})
})
test.describe('Keyboard navigation', () => {
test('ArrowUp on first item keeps first selected', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
// First result should be selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
// ArrowUp on first item should keep first selected
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
})
})

View File

@@ -222,84 +222,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test.describe('vue renderer large graph', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
await comfyPage.vueNodes.waitForNodes()
})
test('idle', async ({ comfyPage }) => {
await comfyPage.perf.startMeasuring()
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('vue-large-graph-idle')
recordMeasurement(m)
console.log(
`Vue large graph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, ${m.domNodes} DOM nodes`
)
})
test('pan', async ({ comfyPage }) => {
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
await comfyPage.perf.startMeasuring()
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.down({ button: 'middle' })
for (let i = 0; i < 60; i++) {
await comfyPage.page.mouse.move(centerX + i * 5, centerY + i * 2)
await comfyPage.nextFrame()
}
await comfyPage.page.mouse.up({ button: 'middle' })
const m = await comfyPage.perf.stopMeasuring('vue-large-graph-pan')
recordMeasurement(m)
console.log(
`Vue large graph pan: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
})
test('zoom out culling', async ({ comfyPage }) => {
await comfyPage.perf.startMeasuring()
// Zoom out far enough that nodes become < 4px screen size
// (triggers size-based culling in isNodeInViewport)
for (let i = 0; i < 20; i++) {
await comfyPage.canvasOps.zoom(100)
}
// Verify we actually entered the culling regime.
// isNodeTooSmall triggers when max(width, height) * scale < 4px.
// Typical nodes are ~200px wide, so scale must be < 0.02.
const scale = await comfyPage.canvasOps.getScale()
expect(scale).toBeLessThan(0.02)
// Idle at extreme zoom-out — most nodes should be culled
for (let i = 0; i < 60; i++) {
await comfyPage.nextFrame()
}
// Zoom back in
for (let i = 0; i < 20; i++) {
await comfyPage.canvasOps.zoom(-100)
}
const m = await comfyPage.perf.stopMeasuring('vue-zoom-culling')
recordMeasurement(m)
console.log(
`Vue zoom culling: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, ${m.frameDurationMs.toFixed(1)}ms/frame`
)
})
})
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')

View File

@@ -0,0 +1,70 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
async function runAndOpenGallery(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow(
'widgets/save_image_and_animated_webp'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
// Wait for SaveImage node to produce output
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
timeout: 30_000
})
// Open Assets sidebar tab and wait for it to load
await comfyPage.page.locator('.assets-tab-button').click()
await comfyPage.page
.locator('.sidebar-content-container')
.waitFor({ state: 'visible' })
// Wait for any asset card to appear (may contain img or video)
const assetCard = comfyPage.page
.locator('[role="button"]')
.filter({ has: comfyPage.page.locator('img, video') })
.first()
await expect(assetCard).toBeVisible({ timeout: 30_000 })
// Hover to reveal zoom button, then click it
await assetCard.hover()
await assetCard.getByLabel('Zoom in').click()
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()
return { gallery }
}
test('opens gallery and shows dialog with close button', async ({
comfyPage
}) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await expect(gallery.getByLabel('Close')).toBeVisible()
})
test('closes gallery on Escape key', async ({ comfyPage }) => {
await runAndOpenGallery(comfyPage)
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await gallery.getByLabel('Close').click()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
})

View File

@@ -1,119 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Properties panel opens with workflow overview', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
})
test('Properties panel shows node details on selection', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(propertiesPanel.panelTitle).toContainText('KSampler')
})
test('Node title input is editable', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(propertiesPanel.panelTitle).toContainText('KSampler')
// Click on the title to enter edit mode
await propertiesPanel.panelTitle.click()
const titleInput = propertiesPanel.root.getByTestId(TestIds.node.titleInput)
await expect(titleInput).toBeVisible()
await titleInput.fill('My Custom Sampler')
await titleInput.press('Enter')
await expect(propertiesPanel.panelTitle).toContainText('My Custom Sampler')
})
test('Search box filters properties', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
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)')
).toHaveCount(2)
await propertiesPanel.searchBox.fill('seed')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(0)
await propertiesPanel.searchBox.fill('')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
test('Expand all / Collapse all toggles sections', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
// Select multiple nodes so collapse toggle button appears
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
// Sections default to collapsed when multiple nodes are selected,
// so the button initially shows "Expand all"
const expandButton = propertiesPanel.root.getByRole('button', {
name: 'Expand all'
})
await expect(expandButton).toBeVisible()
await expandButton.click()
const collapseButton = propertiesPanel.root.getByRole('button', {
name: 'Collapse all'
})
await expect(collapseButton).toBeVisible()
await collapseButton.click()
// After collapsing, the button label switches back to "Expand all"
await expect(expandButton).toBeVisible()
})
test('Properties panel can be closed', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
// The actionbar toggle button hides when the panel is open,
// so use the close button inside the panel itself
const closeButton = comfyPage.page.getByLabel('Toggle properties panel')
await closeButton.click()
await expect(propertiesPanel.root).toBeHidden()
})
})

View File

@@ -305,49 +305,6 @@ export default defineConfig([
}
},
// Layer architecture boundary enforcement
// Layers (bottom to top): base → platform → workbench → renderer
// Each layer may only import from layers below it.
// Existing violations are suppressed with eslint-disable comments.
{
files: [
'src/base/**/*.{ts,vue}',
'src/platform/**/*.{ts,vue}',
'src/workbench/**/*.{ts,vue}'
],
rules: {
'import-x/no-restricted-paths': [
'error',
{
zones: [
{
target: './src/base/**',
from: [
'./src/platform/**',
'./src/workbench/**',
'./src/renderer/**'
],
message:
'base/ cannot import from upper layers (violates layer architecture: base → platform → workbench → renderer)'
},
{
target: './src/platform/**',
from: ['./src/workbench/**', './src/renderer/**'],
message:
'platform/ cannot import from upper layers (violates layer architecture: base → platform → workbench → renderer)'
},
{
target: './src/workbench/**',
from: './src/renderer/**',
message:
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
}
]
}
]
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.2",
"version": "1.43.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -382,13 +382,7 @@ function renderCategoryBlock(category, hasBaseline) {
? ['File', 'Before', 'After', 'Δ Raw', 'Δ Gzip', 'Δ Brotli']
: ['File', 'Size', 'Gzip', 'Brotli']
// Filter out unchanged bundles to keep report within GitHub's 65k char limit
const changedBundles = category.bundles.filter(
(b) => b.status !== 'unchanged'
)
const unchangedCount = category.bundles.length - changedBundles.length
const rows = changedBundles
const rows = category.bundles
.slice()
.sort((a, b) => {
const diffMagnitude = Math.abs(b.diff.size) - Math.abs(a.diff.size)
@@ -415,10 +409,8 @@ function renderCategoryBlock(category, hasBaseline) {
]
})
if (rows.length > 0) {
lines.push(markdownTable([headers, ...rows]))
lines.push('')
}
lines.push(markdownTable([headers, ...rows]))
lines.push('')
const statusParts = []
if (category.counts.added) statusParts.push(`${category.counts.added} added`)
@@ -428,7 +420,6 @@ function renderCategoryBlock(category, hasBaseline) {
statusParts.push(`${category.counts.increased} grew`)
if (category.counts.decreased)
statusParts.push(`${category.counts.decreased} shrank`)
if (unchangedCount > 0) statusParts.push(`${unchangedCount} unchanged`)
if (statusParts.length > 0) {
lines.push(`_Status:_ ${statusParts.join(' / ')}`)

View File

@@ -1,75 +0,0 @@
// @ts-check
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
const args = process.argv.slice(2)
/** @param {string} name */
function getArg(name) {
const prefix = `--${name}=`
const arg = args.find((a) => a.startsWith(prefix))
return arg ? arg.slice(prefix.length) : undefined
}
const sizeStatus = getArg('size-status') ?? 'pending'
const perfStatus = getArg('perf-status') ?? 'pending'
/** @type {string[]} */
const lines = []
// --- Size section ---
if (sizeStatus === 'ready') {
try {
const sizeReport = execFileSync('node', ['scripts/size-report.js'], {
encoding: 'utf-8'
}).trimEnd()
lines.push(sizeReport)
} catch {
lines.push('## 📦 Bundle Size')
lines.push('')
lines.push(
'> ⚠️ Failed to render bundle size report. Check the CI workflow logs.'
)
}
} else if (sizeStatus === 'failed') {
lines.push('## 📦 Bundle Size')
lines.push('')
lines.push('> ⚠️ Size data collection failed. Check the CI workflow logs.')
} else {
lines.push('## 📦 Bundle Size')
lines.push('')
lines.push('> ⏳ Size data collection in progress…')
}
lines.push('')
// --- Perf section ---
if (perfStatus === 'ready' && existsSync('test-results/perf-metrics.json')) {
try {
const perfReport = execFileSync(
'pnpm',
['exec', 'tsx', 'scripts/perf-report.ts'],
{ encoding: 'utf-8' }
).trimEnd()
lines.push(perfReport)
} catch {
lines.push('## ⚡ Performance')
lines.push('')
lines.push(
'> ⚠️ Failed to render performance report. Check the CI workflow logs.'
)
}
} else if (
perfStatus === 'failed' ||
(perfStatus === 'ready' && !existsSync('test-results/perf-metrics.json'))
) {
lines.push('## ⚡ Performance')
lines.push('')
lines.push('> ⚠️ Performance tests failed. Check the CI workflow logs.')
} else {
lines.push('## ⚡ Performance')
lines.push('')
lines.push('> ⏳ Performance tests in progress…')
}
process.stdout.write(lines.join('\n') + '\n')

View File

@@ -7,20 +7,18 @@
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
@@ -128,26 +126,5 @@ onMounted(() => {
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()
// Show cloud notification for macOS desktop users (one-time)
if (isDesktop && electronAPI()?.getPlatform() === 'darwin') {
const settingStore = useSettingStore()
if (!settingStore.get('Comfy.Desktop.CloudNotificationShown')) {
const dialogService = useDialogService()
cloudNotificationTimer = setTimeout(async () => {
try {
await dialogService.showCloudNotification()
} catch (e) {
console.warn('[CloudNotification] Failed to show', e)
}
await settingStore.set('Comfy.Desktop.CloudNotificationShown', true)
}, 2000)
}
}
})
let cloudNotificationTimer: ReturnType<typeof setTimeout> | undefined
onUnmounted(() => {
if (cloudNotificationTimer) clearTimeout(cloudNotificationTimer)
})
</script>

View File

@@ -2,9 +2,7 @@
* Utility functions for downloading files
*/
import { t } from '@/i18n'
// eslint-disable-next-line import-x/no-restricted-paths
import { isCloud } from '@/platform/distribution/types'
// eslint-disable-next-line import-x/no-restricted-paths
import { useToastStore } from '@/platform/updates/common/toastStore'
// Constants

View File

@@ -60,7 +60,7 @@ const mountComponent = (
stubs: {
QueueOverlayExpanded: QueueOverlayExpandedStub,
QueueOverlayActive: true,
ResultGallery: true
MediaLightbox: true
},
directives: {
tooltip: () => {}

View File

@@ -45,7 +45,7 @@
</div>
</div>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -57,7 +57,7 @@ import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'

View File

@@ -92,36 +92,11 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
})
}
function createDomRect({
top,
left,
width,
height
}: {
top: number
left: number
width: number
height: number
}): DOMRect {
return {
x: left,
y: top,
top,
left,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ''
} as DOMRect
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('JobAssetsList', () => {
afterEach(() => {
vi.useRealTimers()
})
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
@@ -273,53 +248,6 @@ describe('JobAssetsList', () => {
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
})
it('positions the popover to the right of rows near the left viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 40,
width: 200,
height: 48
})
)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = wrapper.find('.job-details-popover')
expect(popover.attributes('style')).toContain('left: 248px;')
})
it('positions the popover to the left of rows near the right viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 980,
width: 200,
height: 48
})
)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = wrapper.find('.job-details-popover')
expect(popover.attributes('style')).toContain('left: 672px;')
})
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })

View File

@@ -83,7 +83,7 @@
class="job-details-popover fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
right: `${popoverPosition.right}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
@@ -101,7 +101,6 @@ import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import Button from '@/components/ui/button/Button.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
@@ -122,7 +121,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const popoverPosition = ref<{ top: number; right: number } | null>(null)
const {
activeDetails,
clearHoverTimers,
@@ -145,7 +144,11 @@ function updatePopoverPosition() {
if (!rowElement) return
const rect = rowElement.getBoundingClientRect()
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
const gap = 8
popoverPosition.value = {
top: rect.top,
right: window.innerWidth - rect.left + gap
}
}
function onJobLeave(jobId: string) {

View File

@@ -12,7 +12,7 @@
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
right: `${popoverPosition.right}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
@@ -26,7 +26,7 @@
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
right: `${popoverPosition.right}px`
}"
@mouseenter="onPreviewEnter"
@mouseleave="onPreviewLeave"
@@ -191,7 +191,6 @@ import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
@@ -299,13 +298,17 @@ const onIconLeave = () => scheduleHidePreview()
const onPreviewEnter = () => scheduleShowPreview()
const onPreviewLeave = () => scheduleHidePreview()
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const popoverPosition = ref<{ top: number; right: number } | null>(null)
const updatePopoverPosition = () => {
const el = rowRef.value
if (!el) return
const rect = el.getBoundingClientRect()
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
const gap = 8
popoverPosition.value = {
top: rect.top,
right: window.innerWidth - rect.left + gap
}
}
const isAnyPopoverVisible = computed(

View File

@@ -1,61 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getHoverPopoverPosition } from './getHoverPopoverPosition'
describe('getHoverPopoverPosition', () => {
it('places the popover to the right when space is available', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 40, right: 240 },
1280
)
expect(position).toEqual({ top: 100, left: 248 })
})
it('places the popover to the left when right space is insufficient', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 980, right: 1180 },
1280
)
expect(position).toEqual({ top: 100, left: 672 })
})
it('clamps the top to viewport padding when rect.top is near the top edge', () => {
const position = getHoverPopoverPosition(
{ top: 2, left: 40, right: 240 },
1280
)
expect(position).toEqual({ top: 8, left: 248 })
})
it('clamps left to viewport padding when fallback would go off-screen', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 100, right: 300 },
320
)
expect(position).toEqual({ top: 100, left: 8 })
})
it('prefers right when both sides have equal space', () => {
const position = getHoverPopoverPosition(
{ top: 200, left: 340, right: 640 },
1280
)
expect(position).toEqual({ top: 200, left: 648 })
})
it('falls back to left when right space is less than popover width', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 600, right: 1000 },
1280
)
expect(position).toEqual({ top: 100, left: 292 })
})
it('handles narrow viewport where popover barely fits', () => {
const position = getHoverPopoverPosition(
{ top: 50, left: 8, right: 100 },
316
)
expect(position).toEqual({ top: 50, left: 8 })
})
})

View File

@@ -1,39 +0,0 @@
const POPOVER_GAP = 8
const POPOVER_WIDTH = 300
const VIEWPORT_PADDING = 8
type AnchorRect = Pick<DOMRect, 'top' | 'left' | 'right'>
type HoverPopoverPosition = {
top: number
left: number
}
export function getHoverPopoverPosition(
rect: AnchorRect,
viewportWidth: number
): HoverPopoverPosition {
const availableLeft = rect.left - POPOVER_GAP
const availableRight = viewportWidth - rect.right - POPOVER_GAP
const preferredLeft = rect.right + POPOVER_GAP
const fallbackLeft = rect.left - POPOVER_WIDTH - POPOVER_GAP
const maxLeft = Math.max(
VIEWPORT_PADDING,
viewportWidth - POPOVER_WIDTH - VIEWPORT_PADDING
)
if (
availableRight >= POPOVER_WIDTH &&
(availableRight >= availableLeft || availableLeft < POPOVER_WIDTH)
) {
return {
top: Math.max(VIEWPORT_PADDING, rect.top),
left: Math.min(maxLeft, preferredLeft)
}
}
return {
top: Math.max(VIEWPORT_PADDING, rect.top),
left: Math.max(VIEWPORT_PADDING, Math.min(maxLeft, fallbackLeft))
}
}

View File

@@ -63,7 +63,6 @@ import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
@@ -85,7 +84,6 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
storeToRefs(searchBoxStore)
@@ -167,7 +165,6 @@ function getFirstLink() {
const nodeDefStore = useNodeDefStore()
function showNewSearchBox(e: CanvasPointerEvent | null) {
trackFeatureUsed()
const firstLink = getFirstLink()
if (firstLink) {
const filter =

View File

@@ -170,7 +170,7 @@
</div>
</template>
</SidebarTabTemplate>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -220,7 +220,7 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -1,163 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template: '<div class="job-details-popover-stub" />'
})
vi.mock('@/composables/queue/useJobList', async () => {
const { ref } = await import('vue')
const jobHistoryItem = {
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: true,
isVideo: false,
url: '/api/view/job-1.png'
}
}
}
return {
useJobList: () => ({
selectedJobTab: ref('All'),
selectedWorkflowFilter: ref('all'),
selectedSortMode: ref('mostRecent'),
searchQuery: ref(''),
hasFailedJobs: ref(false),
filteredTasks: ref([]),
groupedJobItems: ref([
{
key: 'group-1',
label: 'Group 1',
items: [jobHistoryItem]
}
])
})
}
})
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({
jobMenuEntries: [],
cancelJob: vi.fn()
})
}))
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
useQueueClearHistoryDialog: () => ({
showQueueClearHistoryDialog: vi.fn()
})
}))
vi.mock('@/composables/queue/useResultGallery', async () => {
const { ref } = await import('vue')
return {
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: vi.fn()
})
}
})
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <T extends (...args: never[]) => unknown>(
fn: T
) => fn
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByJobIds: vi.fn()
})
}))
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
runningTasks: [],
pendingTasks: [],
delete: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const SidebarTabTemplateStub = {
name: 'SidebarTabTemplate',
props: ['title'],
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
}
function mountComponent() {
return mount(JobHistorySidebarTab, {
global: {
plugins: [i18n],
stubs: {
SidebarTabTemplate: SidebarTabTemplateStub,
JobFilterTabs: true,
JobFilterActions: true,
JobHistoryActionsMenu: true,
JobContextMenu: true,
ResultGallery: true,
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
}
afterEach(() => {
vi.useRealTimers()
})
describe('JobHistorySidebarTab', () => {
it('shows the job details popover for jobs in the history panel', async () => {
vi.useFakeTimers()
const wrapper = mountComponent()
const jobRow = wrapper.find('[data-job-id="job-1"]')
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: 'job-1',
workflowId: 'workflow-1'
})
})
})

View File

@@ -58,7 +58,7 @@
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -83,7 +83,7 @@ import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHis
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'

View File

@@ -0,0 +1,229 @@
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
enableAutoUnmount(afterEach)
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import MediaLightbox from './MediaLightbox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
gallery: 'Gallery',
previous: 'Previous',
next: 'Next'
}
}
}
})
type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
mediaType: string
id?: string
url?: string
isImage?: boolean
isVideo?: boolean
isAudio?: boolean
}
describe('MediaLightbox', () => {
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
props: ['src', 'contain', 'alt']
}
const mockResultVideo = {
name: 'ResultVideo',
template:
'<div class="mock-result-video" data-testid="result-video"></div>',
props: ['result']
}
const mockResultAudio = {
name: 'ResultAudio',
template:
'<div class="mock-result-audio" data-testid="result-audio"></div>',
props: ['result']
}
const mockGalleryItems: MockResultItem[] = [
{
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image1.jpg',
id: '1'
},
{
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image2.jpg',
id: '2'
},
{
filename: 'image3.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '789' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image3.jpg',
id: '3'
}
]
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>'
})
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(MediaLightbox, {
global: {
plugins: [i18n],
components: {
ComfyImage: mockComfyImage,
ResultVideo: mockResultVideo,
ResultAudio: mockResultAudio
},
stubs: {
teleport: true
}
},
props: {
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
...props
},
attachTo: document.getElementById('app') || undefined
})
}
it('renders overlay with role="dialog" and aria-modal', async () => {
const wrapper = mountGallery()
await nextTick()
const dialog = wrapper.find('[role="dialog"]')
expect(dialog.exists()).toBe(true)
expect(dialog.attributes('aria-modal')).toBe('true')
})
it('shows navigation buttons when multiple items', async () => {
const wrapper = mountGallery()
await nextTick()
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(true)
})
it('hides navigation buttons for single item', async () => {
const wrapper = mountGallery({
allGalleryItems: [mockGalleryItems[0]] as ResultItemImpl[]
})
await nextTick()
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(false)
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
expect(wrapper.find('[data-mask]').exists()).toBe(false)
await wrapper.setProps({ activeIndex: 0 })
await nextTick()
expect(wrapper.find('[data-mask]').exists()).toBe(true)
})
it('emits update:activeIndex with -1 when close button clicked', async () => {
const wrapper = mountGallery()
await nextTick()
await wrapper.find('[aria-label="Close"]').trigger('click')
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
})
describe('keyboard navigation', () => {
it('navigates to next item on ArrowRight', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
})
it('navigates to previous item on ArrowLeft', async () => {
const wrapper = mountGallery({ activeIndex: 1 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
})
it('wraps to last item on ArrowLeft from first', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
})
it('closes gallery on Escape', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'Escape' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
})
})
})

View File

@@ -0,0 +1,149 @@
<template>
<Teleport to="body">
<div
v-if="galleryVisible"
ref="dialogRef"
role="dialog"
aria-modal="true"
:aria-label="$t('g.gallery')"
tabindex="-1"
class="fixed inset-0 z-9999 flex items-center justify-center bg-black/90 outline-none"
data-mask
@mousedown="onMaskMouseDown"
@mouseup="onMaskMouseUp"
@keydown="handleKeyDown"
>
<!-- Close Button -->
<Button
variant="secondary"
size="icon-lg"
class="absolute top-4 right-4 z-10 rounded-full"
:aria-label="$t('g.close')"
@click="close"
>
<i class="icon-[lucide--x] size-5" />
</Button>
<!-- Previous Button -->
<Button
v-if="hasMultiple"
variant="secondary"
size="icon-lg"
class="fixed top-1/2 left-4 z-10 -translate-y-1/2 rounded-full"
:aria-label="$t('g.previous')"
@click="navigateImage(-1)"
>
<i class="icon-[lucide--chevron-left] size-6" />
</Button>
<!-- Content -->
<div class="flex max-h-full max-w-full items-center justify-center">
<template v-if="activeItem">
<ComfyImage
v-if="activeItem.isImage"
:key="activeItem.url"
:src="activeItem.url"
:contain="false"
:alt="activeItem.filename"
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
/>
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
</template>
</div>
<!-- Next Button -->
<Button
v-if="hasMultiple"
variant="secondary"
size="icon-lg"
class="fixed top-1/2 right-4 z-10 -translate-y-1/2 rounded-full"
:aria-label="$t('g.next')"
@click="navigateImage(1)"
>
<i class="icon-[lucide--chevron-right] size-6" />
</Button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
}>()
const props = defineProps<{
allGalleryItems: ResultItemImpl[]
activeIndex: number
}>()
const galleryVisible = ref(false)
const dialogRef = ref<HTMLElement>()
let previouslyFocusedElement: HTMLElement | null = null
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
const activeItem = computed(() => props.allGalleryItems[props.activeIndex])
watch(
() => props.activeIndex,
(index) => {
galleryVisible.value = index !== -1
if (index !== -1) {
previouslyFocusedElement = document.activeElement as HTMLElement | null
void nextTick(() => dialogRef.value?.focus())
}
},
{ immediate: true }
)
function close() {
galleryVisible.value = false
emit('update:activeIndex', -1)
previouslyFocusedElement?.focus()
previouslyFocusedElement = null
}
function navigateImage(direction: number) {
const newIndex =
(props.activeIndex + direction + props.allGalleryItems.length) %
props.allGalleryItems.length
emit('update:activeIndex', newIndex)
}
let maskMouseDownTarget: EventTarget | null = null
function onMaskMouseDown(event: MouseEvent) {
maskMouseDownTarget = event.target
}
function onMaskMouseUp(event: MouseEvent) {
if (
maskMouseDownTarget === event.target &&
(event.target as HTMLElement)?.hasAttribute('data-mask')
) {
close()
}
}
function handleKeyDown(event: KeyboardEvent) {
const actions: Record<string, () => void> = {
ArrowLeft: () => navigateImage(-1),
ArrowRight: () => navigateImage(1),
Escape: () => close()
}
const action = actions[event.key]
if (action) {
event.preventDefault()
action()
}
}
</script>

View File

@@ -1,184 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Galleria from 'primevue/galleria'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultGallery from './ResultGallery.vue'
type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
mediaType: string
id?: string
url?: string
isImage?: boolean
isVideo?: boolean
}
describe('ResultGallery', () => {
// Mock ComfyImage and ResultVideo components
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
props: ['src', 'contain', 'alt']
}
const mockResultVideo = {
name: 'ResultVideo',
template:
'<div class="mock-result-video" data-testid="result-video"></div>',
props: ['result']
}
// Sample gallery items - using mock instances with only required properties
const mockGalleryItems: MockResultItem[] = [
{
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image1.jpg',
id: '1'
},
{
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image2.jpg',
id: '2'
}
]
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
// Create mock elements for Galleria to find
document.body.innerHTML = `
<div id="app"></div>
`
})
afterEach(() => {
// Clean up any elements added to body
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(ResultGallery, {
global: {
plugins: [PrimeVue],
components: {
Galleria,
ComfyImage: mockComfyImage,
ResultVideo: mockResultVideo
},
stubs: {
teleport: true
}
},
props: {
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
...props
},
attachTo: document.getElementById('app') || undefined
})
}
it('renders Galleria component with correct props', async () => {
const wrapper = mountGallery()
await nextTick() // Wait for component to mount
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
expect(galleria.props('value')).toEqual(mockGalleryItems)
expect(galleria.props('showIndicators')).toBe(false)
expect(galleria.props('showItemNavigators')).toBe(true)
expect(galleria.props('fullScreen')).toBe(true)
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
// Initially galleryVisible should be false
type GalleryVM = typeof wrapper.vm & {
galleryVisible: boolean
}
const vm = wrapper.vm as GalleryVM
expect(vm.galleryVisible).toBe(false)
// Change activeIndex
await wrapper.setProps({ activeIndex: 0 })
await nextTick()
// galleryVisible should become true
expect(vm.galleryVisible).toBe(true)
})
it('should render the component properly', () => {
// This is a meta-test to confirm the component mounts properly
const wrapper = mountGallery()
// We can't directly test the compiled CSS, but we can verify the component renders
expect(wrapper.exists()).toBe(true)
// Verify that the Galleria component exists and is properly mounted
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
})
it('ensures correct configuration for mobile viewport', async () => {
// Mock window.matchMedia to simulate mobile viewport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: query.includes('max-width: 768px'),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
const wrapper = mountGallery()
await nextTick()
// Verify mobile media query is working
expect(window.matchMedia('(max-width: 768px)').matches).toBe(true)
// Check if the component renders with Galleria
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
// Check that our PT props for positioning work correctly
interface GalleriaPT {
prevButton?: { style?: string }
nextButton?: { style?: string }
}
const pt = galleria.props('pt') as GalleriaPT
expect(pt?.prevButton?.style).toContain('position: fixed')
expect(pt?.nextButton?.style).toContain('position: fixed')
})
// Additional tests for interaction could be added once we can reliably
// test Galleria component in fullscreen mode
})

View File

@@ -1,151 +0,0 @@
<template>
<Galleria
v-model:visible="galleryVisible"
:active-index="activeIndex"
:value="allGalleryItems"
:show-indicators="false"
change-item-on-indicator-hover
:show-item-navigators="hasMultiple"
full-screen
:circular="hasMultiple"
:show-thumbnails="false"
:pt="{
mask: {
onMousedown: onMaskMouseDown,
onMouseup: onMaskMouseUp,
'data-mask': true
},
prevButton: {
style: 'position: fixed !important'
},
nextButton: {
style: 'position: fixed !important'
}
}"
@update:visible="handleVisibilityChange"
@update:active-index="handleActiveIndexChange"
>
<template #item="{ item }">
<ComfyImage
v-if="item.isImage"
:key="item.url"
:src="item.url"
:contain="false"
:alt="item.filename"
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
<ResultAudio v-else-if="item.isAudio" :result="item" />
</template>
</Galleria>
</template>
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
}>()
const props = defineProps<{
allGalleryItems: ResultItemImpl[]
activeIndex: number
}>()
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
let maskMouseDownTarget: EventTarget | null = null
const onMaskMouseDown = (event: MouseEvent) => {
maskMouseDownTarget = event.target
}
const onMaskMouseUp = (event: MouseEvent) => {
const maskEl = document.querySelector('[data-mask]')
if (
galleryVisible.value &&
maskMouseDownTarget === event.target &&
maskMouseDownTarget === maskEl
) {
galleryVisible.value = false
handleVisibilityChange(false)
}
}
watch(
() => props.activeIndex,
(index) => {
if (index !== -1) {
galleryVisible.value = true
}
}
)
const handleVisibilityChange = (visible: boolean) => {
if (!visible) {
emit('update:activeIndex', -1)
}
}
const handleActiveIndexChange = (index: number) => {
emit('update:activeIndex', index)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!galleryVisible.value) return
switch (event.key) {
case 'ArrowLeft':
navigateImage(-1)
break
case 'ArrowRight':
navigateImage(1)
break
case 'Escape':
galleryVisible.value = false
handleVisibilityChange(false)
break
}
}
const navigateImage = (direction: number) => {
const newIndex =
(props.activeIndex + direction + props.allGalleryItems.length) %
props.allGalleryItems.length
emit('update:activeIndex', newIndex)
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<style>
/* PrimeVue's galleria teleports the fullscreen gallery out of subtree so we
cannot use scoped style here. */
.p-galleria-close-button {
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
z-index: 1;
}
/* Mobile/tablet specific fixes */
@media screen and (max-width: 768px) {
.p-galleria-prev-button,
.p-galleria-next-button {
z-index: 2;
}
}
</style>

View File

@@ -28,8 +28,9 @@ export const buttonVariants = cva({
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-8',
'icon-sm': 'size-5 p-0',
icon: 'size-8',
'icon-lg': 'size-10',
unset: ''
}
},
@@ -54,8 +55,13 @@ const variants = [
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']
>
const sizes = [
'sm',
'md',
'lg',
'icon-sm',
'icon',
'icon-lg'
] as const satisfies Array<ButtonVariants['size']>
export const FOR_STORIES = { variants, sizes } as const

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "تم إرسال إعادة تعيين كلمة المرور",
"cloudForgotPassword_sendResetLink": "إرسال رابط إعادة التعيين",
"cloudForgotPassword_title": "نسيت كلمة المرور",
"cloudNotification": {
"continueLocally": "المتابعة محليًا",
"exploreCloud": "جرّب السحابة مجانًا",
"feature1Title": "٤٠٠ رصيد مجاني شهريًا",
"feature2Title": "يعمل في أي مكان، فورًا",
"feature3Title": "نماذج جاهزة للاستخدام",
"feature4Title": "أفضل حزم العقد المخصصة مثبتة مسبقًا",
"footer": "ComfyUI يبقى مجانيًا ومفتوح المصدر. السحابة اختيارية.",
"message": "من الإعداد إلى الإنشاء في ثوانٍ. النماذج الشائعة، الإضافات، ومعالجات الرسوميات القوية — جاهزة متى احتجت إليها.",
"title": "تشغيل ComfyUI على السحابة"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "افتح في محرر القناع",
"Outputs": "المخرجات",
"Paste": "لصق",
"Paste Image": "لصق الصورة",
"Pin": "تثبيت",
"Properties": "الخصائص",
"Properties Panel": "لوحة الخصائص",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "رصيدك الحالي"
},
"curveWidget": {
"linear": "خطّي",
"monotone_cubic": "ناعم"
},
"dataTypes": {
"*": "*",
"AUDIO": "صوت",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "العقد الأساسية من الإصدار {version}:",
"outdatedVersion": "تم إنشاء سير العمل هذا باستخدام إصدار أحدث من ComfyUI ({version}). قد لا تعمل بعض العقد بشكل صحيح.",
"outdatedVersionGeneric": "تم إنشاء سير العمل هذا باستخدام إصدار أحدث من ComfyUI. قد لا تعمل بعض العقد بشكل صحيح.",
"unknownVersion": "غير معروف"
"outdatedVersionGeneric": "تم إنشاء سير العمل هذا باستخدام إصدار أحدث من ComfyUI. قد لا تعمل بعض العقد بشكل صحيح."
},
"maintenance": {
"None": "لا شيء",

View File

@@ -136,9 +136,11 @@
"enableOrDisablePack": "Enable or disable pack",
"openManager": "Open Manager",
"manageExtensions": "Manage extensions",
"gallery": "Gallery",
"graphNavigation": "Graph navigation",
"dropYourFileOr": "Drop your file or",
"back": "Back",
"previous": "Previous",
"next": "Next",
"submit": "Submit",
"install": "Install",
@@ -3627,16 +3629,5 @@
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder"
},
"cloudNotification": {
"title": "Run ComfyUI in the Cloud",
"message": "From setup to creation in seconds. Popular models, extensions, and powerful GPUs — ready when you are.",
"feature1Title": "400 Free Credits Monthly",
"feature2Title": "Works Anywhere, Instantly",
"feature3Title": "Models Ready to Use",
"feature4Title": "Top Custom Node Packs Pre-installed",
"footer": "ComfyUI stays free and open source. Cloud is optional.",
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
}
}

View File

@@ -13850,7 +13850,7 @@
},
"steps": {
"name": "steps",
"tooltip": "Optional: The number of steps the LoRA has been trained for, used to name the saved file."
"tooltip": "Optional: The number of steps to LoRA has been trained for, used to name the saved file."
}
}
},
@@ -15973,7 +15973,7 @@
},
"training_dtype": {
"name": "training_dtype",
"tooltip": "The dtype to use for training. 'none' preserves the model's native compute dtype instead of overriding it. For fp16 models, GradScaler is automatically enabled."
"tooltip": "The dtype to use for training."
},
"lora_dtype": {
"name": "lora_dtype",
@@ -15993,7 +15993,7 @@
},
"offloading": {
"name": "offloading",
"tooltip": "Offload model weights to CPU during training to save GPU memory."
"tooltip": "Offload the Model to RAM. Requires Bypass Mode."
},
"existing_lora": {
"name": "existing_lora",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "Restablecimiento de contraseña enviado",
"cloudForgotPassword_sendResetLink": "Enviar enlace de restablecimiento",
"cloudForgotPassword_title": "¿Olvidaste tu contraseña?",
"cloudNotification": {
"continueLocally": "Continuar Localmente",
"exploreCloud": "Probar la Nube Gratis",
"feature1Title": "400 Créditos Gratis al Mes",
"feature2Title": "Funciona en Cualquier Lugar, al Instante",
"feature3Title": "Modelos Listos para Usar",
"feature4Title": "Paquetes de Nodos Personalizados Preinstalados",
"footer": "ComfyUI sigue siendo gratuito y de código abierto. La nube es opcional.",
"message": "De la configuración a la creación en segundos. Modelos populares, extensiones y potentes GPUs — listos cuando los necesites.",
"title": "Ejecuta ComfyUI en la Nube"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "Abrir en Editor de Máscara",
"Outputs": "Salidas",
"Paste": "Pegar",
"Paste Image": "Pegar imagen",
"Pin": "Anclar",
"Properties": "Propiedades",
"Properties Panel": "Panel de Propiedades",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "Tu saldo de créditos"
},
"curveWidget": {
"linear": "Lineal",
"monotone_cubic": "Suave"
},
"dataTypes": {
"*": "*",
"AUDIO": "AUDIO",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "Nodos principales de la versión {version}:",
"outdatedVersion": "Este flujo de trabajo fue creado con una versión más reciente de ComfyUI ({version}). Es posible que algunos nodos no funcionen correctamente.",
"outdatedVersionGeneric": "Este flujo de trabajo fue creado con una versión más reciente de ComfyUI. Es posible que algunos nodos no funcionen correctamente.",
"unknownVersion": "desconocido"
"outdatedVersionGeneric": "Este flujo de trabajo fue creado con una versión más reciente de ComfyUI. Es posible que algunos nodos no funcionen correctamente."
},
"maintenance": {
"None": "Ninguno",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "لینک بازنشانی رمز عبور ارسال شد",
"cloudForgotPassword_sendResetLink": "ارسال لینک بازنشانی",
"cloudForgotPassword_title": "فراموشی رمز عبور",
"cloudNotification": {
"continueLocally": "ادامه به صورت محلی",
"exploreCloud": "امتحان رایگان فضای ابری",
"feature1Title": "۴۰۰ اعتبار رایگان ماهانه",
"feature2Title": "قابل استفاده در هر مکان، بلافاصله",
"feature3Title": "مدل‌ها آماده استفاده",
"feature4Title": "برترین بسته‌های Node سفارشی از پیش نصب‌شده",
"footer": "ComfyUI رایگان و متن‌باز باقی می‌ماند. استفاده از فضای ابری اختیاری است.",
"message": "از راه‌اندازی تا خلق اثر در چند ثانیه. مدل‌های محبوب، افزونه‌ها و GPUهای قدرتمند — آماده برای شما.",
"title": "اجرای ComfyUI در فضای ابری"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "باز کردن در Mask Editor",
"Outputs": "خروجی‌ها",
"Paste": "چسباندن",
"Paste Image": "چسباندن تصویر",
"Pin": "سنجاق کردن",
"Properties": "ویژگی‌ها",
"Properties Panel": "پنل ویژگی‌ها",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "موجودی اعتبار شما"
},
"curveWidget": {
"linear": "خطی",
"monotone_cubic": "صاف"
},
"dataTypes": {
"*": "*",
"AUDIO": "صوت",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "nodeهای اصلی از نسخه {version}:",
"outdatedVersion": "این workflow با نسخه جدیدتری از ComfyUI ({version}) ایجاد شده است. برخی nodeها ممکن است به درستی کار نکنند.",
"outdatedVersionGeneric": "این workflow با نسخه جدیدتری از ComfyUI ایجاد شده است. برخی nodeها ممکن است به درستی کار نکنند.",
"unknownVersion": "نامشخص"
"outdatedVersionGeneric": "این workflow با نسخه جدیدتری از ComfyUI ایجاد شده است. برخی nodeها ممکن است به درستی کار نکنند."
},
"maintenance": {
"None": "هیچ‌کدام",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "Réinitialisation du mot de passe envoyée",
"cloudForgotPassword_sendResetLink": "Envoyer le lien de réinitialisation",
"cloudForgotPassword_title": "Mot de passe oublié",
"cloudNotification": {
"continueLocally": "Continuer localement",
"exploreCloud": "Essayer le cloud gratuitement",
"feature1Title": "400 crédits gratuits par mois",
"feature2Title": "Fonctionne partout, instantanément",
"feature3Title": "Modèles prêts à lemploi",
"feature4Title": "Packs de nœuds personnalisés préinstallés",
"footer": "ComfyUI reste gratuit et open source. Le cloud est optionnel.",
"message": "De la configuration à la création en quelques secondes. Modèles populaires, extensions et GPU puissants — prêts quand vous lêtes.",
"title": "Exécutez ComfyUI dans le Cloud"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "Ouvrir dans l'éditeur de masque",
"Outputs": "Sorties",
"Paste": "Coller",
"Paste Image": "Coller limage",
"Pin": "Épingler",
"Properties": "Propriétés",
"Properties Panel": "Panneau des Propriétés",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "Votre solde de crédits"
},
"curveWidget": {
"linear": "Linéaire",
"monotone_cubic": "Lisse"
},
"dataTypes": {
"*": "*",
"AUDIO": "AUDIO",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "Nœuds principaux de la version {version} :",
"outdatedVersion": "Ce workflow a été créé avec une version plus récente de ComfyUI ({version}). Certains nœuds peuvent ne pas fonctionner correctement.",
"outdatedVersionGeneric": "Ce workflow a été créé avec une version plus récente de ComfyUI. Certains nœuds peuvent ne pas fonctionner correctement.",
"unknownVersion": "inconnue"
"outdatedVersionGeneric": "Ce workflow a été créé avec une version plus récente de ComfyUI. Certains nœuds peuvent ne pas fonctionner correctement."
},
"maintenance": {
"None": "Aucun",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "パスワードリセットを送信しました",
"cloudForgotPassword_sendResetLink": "リセットリンクを送信",
"cloudForgotPassword_title": "パスワードを忘れた場合",
"cloudNotification": {
"continueLocally": "ローカルで続行",
"exploreCloud": "クラウドを無料で試す",
"feature1Title": "毎月400クレジット無料",
"feature2Title": "どこでも即時利用可能",
"feature3Title": "すぐに使えるモデル",
"feature4Title": "人気カスタムノードパックをプリインストール",
"footer": "ComfyUIは無料かつオープンソースのままです。クラウド利用は任意です。",
"message": "セットアップから作成まで数秒。人気モデル、拡張機能、強力なGPUがすぐに利用可能。",
"title": "ComfyUIをクラウドで実行"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "マスクエディタで開く",
"Outputs": "出力",
"Paste": "貼り付け",
"Paste Image": "画像を貼り付け",
"Pin": "ピン",
"Properties": "プロパティ",
"Properties Panel": "プロパティパネル",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "あなたのクレジット残高"
},
"curveWidget": {
"linear": "リニア",
"monotone_cubic": "スムーズ"
},
"dataTypes": {
"*": "*",
"AUDIO": "オーディオ",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "バージョン{version}のコアノード:",
"outdatedVersion": "このワークフローは新しいバージョンのComfyUI{version})で作成されました。一部のノードが正しく動作しない場合があります。",
"outdatedVersionGeneric": "このワークフローは新しいバージョンのComfyUIで作成されました。一部のードが正しく動作しない場合があります。",
"unknownVersion": "不明"
"outdatedVersionGeneric": "このワークフローは新しいバージョンのComfyUIで作成されました。一部のードが正しく動作しない場合があります。"
},
"maintenance": {
"None": "なし",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "비밀번호 재설정 전송됨",
"cloudForgotPassword_sendResetLink": "재설정 링크 보내기",
"cloudForgotPassword_title": "비밀번호 찾기",
"cloudNotification": {
"continueLocally": "로컬에서 계속하기",
"exploreCloud": "클라우드 무료 체험",
"feature1Title": "매월 400 크레딧 무료 제공",
"feature2Title": "어디서나 즉시 사용 가능",
"feature3Title": "즉시 사용 가능한 모델",
"feature4Title": "최고의 커스텀 노드 팩 사전 설치",
"footer": "ComfyUI는 계속 무료이자 오픈 소스입니다. 클라우드 사용은 선택 사항입니다.",
"message": "설정부터 창작까지 몇 초 만에. 인기 모델, 확장 기능, 강력한 GPU — 언제든 바로 사용 가능합니다.",
"title": "ComfyUI 클라우드에서 실행하기"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "마스크 편집기에서 열기",
"Outputs": "출력",
"Paste": "붙여넣기",
"Paste Image": "이미지 붙여넣기",
"Pin": "고정",
"Properties": "속성",
"Properties Panel": "속성 패널",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "보유 크레딧 잔액"
},
"curveWidget": {
"linear": "직선",
"monotone_cubic": "부드럽게"
},
"dataTypes": {
"*": "*",
"AUDIO": "오디오",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "버전 {version}의 코어 노드:",
"outdatedVersion": "이 워크플로우는 더 최신 버전의 ComfyUI({version})에서 생성되었습니다. 일부 노드는 제대로 작동하지 않을 수 있습니다.",
"outdatedVersionGeneric": "이 워크플로우는 더 최신 버전의 ComfyUI에서 생성되었습니다. 일부 노드는 제대로 작동하지 않을 수 있습니다.",
"unknownVersion": "알 수 없음"
"outdatedVersionGeneric": "이 워크플로우는 더 최신 버전의 ComfyUI에서 생성되었습니다. 일부 노드는 제대로 작동하지 않을 수 있습니다."
},
"maintenance": {
"None": "없음",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "Redefinição de senha enviada",
"cloudForgotPassword_sendResetLink": "Enviar link de redefinição",
"cloudForgotPassword_title": "Esqueceu a senha",
"cloudNotification": {
"continueLocally": "Continuar Localmente",
"exploreCloud": "Experimente a Nuvem Gratuitamente",
"feature1Title": "400 Créditos Grátis por Mês",
"feature2Title": "Funciona em Qualquer Lugar, Instantaneamente",
"feature3Title": "Modelos Prontos para Usar",
"feature4Title": "Principais Pacotes de Nodes Personalizados Pré-instalados",
"footer": "O ComfyUI continua gratuito e de código aberto. A nuvem é opcional.",
"message": "Da configuração à criação em segundos. Modelos populares, extensões e GPUs potentes — prontos quando você quiser.",
"title": "Execute o ComfyUI na Nuvem"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "Abrir no Editor de Máscara",
"Outputs": "Saídas",
"Paste": "Colar",
"Paste Image": "Colar imagem",
"Pin": "Fixar",
"Properties": "Propriedades",
"Properties Panel": "Painel de Propriedades",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "Seu saldo de créditos"
},
"curveWidget": {
"linear": "Linear",
"monotone_cubic": "Suave"
},
"dataTypes": {
"*": "*",
"AUDIO": "ÁUDIO",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "Nós principais da versão {version}:",
"outdatedVersion": "Este fluxo de trabalho foi criado com uma versão mais recente do ComfyUI ({version}). Alguns nós podem não funcionar corretamente.",
"outdatedVersionGeneric": "Este fluxo de trabalho foi criado com uma versão mais recente do ComfyUI. Alguns nós podem não funcionar corretamente.",
"unknownVersion": "desconhecida"
"outdatedVersionGeneric": "Este fluxo de trabalho foi criado com uma versão mais recente do ComfyUI. Alguns nós podem não funcionar corretamente."
},
"maintenance": {
"None": "Nenhum",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "Запрос на сброс пароля отправлен",
"cloudForgotPassword_sendResetLink": "Отправить ссылку для сброса",
"cloudForgotPassword_title": "Забыли пароль",
"cloudNotification": {
"continueLocally": "Продолжить локально",
"exploreCloud": "Попробовать облако бесплатно",
"feature1Title": "400 бесплатных кредитов в месяц",
"feature2Title": "Работает везде и мгновенно",
"feature3Title": "Модели готовы к использованию",
"feature4Title": "Лучшие пользовательские пакеты узлов предустановлены",
"footer": "ComfyUI остаётся бесплатным и с открытым исходным кодом. Облако — по желанию.",
"message": "От настройки до создания за считанные секунды. Популярные модели, расширения и мощные GPU — всё готово, когда вы готовы.",
"title": "Запустите ComfyUI в облаке"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "Открыть в редакторе масок",
"Outputs": "Выходы",
"Paste": "Вставить",
"Paste Image": "Вставить изображение",
"Pin": "Закрепить",
"Properties": "Свойства",
"Properties Panel": "Панель свойств",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "Ваш баланс кредитов"
},
"curveWidget": {
"linear": "Линейная",
"monotone_cubic": "Сглаженная"
},
"dataTypes": {
"*": "*",
"AUDIO": "АУДИО",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "Базовые узлы из версии {version}:",
"outdatedVersion": "Этот рабочий процесс был создан в более новой версии ComfyUI ({version}). Некоторые узлы могут работать некорректно.",
"outdatedVersionGeneric": "Этот рабочий процесс был создан в более новой версии ComfyUI. Некоторые узлы могут работать некорректно.",
"unknownVersion": "неизвестно"
"outdatedVersionGeneric": "Этот рабочий процесс был создан в более новой версии ComfyUI. Некоторые узлы могут работать некорректно."
},
"maintenance": {
"None": "Нет",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "Parola sıfırlama gönderildi",
"cloudForgotPassword_sendResetLink": "Sıfırlama bağlantısını gönder",
"cloudForgotPassword_title": "Şifremi Unuttum",
"cloudNotification": {
"continueLocally": "Yerelde Devam Et",
"exploreCloud": "Bulutu Ücretsiz Dene",
"feature1Title": "Aylık 400 Ücretsiz Kredi",
"feature2Title": "Her Yerde, Anında Çalışır",
"feature3Title": "Kullanıma Hazır Modeller",
"feature4Title": "En İyi Özel Node Paketleri Önceden Yüklü",
"footer": "ComfyUI ücretsiz ve açık kaynak olarak kalır. Bulut seçime bağlıdır.",
"message": "Kurulumdan üretime saniyeler içinde. Popüler modeller, eklentiler ve güçlü GPU'lar — hazır olduğunuzda kullanıma hazır.",
"title": "ComfyUI'yi Bulutta Çalıştırın"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "Maske Düzenleyicide Aç",
"Outputs": ıktılar",
"Paste": "Yapıştır",
"Paste Image": "Görseli Yapıştır",
"Pin": "Sabitle",
"Properties": "Özellikler",
"Properties Panel": "Özellikler Paneli",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "Kredi bakiyeniz"
},
"curveWidget": {
"linear": "Doğrusal",
"monotone_cubic": "Yumuşak"
},
"dataTypes": {
"*": "*",
"AUDIO": "SES",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "Sürüm {version} çekirdek düğümleri:",
"outdatedVersion": "Bu iş akışı ComfyUI'nin daha yeni bir sürümüyle oluşturulmuş ({version}). Bazı düğümler düzgün çalışmayabilir.",
"outdatedVersionGeneric": "Bu iş akışı ComfyUI'nin daha yeni bir sürümüyle oluşturulmuş. Bazı düğümler düzgün çalışmayabilir.",
"unknownVersion": "bilinmeyen"
"outdatedVersionGeneric": "Bu iş akışı ComfyUI'nin daha yeni bir sürümüyle oluşturulmuş. Bazı düğümler düzgün çalışmayabilir."
},
"maintenance": {
"None": "Yok",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "密碼重設已發送",
"cloudForgotPassword_sendResetLink": "寄送重設連結",
"cloudForgotPassword_title": "忘記密碼",
"cloudNotification": {
"continueLocally": "在本地繼續",
"exploreCloud": "免費試用雲端",
"feature1Title": "每月 400 點免費額度",
"feature2Title": "隨處可用,立即啟動",
"feature3Title": "模型即刻可用",
"feature4Title": "頂級自訂節點包預先安裝",
"footer": "ComfyUI 持續免費且開源。雲端服務為選用項目。",
"message": "從設定到創作只需幾秒。熱門模型、擴充套件與強大 GPU隨時待命。",
"title": "在雲端運行 ComfyUI"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "在遮罩編輯器中開啟",
"Outputs": "輸出",
"Paste": "貼上",
"Paste Image": "貼上圖片",
"Pin": "釘選",
"Properties": "屬性",
"Properties Panel": "屬性面板",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "您的點數餘額"
},
"curveWidget": {
"linear": "線性",
"monotone_cubic": "平滑"
},
"dataTypes": {
"*": "*",
"AUDIO": "音訊",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "來自版本 {version} 的核心節點:",
"outdatedVersion": "此工作流程是以較新版本的 ComfyUI{version})建立的。部分節點可能無法正確運作。",
"outdatedVersionGeneric": "此工作流程是以較新版本的 ComfyUI 建立的。部分節點可能無法正確運作。",
"unknownVersion": "未知"
"outdatedVersionGeneric": "此工作流程是以較新版本的 ComfyUI 建立的。部分節點可能無法正確運作。"
},
"maintenance": {
"None": "無",

View File

@@ -389,17 +389,6 @@
"cloudForgotPassword_passwordResetSent": "密码重置邮件已发送",
"cloudForgotPassword_sendResetLink": "发送重置链接",
"cloudForgotPassword_title": "忘记密码",
"cloudNotification": {
"continueLocally": "本地继续",
"exploreCloud": "免费试用云端",
"feature1Title": "每月 400 免费积分",
"feature2Title": "随时随地,立即可用",
"feature3Title": "模型即刻可用",
"feature4Title": "顶级自定义节点包预装",
"footer": "ComfyUI 始终免费且开源。云服务为可选项。",
"message": "从设置到创作只需几秒。热门模型、扩展和强大 GPU —— 随时可用。",
"title": "在云端运行 ComfyUI"
},
"cloudOnboarding": {
"authTimeout": {
"causes": [
@@ -664,7 +653,6 @@
"Open in Mask Editor": "用遮罩编辑器打开",
"Outputs": "输出",
"Paste": "粘贴",
"Paste Image": "粘贴图像",
"Pin": "固定",
"Properties": "属性",
"Properties Panel": "属性面板",
@@ -749,10 +737,6 @@
},
"yourCreditBalance": "您的积分余额"
},
"curveWidget": {
"linear": "线性",
"monotone_cubic": "平滑"
},
"dataTypes": {
"*": "*",
"AUDIO": "音频",
@@ -1618,8 +1602,7 @@
"loadWorkflowWarning": {
"coreNodesFromVersion": "核心节点来源于 {version} 版本。",
"outdatedVersion": "这个工作流由新版 ComfyUI{version})创建,部分节点可能无法正常运行。",
"outdatedVersionGeneric": "这个工作流由新版 ComfyUI 创建,部分节点可能无法正常运行。",
"unknownVersion": "未知"
"outdatedVersionGeneric": "这个工作流由新版 ComfyUI 创建,部分节点可能无法正常运行。"
},
"maintenance": {
"None": "无",

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetItem } from '../schemas/assetSchema'
@@ -10,16 +11,26 @@ const meta: Meta<typeof MediaAssetCard> = {
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { ResultGallery },
(_story, context) => ({
components: { MediaLightbox },
setup() {
const galleryStore = useMediaAssetGalleryStore()
;(context.args as Record<string, unknown>).onZoom = (
asset: AssetItem
) => {
const kind = getMediaTypeFromFilename(asset.name)
galleryStore.openSingle({
...asset,
kind,
src: asset.preview_url || ''
})
}
return { galleryStore }
},
template: `
<div>
<story />
<ResultGallery
<MediaLightbox
v-model:active-index="galleryStore.activeIndex"
:all-gallery-items="galleryStore.items"
/>

View File

@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
type MockItem = Pick<
ResultItemImpl,
'filename' | 'url' | 'isImage' | 'isVideo' | 'isAudio'
>
const SAMPLE_IMAGES: MockItem[] = [
{
filename: 'landscape.jpg',
url: 'https://i.imgur.com/OB0y6MR.jpg',
isImage: true,
isVideo: false,
isAudio: false
},
{
filename: 'portrait.jpg',
url: 'https://i.imgur.com/CzXTtJV.jpg',
isImage: true,
isVideo: false,
isAudio: false
},
{
filename: 'nature.jpg',
url: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
isImage: true,
isVideo: false,
isAudio: false
}
]
const meta: Meta<typeof MediaLightbox> = {
title: 'Platform/Assets/MediaLightbox',
component: MediaLightbox
}
export default meta
type Story = StoryObj<typeof meta>
export const MultipleImages: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(0)
const items = SAMPLE_IMAGES as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Use arrow keys to navigate, Escape to close. Click backdrop to close.
</p>
<div class="flex gap-2">
<button
v-for="(item, i) in items"
:key="i"
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = i"
>
Open {{ item.filename }}
</button>
</div>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}
export const SingleImage: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(0)
const items = [SAMPLE_IMAGES[0]] as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Single image — no navigation buttons shown.
</p>
<button
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = 0"
>
Open lightbox
</button>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}
export const Closed: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(-1)
const items = SAMPLE_IMAGES as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Lightbox is closed (activeIndex = -1). Click a button to open.
</p>
<div class="flex gap-2">
<button
v-for="(item, i) in items"
:key="i"
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = i"
>
{{ item.filename }}
</button>
</div>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}

View File

@@ -1,115 +0,0 @@
<template>
<div class="relative grid h-full grid-cols-5">
<Button
size="unset"
variant="muted-textonly"
class="absolute top-2.5 right-2.5 z-10 size-8 rounded-full p-0 text-white hover:bg-white/20"
:aria-label="t('g.close')"
@click="onDismiss"
>
<i class="pi pi-times" />
</Button>
<div
class="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
>
<video
autoplay
loop
muted
playsinline
class="-ml-[20%] h-full min-w-5/4 object-cover p-0"
>
<source
src="/assets/images/cloud-subscription.webm"
type="video/webm"
/>
</video>
</div>
<div class="col-span-3 flex flex-col justify-between p-8">
<div>
<div class="flex flex-col gap-4">
<div class="text-sm font-semibold text-text-primary">
{{ t('cloudNotification.title') }}
</div>
<p class="m-0 text-sm text-text-secondary">
{{ t('cloudNotification.message') }}
</p>
</div>
<div class="mt-6 flex flex-col items-start gap-0 self-stretch">
<div v-for="n in 4" :key="n" class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ t(`cloudNotification.feature${n}Title`) }}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-2 pt-8">
<Button
variant="primary"
size="lg"
class="w-full font-bold"
@click="onExplore"
>
{{ t('cloudNotification.exploreCloud') }}
</Button>
<Button variant="textonly" size="sm" class="w-full" @click="onDismiss">
{{ t('cloudNotification.continueLocally') }}
</Button>
<p class="m-0 text-center text-xs text-text-secondary">
{{ t('cloudNotification.footer') }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
onMounted(() => {
// Impression event — uses trackUiButtonClicked as no dedicated impression tracker exists
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_modal_impression'
})
})
function onDismiss() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_continue_locally_clicked'
})
useDialogStore().closeDialog()
}
function onExplore() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_explore_cloud_clicked'
})
const params = new URLSearchParams({
utm_source: 'desktop',
utm_medium: 'onload-modal',
utm_campaign: 'local-to-cloud-conversion',
utm_id: 'desktop-onload-modal',
utm_source_platform: 'mac-desktop'
})
window.open(
`https://www.comfy.org/cloud?${params}`,
'_blank',
'noopener,noreferrer'
)
useDialogStore().closeDialog()
}
</script>

View File

@@ -10,7 +10,6 @@ import type {
} from './types'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// eslint-disable-next-line import-x/no-restricted-paths
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type {

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { MissingModelCandidate } from '@/platform/missingModel/types'

View File

@@ -48,7 +48,6 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'

View File

@@ -7,7 +7,6 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */

View File

@@ -6,7 +6,6 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**

View File

@@ -293,12 +293,6 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Desktop.CloudNotificationShown',
name: 'Cloud notification shown',
type: 'hidden',
defaultValue: false
},
{
id: 'Comfy.Graph.ZoomSpeed',
category: ['LiteGraph', 'Canvas', 'ZoomSpeed'],

View File

@@ -54,9 +54,7 @@ watch(
showTimeout = setTimeout(() => {
showTimeout = null
if (!isValidTypeformId.value) return
isVisible.value = true
markSurveyShown()
emit('shown')
}, delayMs.value)
},
@@ -81,6 +79,10 @@ whenever(typeformRef, () => {
document.head.appendChild(scriptEl)
})
function handleAccept() {
markSurveyShown()
}
function handleDismiss() {
isVisible.value = false
emit('dismissed')
@@ -108,18 +110,24 @@ function handleOptOut() {
data-testid="nightly-survey-popover"
class="fixed right-4 bottom-4 z-10000 w-80 rounded-lg border border-border-subtle bg-base-background p-4 shadow-lg"
>
<div class="mb-2 flex items-center justify-end">
<Button
variant="muted-textonly"
size="icon-sm"
<div class="mb-3 flex items-start justify-between">
<h3 class="text-sm font-medium text-text-primary">
{{ t('nightlySurvey.title') }}
</h3>
<button
class="text-text-muted hover:text-text-primary"
:aria-label="t('g.close')"
@click="handleDismiss"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</button>
</div>
<div v-if="typeformError" class="text-danger text-sm">
<p class="mb-4 text-sm text-text-secondary">
{{ t('nightlySurvey.description') }}
</p>
<div v-if="typeformError" class="text-danger mb-4 text-sm">
{{ t('nightlySurvey.loadError') }}
</div>
@@ -131,13 +139,26 @@ function handleOptOut() {
class="min-h-[300px]"
/>
<div class="mt-3 flex items-center justify-center gap-2">
<Button variant="textonly" size="sm" @click="handleDismiss">
{{ t('nightlySurvey.notNow') }}
</Button>
<Button variant="muted-textonly" size="sm" @click="handleOptOut">
{{ t('nightlySurvey.dontAskAgain') }}
<div class="mt-4 flex flex-col gap-2">
<Button variant="primary" class="w-full" @click="handleAccept">
{{ t('nightlySurvey.accept') }}
</Button>
<div class="flex gap-2">
<Button
variant="textonly"
class="flex-1 text-xs"
@click="handleDismiss"
>
{{ t('nightlySurvey.notNow') }}
</Button>
<Button
variant="muted-textonly"
class="flex-1 text-xs"
@click="handleOptOut"
>
{{ t('nightlySurvey.dontAskAgain') }}
</Button>
</div>
</div>
</div>
</Transition>

View File

@@ -4,14 +4,7 @@ import type { FeatureSurveyConfig } from './useSurveyEligibility'
* Registry of all feature surveys.
* Add new surveys here when targeting specific features for feedback.
*/
export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
'node-search': {
featureId: 'node-search',
typeformId: 'goZLqjKL',
triggerThreshold: 3,
delayMs: 5000
}
}
export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {}
export function getSurveyConfig(
featureId: string

View File

@@ -13,7 +13,6 @@ import {
} from '@/platform/workflow/management/stores/workflowStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
// eslint-disable-next-line import-x/no-restricted-paths
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'

View File

@@ -14,7 +14,6 @@ import type {
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
// eslint-disable-next-line import-x/no-restricted-paths
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'

View File

@@ -5,7 +5,6 @@ import { useRoute, useRouter } from 'vue-router'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'

View File

@@ -301,7 +301,6 @@ const zSettings = z.object({
'Comfy.UI.TabBarLayout': z.enum(['Default', 'Legacy']),
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),
'Comfy.Desktop.CloudNotificationShown': z.boolean(),
'Comfy.DisableFloatRounding': z.boolean(),
'Comfy.DisableSliders': z.boolean(),
'Comfy.DOMClippingEnabled': z.boolean(),

View File

@@ -1,6 +1,5 @@
import { promiseTimeout, until } from '@vueuse/core'
import axios from 'axios'
import { storeToRefs } from 'pinia'
import { get } from 'es-toolkit/compat'
import { trimEnd } from 'es-toolkit'
import { ref } from 'vue'
@@ -415,10 +414,9 @@ export class ComfyApi extends EventTarget {
if (authStore.isInitialized) return
const { isInitialized } = storeToRefs(authStore)
try {
await Promise.race([
until(isInitialized).toBe(true),
until(authStore.isInitialized),
promiseTimeout(10000)
])
} catch {

View File

@@ -28,8 +28,6 @@ const lazyUpdatePasswordContent = () =>
import('@/components/dialog/content/UpdatePasswordContent.vue')
const lazyComfyOrgHeader = () =>
import('@/components/dialog/header/ComfyOrgHeader.vue')
const lazyCloudNotificationContent = () =>
import('@/platform/cloud/notification/components/CloudNotificationContent.vue')
export type ConfirmationDialogType =
| 'default'
@@ -553,25 +551,6 @@ export const useDialogService = () => {
})
}
/** Shows one-time cloud notification modal for macOS desktop users. */
async function showCloudNotification(): Promise<void> {
const { default: component } = await lazyCloudNotificationContent()
return new Promise<void>((resolve) => {
showLayoutDialog({
key: 'global-cloud-notification',
component,
props: {},
dialogComponentProps: {
closable: false,
pt: {
root: { class: 'w-170 max-h-[85vh]' }
},
onClose: () => resolve()
}
})
})
}
return {
showExecutionErrorDialog,
showApiNodesSignInDialog,
@@ -580,7 +559,6 @@ export const useDialogService = () => {
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,
showCloudNotification,
prompt,
showErrorDialog,
confirm,

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -51,11 +51,9 @@ vi.mock('@/stores/userStore', () => ({
}))
const mockIsFirebaseInitialized = ref(false)
const mockIsFirebaseAuthenticated = ref(false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
isInitialized: mockIsFirebaseInitialized,
isAuthenticated: mockIsFirebaseAuthenticated
isInitialized: mockIsFirebaseInitialized
}))
}))
@@ -68,7 +66,6 @@ describe('bootstrapStore', () => {
beforeEach(() => {
mockIsSettingsReady.value = false
mockIsFirebaseInitialized.value = false
mockIsFirebaseAuthenticated.value = false
mockNeedsLogin.value = false
mockDistributionTypes.isCloud = false
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -98,23 +95,17 @@ describe('bootstrapStore', () => {
mockDistributionTypes.isCloud = true
})
it('waits for Firebase auth before loading stores', async () => {
it('waits for Firebase auth before loading i18n and settings', async () => {
const store = useBootstrapStore()
const settingStore = useSettingStore()
const bootstrapPromise = store.startStoreBootstrap()
// Bootstrap is blocked waiting for firebase
expect(store.isI18nReady).toBe(false)
expect(settingStore.isReady).toBe(false)
// Firebase initialized but user not yet authenticated
// Unblock by initializing firebase
mockIsFirebaseInitialized.value = true
await nextTick()
expect(store.isI18nReady).toBe(false)
expect(settingStore.isReady).toBe(false)
// User authenticates (e.g. signs in on login page)
mockIsFirebaseAuthenticated.value = true
await bootstrapPromise
await vi.waitFor(() => {

View File

@@ -36,17 +36,14 @@ export const useBootstrapStore = defineStore('bootstrap', () => {
}
async function startStoreBootstrap() {
if (isCloud) {
const { isInitialized, isAuthenticated } = storeToRefs(
useFirebaseAuthStore()
)
await until(isInitialized).toBe(true)
await until(isAuthenticated).toBe(true)
}
const userStore = useUserStore()
await userStore.initialize()
if (isCloud) {
const { isInitialized } = storeToRefs(useFirebaseAuthStore())
await until(isInitialized).toBe(true)
}
const { needsLogin } = storeToRefs(userStore)
await until(needsLogin).toBe(false)

View File

@@ -325,329 +325,6 @@ describe('nodeOutputStore getPreviewParam', () => {
})
})
describe('nodeOutputStore snapshotOutputs / restoreOutputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should round-trip outputs through snapshot and restore', () => {
const store = useNodeOutputStore()
// Set input previews via execution path
const inputOutput = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('3', inputOutput)
const execOutput = createMockOutputs([
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId('4', execOutput)
// Snapshot
const snapshot = store.snapshotOutputs()
// Clear everything
store.resetAllOutputsAndPreviews()
expect(Object.keys(app.nodeOutputs)).toHaveLength(0)
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
// Restore from snapshot
store.restoreOutputs(snapshot)
expect(app.nodeOutputs['3']).toStrictEqual(inputOutput)
expect(app.nodeOutputs['4']).toStrictEqual(execOutput)
expect(store.nodeOutputs['3']).toStrictEqual(inputOutput)
expect(store.nodeOutputs['4']).toStrictEqual(execOutput)
})
it('should preserve outputs across a simulated tab switch cycle', () => {
const store = useNodeOutputStore()
// Tab A: execution produces outputs for two nodes
const outputA1 = createMockOutputs([
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
])
const outputA2 = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('1', outputA1)
store.setNodeOutputsByExecutionId('3', outputA2)
// --- Switch away: store() then clean ---
const tabASnapshot = store.snapshotOutputs()
store.resetAllOutputsAndPreviews()
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
expect(Object.keys(app.nodeOutputs)).toHaveLength(0)
// Tab B: fresh empty workflow (no outputs)
const tabBSnapshot = store.snapshotOutputs()
expect(Object.keys(tabBSnapshot)).toHaveLength(0)
// --- Switch back to Tab A: store Tab B then restore Tab A ---
store.resetAllOutputsAndPreviews()
store.restoreOutputs(tabASnapshot)
// Tab A's outputs should be fully restored
expect(store.nodeOutputs['1']).toStrictEqual(outputA1)
expect(store.nodeOutputs['3']).toStrictEqual(outputA2)
expect(app.nodeOutputs['1']).toStrictEqual(outputA1)
expect(app.nodeOutputs['3']).toStrictEqual(outputA2)
// New execution should still work after restore
const newOutput = createMockOutputs([{ filename: 'new.png' }])
store.setNodeOutputsByExecutionId('5', newOutput)
expect(store.nodeOutputs['5']).toStrictEqual(newOutput)
})
it('should keep tab outputs independent across multiple switches', () => {
const store = useNodeOutputStore()
// Tab A: execute
const outputA = createMockOutputs([{ filename: 'tab_a.png' }])
store.setNodeOutputsByExecutionId('1', outputA)
const snapshotA = store.snapshotOutputs()
// Switch to Tab B
store.resetAllOutputsAndPreviews()
const outputB = createMockOutputs([{ filename: 'tab_b.png' }])
store.setNodeOutputsByExecutionId('1', outputB)
const snapshotB = store.snapshotOutputs()
// Switch back to Tab A
store.resetAllOutputsAndPreviews()
store.restoreOutputs(snapshotA)
expect(store.nodeOutputs['1']?.images?.[0]?.filename).toBe('tab_a.png')
// Switch back to Tab B
const snapshotA2 = store.snapshotOutputs()
store.resetAllOutputsAndPreviews()
store.restoreOutputs(snapshotB)
expect(store.nodeOutputs['1']?.images?.[0]?.filename).toBe('tab_b.png')
// And back to Tab A again - still correct
store.resetAllOutputsAndPreviews()
store.restoreOutputs(snapshotA2)
expect(store.nodeOutputs['1']?.images?.[0]?.filename).toBe('tab_a.png')
})
it('should return a deep clone from snapshotOutputs', () => {
const store = useNodeOutputStore()
const output = createMockOutputs([{ filename: 'a.png' }])
store.setNodeOutputsByExecutionId('1', output)
const snapshot = store.snapshotOutputs()
// Mutate the snapshot
snapshot['1'].images![0].filename = 'mutated.png'
snapshot['99'] = createMockOutputs([{ filename: 'new.png' }])
// Store should be unchanged
expect(store.nodeOutputs['1']?.images?.[0]?.filename).toBe('a.png')
expect(app.nodeOutputs['1']?.images?.[0]?.filename).toBe('a.png')
expect(store.nodeOutputs['99']).toBeUndefined()
})
})
describe('nodeOutputStore resetAllOutputsAndPreviews', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should clear all outputs and previews for multiple nodes', () => {
const store = useNodeOutputStore()
store.setNodeOutputsByExecutionId(
'1',
createMockOutputs([{ filename: 'a.png' }])
)
store.setNodeOutputsByExecutionId(
'2',
createMockOutputs([{ filename: 'b.png' }])
)
store.setNodeOutputsByExecutionId(
'3',
createMockOutputs([{ filename: 'c.png', type: 'input' }])
)
expect(Object.keys(store.nodeOutputs)).toHaveLength(3)
expect(Object.keys(app.nodeOutputs)).toHaveLength(3)
store.resetAllOutputsAndPreviews()
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
expect(Object.keys(app.nodeOutputs)).toHaveLength(0)
expect(Object.keys(app.nodePreviewImages)).toHaveLength(0)
})
})
describe('nodeOutputStore restoreOutputs + execution interaction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should allow execution to update outputs after restore', () => {
const store = useNodeOutputStore()
// Simulate tab restore with existing input preview
const inputOutput = createMockOutputs([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
const savedOutputs: Record<string, ExecutedWsMessage['output']> = {
'3': inputOutput
}
store.restoreOutputs(savedOutputs)
expect(store.nodeOutputs['3']).toStrictEqual(inputOutput)
// Simulate execution sending new output for a different node
const execOutput = createMockOutputs([
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId('4', execOutput)
// Both should be present
expect(store.nodeOutputs['3']).toStrictEqual(inputOutput)
expect(store.nodeOutputs['4']).toStrictEqual(execOutput)
expect(app.nodeOutputs['3']).toStrictEqual(inputOutput)
expect(app.nodeOutputs['4']).toStrictEqual(execOutput)
})
it('should overwrite existing output when execution sends new data for same node', () => {
const store = useNodeOutputStore()
// Restore with input preview
const inputOutput = createMockOutputs([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
store.restoreOutputs({ '3': inputOutput })
// Execution sends new output for the same node (non-merge)
const execOutput = createMockOutputs([
{ filename: 'result.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId('3', execOutput)
// On current main (without PR #9123 guard), execution overwrites
expect(store.nodeOutputs['3']).toStrictEqual(execOutput)
expect(app.nodeOutputs['3']).toStrictEqual(execOutput)
})
})
describe('nodeOutputStore merge mode interactions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should merge new images with existing input preview images', () => {
const store = useNodeOutputStore()
// Set initial input preview
const inputOutput = createMockOutputs([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('3', inputOutput)
// Merge new execution images
const execOutput = createMockOutputs([
{ filename: 'result.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId('3', execOutput, { merge: true })
// Should have both images concatenated
expect(store.nodeOutputs['3']?.images).toHaveLength(2)
expect(app.nodeOutputs['3']?.images).toHaveLength(2)
expect(store.nodeOutputs['3']?.images?.[0]?.filename).toBe('uploaded.png')
expect(store.nodeOutputs['3']?.images?.[1]?.filename).toBe('result.png')
})
it('should not duplicate when merge is called with empty images array', () => {
const store = useNodeOutputStore()
// Set initial input preview
const inputOutput = createMockOutputs([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('3', inputOutput)
// Merge with empty images — the input-preview guard (lines 166-177)
// copies existing input images into the incoming outputs before the
// merge concat runs, resulting in duplication.
const emptyOutput = createMockOutputs([])
store.setNodeOutputsByExecutionId('3', emptyOutput, { merge: true })
expect(store.nodeOutputs['3']?.images).toHaveLength(2)
expect(store.nodeOutputs['3']?.images?.[0]?.filename).toBe('uploaded.png')
expect(store.nodeOutputs['3']?.images?.[1]?.filename).toBe('uploaded.png')
})
})
describe('nodeOutputStore setNodeOutputs (widget path)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should return early for empty string filename', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, '')
expect(store.nodeOutputs['5']).toBeUndefined()
expect(app.nodeOutputs['5']).toBeUndefined()
})
it('should return early for null node', () => {
const store = useNodeOutputStore()
store.setNodeOutputs(null as unknown as LGraphNode, 'test.png')
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
})
it('should set outputs for valid string filename', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, 'test.png')
expect(store.nodeOutputs['5']).toBeDefined()
expect(store.nodeOutputs['5']?.images).toHaveLength(1)
expect(store.nodeOutputs['5']?.images?.[0]?.filename).toBe('test.png')
expect(store.nodeOutputs['5']?.images?.[0]?.type).toBe('input')
})
it('should skip empty array of filenames after createOutputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, [])
expect(store.nodeOutputs['5']).toBeUndefined()
expect(app.nodeOutputs['5']).toBeUndefined()
})
})
describe('nodeOutputStore syncLegacyNodeImgs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -1,4 +1,3 @@
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Utility functions for handling workbench events