mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-25 23:07:46 +00:00
Compare commits
13 Commits
refactor/g
...
invert-ope
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce8918195 | ||
|
|
e710ad5b8e | ||
|
|
e85fc6390a | ||
|
|
4a8f68a6bd | ||
|
|
c8a03b8cf1 | ||
|
|
a75444d56a | ||
|
|
1934064839 | ||
|
|
54fe02bdf1 | ||
|
|
912283a8e2 | ||
|
|
7823cfc83e | ||
|
|
25e1b0e708 | ||
|
|
cdf74c36f7 | ||
|
|
b0d7f38caa |
82
.claude/skills/layer-audit/SKILL.md
Normal file
82
.claude/skills/layer-audit/SKILL.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
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/` |
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -12,7 +12,7 @@ runs:
|
||||
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
109
.github/workflows/api-update-registry-api-types.yaml
vendored
109
.github/workflows/api-update-registry-api-types.yaml
vendored
@@ -1,109 +0,0 @@
|
||||
# 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
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
1
.github/workflows/ci-perf-report.yaml
vendored
1
.github/workflows/ci-perf-report.yaml
vendored
@@ -64,6 +64,7 @@ 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'
|
||||
|
||||
7
.github/workflows/ci-size-data.yaml
vendored
7
.github/workflows/ci-size-data.yaml
vendored
@@ -8,6 +8,10 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: size-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -28,11 +32,12 @@ jobs:
|
||||
- name: Collect size data
|
||||
run: node scripts/size-collect.js
|
||||
|
||||
- name: Save PR number & base branch
|
||||
- name: Save PR metadata
|
||||
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
|
||||
|
||||
2
.github/workflows/ci-tests-e2e.yaml
vendored
2
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -144,7 +144,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
148
.github/workflows/pr-perf-report.yaml
vendored
148
.github/workflows/pr-perf-report.yaml
vendored
@@ -1,148 +0,0 @@
|
||||
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 }}
|
||||
220
.github/workflows/pr-report.yaml
vendored
Normal file
220
.github/workflows/pr-report.yaml
vendored
Normal file
@@ -0,0 +1,220 @@
|
||||
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
133
.github/workflows/pr-size-report.yaml
vendored
@@ -1,133 +0,0 @@
|
||||
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 }}
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
path: comfyui
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
ref: v${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/release-draft-create.yaml
vendored
2
.github/workflows/release-draft-create.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/release-npm-types.yaml
vendored
2
.github/workflows/release-npm-types.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/release-pypi-dev.yaml
vendored
2
.github/workflows/release-pypi-dev.yaml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/release-version-bump.yaml
vendored
2
.github/workflows/release-version-bump.yaml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/weekly-docs-check.yaml
vendored
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
102
browser_tests/tests/bottomPanelLogs.spec.ts
Normal file
102
browser_tests/tests/bottomPanelLogs.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
65
browser_tests/tests/focusMode.spec.ts
Normal file
65
browser_tests/tests/focusMode.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
91
browser_tests/tests/jobHistoryActions.spec.ts
Normal file
91
browser_tests/tests/jobHistoryActions.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
143
browser_tests/tests/nodeSearchBoxV2Extended.spec.ts
Normal file
143
browser_tests/tests/nodeSearchBoxV2Extended.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -222,6 +222,84 @@ 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')
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
119
browser_tests/tests/rightSidePanelTabs.spec.ts
Normal file
119
browser_tests/tests/rightSidePanelTabs.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -305,6 +305,49 @@ 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
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.1",
|
||||
"version": "1.43.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -382,7 +382,13 @@ function renderCategoryBlock(category, hasBaseline) {
|
||||
? ['File', 'Before', 'After', 'Δ Raw', 'Δ Gzip', 'Δ Brotli']
|
||||
: ['File', 'Size', 'Gzip', 'Brotli']
|
||||
|
||||
const rows = category.bundles
|
||||
// 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
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const diffMagnitude = Math.abs(b.diff.size) - Math.abs(a.diff.size)
|
||||
@@ -409,8 +415,10 @@ function renderCategoryBlock(category, hasBaseline) {
|
||||
]
|
||||
})
|
||||
|
||||
lines.push(markdownTable([headers, ...rows]))
|
||||
lines.push('')
|
||||
if (rows.length > 0) {
|
||||
lines.push(markdownTable([headers, ...rows]))
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const statusParts = []
|
||||
if (category.counts.added) statusParts.push(`${category.counts.added} added`)
|
||||
@@ -420,6 +428,7 @@ 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(' / ')}`)
|
||||
|
||||
75
scripts/unified-report.js
Normal file
75
scripts/unified-report.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// @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')
|
||||
25
src/App.vue
25
src/App.vue
@@ -7,18 +7,20 @@
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, 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()
|
||||
@@ -126,5 +128,26 @@ 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>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* 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
|
||||
|
||||
@@ -60,7 +60,7 @@ const mountComponent = (
|
||||
stubs: {
|
||||
QueueOverlayExpanded: QueueOverlayExpandedStub,
|
||||
QueueOverlayActive: true,
|
||||
MediaLightbox: true
|
||||
ResultGallery: true
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaLightbox
|
||||
<ResultGallery
|
||||
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 MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
|
||||
|
||||
@@ -92,11 +92,36 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('JobAssetsList', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
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', () => {
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
@@ -248,6 +273,53 @@ 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' })
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
class="job-details-popover fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
right: `${popoverPosition.right}px`
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
@@ -101,6 +101,7 @@ 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'
|
||||
@@ -121,7 +122,7 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; right: number } | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
@@ -144,11 +145,7 @@ function updatePopoverPosition() {
|
||||
if (!rowElement) return
|
||||
|
||||
const rect = rowElement.getBoundingClientRect()
|
||||
const gap = 8
|
||||
popoverPosition.value = {
|
||||
top: rect.top,
|
||||
right: window.innerWidth - rect.left + gap
|
||||
}
|
||||
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
|
||||
}
|
||||
|
||||
function onJobLeave(jobId: string) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
right: `${popoverPosition.right}px`
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
@@ -26,7 +26,7 @@
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
right: `${popoverPosition.right}px`
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPreviewEnter"
|
||||
@mouseleave="onPreviewLeave"
|
||||
@@ -191,6 +191,7 @@ 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'
|
||||
@@ -298,17 +299,13 @@ const onIconLeave = () => scheduleHidePreview()
|
||||
const onPreviewEnter = () => scheduleShowPreview()
|
||||
const onPreviewLeave = () => scheduleHidePreview()
|
||||
|
||||
const popoverPosition = ref<{ top: number; right: number } | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const el = rowRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const gap = 8
|
||||
popoverPosition.value = {
|
||||
top: rect.top,
|
||||
right: window.innerWidth - rect.left + gap
|
||||
}
|
||||
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
|
||||
}
|
||||
|
||||
const isAnyPopoverVisible = computed(
|
||||
|
||||
61
src/components/queue/job/getHoverPopoverPosition.test.ts
Normal file
61
src/components/queue/job/getHoverPopoverPosition.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
39
src/components/queue/job/getHoverPopoverPosition.ts
Normal file
39
src/components/queue/job/getHoverPopoverPosition.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ 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'
|
||||
@@ -84,6 +85,7 @@ let disconnectOnReset = false
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
|
||||
|
||||
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
|
||||
storeToRefs(searchBoxStore)
|
||||
@@ -165,6 +167,7 @@ function getFirstLink() {
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function showNewSearchBox(e: CanvasPointerEvent | null) {
|
||||
trackFeatureUsed()
|
||||
const firstLink = getFirstLink()
|
||||
if (firstLink) {
|
||||
const filter =
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<MediaLightbox
|
||||
<ResultGallery
|
||||
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 MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
163
src/components/sidebar/tabs/JobHistorySidebarTab.test.ts
Normal file
163
src/components/sidebar/tabs/JobHistorySidebarTab.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -58,7 +58,7 @@
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
<ResultGallery
|
||||
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 MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
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])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
<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>
|
||||
184
src/components/sidebar/tabs/queue/ResultGallery.test.ts
Normal file
184
src/components/sidebar/tabs/queue/ResultGallery.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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
|
||||
})
|
||||
151
src/components/sidebar/tabs/queue/ResultGallery.vue
Normal file
151
src/components/sidebar/tabs/queue/ResultGallery.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<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>
|
||||
@@ -28,9 +28,8 @@ 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-sm': 'size-5 p-0',
|
||||
icon: 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
'icon-sm': 'size-5 p-0',
|
||||
unset: ''
|
||||
}
|
||||
},
|
||||
@@ -55,13 +54,8 @@ const variants = [
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'icon-sm',
|
||||
'icon',
|
||||
'icon-lg'
|
||||
] as const satisfies Array<ButtonVariants['size']>
|
||||
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
|
||||
ButtonVariants['size']
|
||||
>
|
||||
|
||||
export const FOR_STORIES = { variants, sizes } as const
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"cloudForgotPassword_passwordResetSent": "تم إرسال إعادة تعيين كلمة المرور",
|
||||
"cloudForgotPassword_sendResetLink": "إرسال رابط إعادة التعيين",
|
||||
"cloudForgotPassword_title": "نسيت كلمة المرور",
|
||||
"cloudNotification": {
|
||||
"continueLocally": "المتابعة محليًا",
|
||||
"exploreCloud": "جرّب السحابة مجانًا",
|
||||
"feature1Title": "٤٠٠ رصيد مجاني شهريًا",
|
||||
"feature2Title": "يعمل في أي مكان، فورًا",
|
||||
"feature3Title": "نماذج جاهزة للاستخدام",
|
||||
"feature4Title": "أفضل حزم العقد المخصصة مثبتة مسبقًا",
|
||||
"footer": "ComfyUI يبقى مجانيًا ومفتوح المصدر. السحابة اختيارية.",
|
||||
"message": "من الإعداد إلى الإنشاء في ثوانٍ. النماذج الشائعة، الإضافات، ومعالجات الرسوميات القوية — جاهزة متى احتجت إليها.",
|
||||
"title": "تشغيل ComfyUI على السحابة"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"authTimeout": {
|
||||
"causes": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"Open in Mask Editor": "افتح في محرر القناع",
|
||||
"Outputs": "المخرجات",
|
||||
"Paste": "لصق",
|
||||
"Paste Image": "لصق الصورة",
|
||||
"Pin": "تثبيت",
|
||||
"Properties": "الخصائص",
|
||||
"Properties Panel": "لوحة الخصائص",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "رصيدك الحالي"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "خطّي",
|
||||
"monotone_cubic": "ناعم"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "صوت",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "العقد الأساسية من الإصدار {version}:",
|
||||
"outdatedVersion": "تم إنشاء سير العمل هذا باستخدام إصدار أحدث من ComfyUI ({version}). قد لا تعمل بعض العقد بشكل صحيح.",
|
||||
"outdatedVersionGeneric": "تم إنشاء سير العمل هذا باستخدام إصدار أحدث من ComfyUI. قد لا تعمل بعض العقد بشكل صحيح."
|
||||
"outdatedVersionGeneric": "تم إنشاء سير العمل هذا باستخدام إصدار أحدث من ComfyUI. قد لا تعمل بعض العقد بشكل صحيح.",
|
||||
"unknownVersion": "غير معروف"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "لا شيء",
|
||||
|
||||
@@ -136,11 +136,9 @@
|
||||
"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",
|
||||
@@ -3629,5 +3627,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13850,7 +13850,7 @@
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"tooltip": "Optional: The number of steps to LoRA has been trained for, used to name the saved file."
|
||||
"tooltip": "Optional: The number of steps the 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."
|
||||
"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."
|
||||
},
|
||||
"lora_dtype": {
|
||||
"name": "lora_dtype",
|
||||
@@ -15993,7 +15993,7 @@
|
||||
},
|
||||
"offloading": {
|
||||
"name": "offloading",
|
||||
"tooltip": "Offload the Model to RAM. Requires Bypass Mode."
|
||||
"tooltip": "Offload model weights to CPU during training to save GPU memory."
|
||||
},
|
||||
"existing_lora": {
|
||||
"name": "existing_lora",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"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": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"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",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "Tu saldo de créditos"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "Lineal",
|
||||
"monotone_cubic": "Suave"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "AUDIO",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Ninguno",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"cloudForgotPassword_passwordResetSent": "لینک بازنشانی رمز عبور ارسال شد",
|
||||
"cloudForgotPassword_sendResetLink": "ارسال لینک بازنشانی",
|
||||
"cloudForgotPassword_title": "فراموشی رمز عبور",
|
||||
"cloudNotification": {
|
||||
"continueLocally": "ادامه به صورت محلی",
|
||||
"exploreCloud": "امتحان رایگان فضای ابری",
|
||||
"feature1Title": "۴۰۰ اعتبار رایگان ماهانه",
|
||||
"feature2Title": "قابل استفاده در هر مکان، بلافاصله",
|
||||
"feature3Title": "مدلها آماده استفاده",
|
||||
"feature4Title": "برترین بستههای Node سفارشی از پیش نصبشده",
|
||||
"footer": "ComfyUI رایگان و متنباز باقی میماند. استفاده از فضای ابری اختیاری است.",
|
||||
"message": "از راهاندازی تا خلق اثر در چند ثانیه. مدلهای محبوب، افزونهها و GPUهای قدرتمند — آماده برای شما.",
|
||||
"title": "اجرای ComfyUI در فضای ابری"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"authTimeout": {
|
||||
"causes": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"Open in Mask Editor": "باز کردن در Mask Editor",
|
||||
"Outputs": "خروجیها",
|
||||
"Paste": "چسباندن",
|
||||
"Paste Image": "چسباندن تصویر",
|
||||
"Pin": "سنجاق کردن",
|
||||
"Properties": "ویژگیها",
|
||||
"Properties Panel": "پنل ویژگیها",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "موجودی اعتبار شما"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "خطی",
|
||||
"monotone_cubic": "صاف"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "صوت",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "nodeهای اصلی از نسخه {version}:",
|
||||
"outdatedVersion": "این workflow با نسخه جدیدتری از ComfyUI ({version}) ایجاد شده است. برخی nodeها ممکن است به درستی کار نکنند.",
|
||||
"outdatedVersionGeneric": "این workflow با نسخه جدیدتری از ComfyUI ایجاد شده است. برخی nodeها ممکن است به درستی کار نکنند."
|
||||
"outdatedVersionGeneric": "این workflow با نسخه جدیدتری از ComfyUI ایجاد شده است. برخی nodeها ممکن است به درستی کار نکنند.",
|
||||
"unknownVersion": "نامشخص"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "هیچکدام",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"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 à l’emploi",
|
||||
"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": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"Open in Mask Editor": "Ouvrir dans l'éditeur de masque",
|
||||
"Outputs": "Sorties",
|
||||
"Paste": "Coller",
|
||||
"Paste Image": "Coller l’image",
|
||||
"Pin": "Épingler",
|
||||
"Properties": "Propriétés",
|
||||
"Properties Panel": "Panneau des Propriétés",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "Votre solde de crédits"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "Linéaire",
|
||||
"monotone_cubic": "Lisse"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "AUDIO",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"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."
|
||||
"outdatedVersionGeneric": "Ce workflow a été créé avec une version plus récente de ComfyUI. Certains nœuds peuvent ne pas fonctionner correctement.",
|
||||
"unknownVersion": "inconnue"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Aucun",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"cloudForgotPassword_passwordResetSent": "パスワードリセットを送信しました",
|
||||
"cloudForgotPassword_sendResetLink": "リセットリンクを送信",
|
||||
"cloudForgotPassword_title": "パスワードを忘れた場合",
|
||||
"cloudNotification": {
|
||||
"continueLocally": "ローカルで続行",
|
||||
"exploreCloud": "クラウドを無料で試す",
|
||||
"feature1Title": "毎月400クレジット無料",
|
||||
"feature2Title": "どこでも即時利用可能",
|
||||
"feature3Title": "すぐに使えるモデル",
|
||||
"feature4Title": "人気カスタムノードパックをプリインストール",
|
||||
"footer": "ComfyUIは無料かつオープンソースのままです。クラウド利用は任意です。",
|
||||
"message": "セットアップから作成まで数秒。人気モデル、拡張機能、強力なGPUがすぐに利用可能。",
|
||||
"title": "ComfyUIをクラウドで実行"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"authTimeout": {
|
||||
"causes": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"Open in Mask Editor": "マスクエディタで開く",
|
||||
"Outputs": "出力",
|
||||
"Paste": "貼り付け",
|
||||
"Paste Image": "画像を貼り付け",
|
||||
"Pin": "ピン",
|
||||
"Properties": "プロパティ",
|
||||
"Properties Panel": "プロパティパネル",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "あなたのクレジット残高"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "リニア",
|
||||
"monotone_cubic": "スムーズ"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "オーディオ",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "バージョン{version}のコアノード:",
|
||||
"outdatedVersion": "このワークフローは新しいバージョンのComfyUI({version})で作成されました。一部のノードが正しく動作しない場合があります。",
|
||||
"outdatedVersionGeneric": "このワークフローは新しいバージョンのComfyUIで作成されました。一部のノードが正しく動作しない場合があります。"
|
||||
"outdatedVersionGeneric": "このワークフローは新しいバージョンのComfyUIで作成されました。一部のノードが正しく動作しない場合があります。",
|
||||
"unknownVersion": "不明"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "なし",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"cloudForgotPassword_passwordResetSent": "비밀번호 재설정 전송됨",
|
||||
"cloudForgotPassword_sendResetLink": "재설정 링크 보내기",
|
||||
"cloudForgotPassword_title": "비밀번호 찾기",
|
||||
"cloudNotification": {
|
||||
"continueLocally": "로컬에서 계속하기",
|
||||
"exploreCloud": "클라우드 무료 체험",
|
||||
"feature1Title": "매월 400 크레딧 무료 제공",
|
||||
"feature2Title": "어디서나 즉시 사용 가능",
|
||||
"feature3Title": "즉시 사용 가능한 모델",
|
||||
"feature4Title": "최고의 커스텀 노드 팩 사전 설치",
|
||||
"footer": "ComfyUI는 계속 무료이자 오픈 소스입니다. 클라우드 사용은 선택 사항입니다.",
|
||||
"message": "설정부터 창작까지 몇 초 만에. 인기 모델, 확장 기능, 강력한 GPU — 언제든 바로 사용 가능합니다.",
|
||||
"title": "ComfyUI 클라우드에서 실행하기"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"authTimeout": {
|
||||
"causes": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"Open in Mask Editor": "마스크 편집기에서 열기",
|
||||
"Outputs": "출력",
|
||||
"Paste": "붙여넣기",
|
||||
"Paste Image": "이미지 붙여넣기",
|
||||
"Pin": "고정",
|
||||
"Properties": "속성",
|
||||
"Properties Panel": "속성 패널",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "보유 크레딧 잔액"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "직선",
|
||||
"monotone_cubic": "부드럽게"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "오디오",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "버전 {version}의 코어 노드:",
|
||||
"outdatedVersion": "이 워크플로우는 더 최신 버전의 ComfyUI({version})에서 생성되었습니다. 일부 노드는 제대로 작동하지 않을 수 있습니다.",
|
||||
"outdatedVersionGeneric": "이 워크플로우는 더 최신 버전의 ComfyUI에서 생성되었습니다. 일부 노드는 제대로 작동하지 않을 수 있습니다."
|
||||
"outdatedVersionGeneric": "이 워크플로우는 더 최신 버전의 ComfyUI에서 생성되었습니다. 일부 노드는 제대로 작동하지 않을 수 있습니다.",
|
||||
"unknownVersion": "알 수 없음"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "없음",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"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": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"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",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "Seu saldo de créditos"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "Linear",
|
||||
"monotone_cubic": "Suave"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "ÁUDIO",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Nenhum",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"cloudForgotPassword_passwordResetSent": "Запрос на сброс пароля отправлен",
|
||||
"cloudForgotPassword_sendResetLink": "Отправить ссылку для сброса",
|
||||
"cloudForgotPassword_title": "Забыли пароль",
|
||||
"cloudNotification": {
|
||||
"continueLocally": "Продолжить локально",
|
||||
"exploreCloud": "Попробовать облако бесплатно",
|
||||
"feature1Title": "400 бесплатных кредитов в месяц",
|
||||
"feature2Title": "Работает везде и мгновенно",
|
||||
"feature3Title": "Модели готовы к использованию",
|
||||
"feature4Title": "Лучшие пользовательские пакеты узлов предустановлены",
|
||||
"footer": "ComfyUI остаётся бесплатным и с открытым исходным кодом. Облако — по желанию.",
|
||||
"message": "От настройки до создания за считанные секунды. Популярные модели, расширения и мощные GPU — всё готово, когда вы готовы.",
|
||||
"title": "Запустите ComfyUI в облаке"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"authTimeout": {
|
||||
"causes": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"Open in Mask Editor": "Открыть в редакторе масок",
|
||||
"Outputs": "Выходы",
|
||||
"Paste": "Вставить",
|
||||
"Paste Image": "Вставить изображение",
|
||||
"Pin": "Закрепить",
|
||||
"Properties": "Свойства",
|
||||
"Properties Panel": "Панель свойств",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "Ваш баланс кредитов"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "Линейная",
|
||||
"monotone_cubic": "Сглаженная"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "АУДИО",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Базовые узлы из версии {version}:",
|
||||
"outdatedVersion": "Этот рабочий процесс был создан в более новой версии ComfyUI ({version}). Некоторые узлы могут работать некорректно.",
|
||||
"outdatedVersionGeneric": "Этот рабочий процесс был создан в более новой версии ComfyUI. Некоторые узлы могут работать некорректно."
|
||||
"outdatedVersionGeneric": "Этот рабочий процесс был создан в более новой версии ComfyUI. Некоторые узлы могут работать некорректно.",
|
||||
"unknownVersion": "неизвестно"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Нет",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"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": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"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",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "Kredi bakiyeniz"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "Doğrusal",
|
||||
"monotone_cubic": "Yumuşak"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "SES",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Yok",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"cloudForgotPassword_passwordResetSent": "密碼重設已發送",
|
||||
"cloudForgotPassword_sendResetLink": "寄送重設連結",
|
||||
"cloudForgotPassword_title": "忘記密碼",
|
||||
"cloudNotification": {
|
||||
"continueLocally": "在本地繼續",
|
||||
"exploreCloud": "免費試用雲端",
|
||||
"feature1Title": "每月 400 點免費額度",
|
||||
"feature2Title": "隨處可用,立即啟動",
|
||||
"feature3Title": "模型即刻可用",
|
||||
"feature4Title": "頂級自訂節點包預先安裝",
|
||||
"footer": "ComfyUI 持續免費且開源。雲端服務為選用項目。",
|
||||
"message": "從設定到創作只需幾秒。熱門模型、擴充套件與強大 GPU,隨時待命。",
|
||||
"title": "在雲端運行 ComfyUI"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"authTimeout": {
|
||||
"causes": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"Open in Mask Editor": "在遮罩編輯器中開啟",
|
||||
"Outputs": "輸出",
|
||||
"Paste": "貼上",
|
||||
"Paste Image": "貼上圖片",
|
||||
"Pin": "釘選",
|
||||
"Properties": "屬性",
|
||||
"Properties Panel": "屬性面板",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "您的點數餘額"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "線性",
|
||||
"monotone_cubic": "平滑"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "音訊",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "來自版本 {version} 的核心節點:",
|
||||
"outdatedVersion": "此工作流程是以較新版本的 ComfyUI({version})建立的。部分節點可能無法正確運作。",
|
||||
"outdatedVersionGeneric": "此工作流程是以較新版本的 ComfyUI 建立的。部分節點可能無法正確運作。"
|
||||
"outdatedVersionGeneric": "此工作流程是以較新版本的 ComfyUI 建立的。部分節點可能無法正確運作。",
|
||||
"unknownVersion": "未知"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "無",
|
||||
|
||||
@@ -389,6 +389,17 @@
|
||||
"cloudForgotPassword_passwordResetSent": "密码重置邮件已发送",
|
||||
"cloudForgotPassword_sendResetLink": "发送重置链接",
|
||||
"cloudForgotPassword_title": "忘记密码",
|
||||
"cloudNotification": {
|
||||
"continueLocally": "本地继续",
|
||||
"exploreCloud": "免费试用云端",
|
||||
"feature1Title": "每月 400 免费积分",
|
||||
"feature2Title": "随时随地,立即可用",
|
||||
"feature3Title": "模型即刻可用",
|
||||
"feature4Title": "顶级自定义节点包预装",
|
||||
"footer": "ComfyUI 始终免费且开源。云服务为可选项。",
|
||||
"message": "从设置到创作只需几秒。热门模型、扩展和强大 GPU —— 随时可用。",
|
||||
"title": "在云端运行 ComfyUI"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"authTimeout": {
|
||||
"causes": [
|
||||
@@ -653,6 +664,7 @@
|
||||
"Open in Mask Editor": "用遮罩编辑器打开",
|
||||
"Outputs": "输出",
|
||||
"Paste": "粘贴",
|
||||
"Paste Image": "粘贴图像",
|
||||
"Pin": "固定",
|
||||
"Properties": "属性",
|
||||
"Properties Panel": "属性面板",
|
||||
@@ -737,6 +749,10 @@
|
||||
},
|
||||
"yourCreditBalance": "您的积分余额"
|
||||
},
|
||||
"curveWidget": {
|
||||
"linear": "线性",
|
||||
"monotone_cubic": "平滑"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "音频",
|
||||
@@ -1602,7 +1618,8 @@
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "核心节点来源于 {version} 版本。",
|
||||
"outdatedVersion": "这个工作流由新版 ComfyUI({version})创建,部分节点可能无法正常运行。",
|
||||
"outdatedVersionGeneric": "这个工作流由新版 ComfyUI 创建,部分节点可能无法正常运行。"
|
||||
"outdatedVersionGeneric": "这个工作流由新版 ComfyUI 创建,部分节点可能无法正常运行。",
|
||||
"unknownVersion": "未知"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "无",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
@@ -11,26 +10,16 @@ const meta: Meta<typeof MediaAssetCard> = {
|
||||
title: 'Platform/Assets/MediaAssetCard',
|
||||
component: MediaAssetCard,
|
||||
decorators: [
|
||||
(_story, context) => ({
|
||||
components: { MediaLightbox },
|
||||
() => ({
|
||||
components: { ResultGallery },
|
||||
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 />
|
||||
<MediaLightbox
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryStore.activeIndex"
|
||||
:all-gallery-items="galleryStore.items"
|
||||
/>
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<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>
|
||||
@@ -10,6 +10,7 @@ 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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
|
||||
@@ -48,6 +48,7 @@ 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'
|
||||
|
||||
@@ -7,6 +7,7 @@ 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. */
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -293,6 +293,12 @@ 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'],
|
||||
|
||||
@@ -54,7 +54,9 @@ watch(
|
||||
|
||||
showTimeout = setTimeout(() => {
|
||||
showTimeout = null
|
||||
if (!isValidTypeformId.value) return
|
||||
isVisible.value = true
|
||||
markSurveyShown()
|
||||
emit('shown')
|
||||
}, delayMs.value)
|
||||
},
|
||||
@@ -79,10 +81,6 @@ whenever(typeformRef, () => {
|
||||
document.head.appendChild(scriptEl)
|
||||
})
|
||||
|
||||
function handleAccept() {
|
||||
markSurveyShown()
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
isVisible.value = false
|
||||
emit('dismissed')
|
||||
@@ -110,24 +108,18 @@ 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-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"
|
||||
<div class="mb-2 flex items-center justify-end">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="handleDismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-sm text-text-secondary">
|
||||
{{ t('nightlySurvey.description') }}
|
||||
</p>
|
||||
|
||||
<div v-if="typeformError" class="text-danger mb-4 text-sm">
|
||||
<div v-if="typeformError" class="text-danger text-sm">
|
||||
{{ t('nightlySurvey.loadError') }}
|
||||
</div>
|
||||
|
||||
@@ -139,26 +131,13 @@ function handleOptOut() {
|
||||
class="min-h-[300px]"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<Button variant="primary" class="w-full" @click="handleAccept">
|
||||
{{ t('nightlySurvey.accept') }}
|
||||
<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') }}
|
||||
</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>
|
||||
|
||||
@@ -4,7 +4,14 @@ 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> = {}
|
||||
export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
|
||||
'node-search': {
|
||||
featureId: 'node-search',
|
||||
typeformId: 'goZLqjKL',
|
||||
triggerThreshold: 3,
|
||||
delayMs: 5000
|
||||
}
|
||||
}
|
||||
|
||||
export function getSurveyConfig(
|
||||
featureId: string
|
||||
|
||||
@@ -13,6 +13,7 @@ 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'
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
|
||||
@@ -301,6 +301,7 @@ 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(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -414,9 +415,10 @@ export class ComfyApi extends EventTarget {
|
||||
|
||||
if (authStore.isInitialized) return
|
||||
|
||||
const { isInitialized } = storeToRefs(authStore)
|
||||
try {
|
||||
await Promise.race([
|
||||
until(authStore.isInitialized),
|
||||
until(isInitialized).toBe(true),
|
||||
promiseTimeout(10000)
|
||||
])
|
||||
} catch {
|
||||
|
||||
@@ -28,6 +28,8 @@ 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'
|
||||
@@ -551,6 +553,25 @@ 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,
|
||||
@@ -559,6 +580,7 @@ export const useDialogService = () => {
|
||||
showTopUpCreditsDialog,
|
||||
showUpdatePasswordDialog,
|
||||
showExtensionDialog,
|
||||
showCloudNotification,
|
||||
prompt,
|
||||
showErrorDialog,
|
||||
confirm,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
@@ -51,9 +51,11 @@ vi.mock('@/stores/userStore', () => ({
|
||||
}))
|
||||
|
||||
const mockIsFirebaseInitialized = ref(false)
|
||||
const mockIsFirebaseAuthenticated = ref(false)
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
isInitialized: mockIsFirebaseInitialized
|
||||
isInitialized: mockIsFirebaseInitialized,
|
||||
isAuthenticated: mockIsFirebaseAuthenticated
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -66,6 +68,7 @@ describe('bootstrapStore', () => {
|
||||
beforeEach(() => {
|
||||
mockIsSettingsReady.value = false
|
||||
mockIsFirebaseInitialized.value = false
|
||||
mockIsFirebaseAuthenticated.value = false
|
||||
mockNeedsLogin.value = false
|
||||
mockDistributionTypes.isCloud = false
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -95,17 +98,23 @@ describe('bootstrapStore', () => {
|
||||
mockDistributionTypes.isCloud = true
|
||||
})
|
||||
|
||||
it('waits for Firebase auth before loading i18n and settings', async () => {
|
||||
it('waits for Firebase auth before loading stores', 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)
|
||||
|
||||
// Unblock by initializing firebase
|
||||
// Firebase initialized but user not yet authenticated
|
||||
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(() => {
|
||||
|
||||
@@ -36,14 +36,17 @@ 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)
|
||||
|
||||
|
||||
@@ -325,6 +325,329 @@ 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 }))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
/**
|
||||
* Utility functions for handling workbench events
|
||||
|
||||
Reference in New Issue
Block a user