Compare commits

..

16 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,13 +97,6 @@
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},

View File

@@ -2,11 +2,6 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
@@ -37,28 +32,6 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -232,7 +232,7 @@ test.describe('Workflows sidebar', () => {
.toEqual('workflow1')
})
test('Reports missing nodes warning again when switching back to workflow', async ({
test('Does not report warning when switching between opened workflows', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -254,11 +254,10 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow — overlay should reappear
// so users can install missing node packs without a page reload
// Switch back to the missing_nodes workflow — overlay should not reappear
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).toBeVisible()
await expect(errorOverlay).not.toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({

View File

@@ -631,29 +631,6 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
})
test.describe('DOM Widget Promotion', () => {

View File

@@ -1,160 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import {
getPromotedWidgetSnapshot,
getPromotedWidgets
} from '../helpers/promotedWidgets'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph', '@widget'] }, () => {
test('hydrates legacy proxyWidgets deterministically across reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-duplicate-ids'
)
await comfyPage.nextFrame()
const firstSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
expect(firstSnapshot.proxyWidgets.length).toBeGreaterThan(0)
expect(
firstSnapshot.proxyWidgets.every(([nodeId]) => nodeId !== '-1')
).toBe(true)
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-duplicate-ids'
)
await comfyPage.nextFrame()
const secondSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
expect(secondSnapshot.proxyWidgets).toEqual(firstSnapshot.proxyWidgets)
expect(secondSnapshot.widgetNames).toEqual(firstSnapshot.widgetNames)
})
test('promoted view falls back to disconnected placeholder after source widget removal', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const projection = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById('11')
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
)
throw new Error('Expected host subgraph node 11')
const beforeType = hostNode.widgets?.[0]?.type
const proxyWidgets = Array.isArray(hostNode.properties?.proxyWidgets)
? hostNode.properties.proxyWidgets.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
: []
const firstPromotion = proxyWidgets[0]
if (!firstPromotion)
throw new Error('Expected at least one promoted widget entry')
const [sourceNodeId, sourceWidgetName] = firstPromotion
const subgraph = graph.subgraphs.get(hostNode.type)
const sourceNode = subgraph?.getNodeById(Number(sourceNodeId))
if (!sourceNode?.widgets)
throw new Error('Expected promoted source node widget list')
sourceNode.widgets = sourceNode.widgets.filter(
(widget) => widget.name !== sourceWidgetName
)
return {
beforeType,
afterType: hostNode.widgets?.[0]?.type
}
})
expect(projection.beforeType).toBe('customtext')
expect(projection.afterType).toBe('button')
})
test('unpacking one preview host keeps remaining pseudo-preview promotions resolvable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const beforeNode8 = await getPromotedWidgets(comfyPage, '8')
expect(beforeNode8).toEqual([['6', '$$canvas-image-preview']])
const cleanupResult = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const invalidPseudoEntries = () => {
const invalid: string[] = []
for (const node of graph.nodes) {
if (
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
)
continue
const subgraph = graph.subgraphs.get(node.type)
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
: []
for (const entry of proxyWidgets) {
if (entry[1] !== '$$canvas-image-preview') continue
const sourceNodeId = Number(entry[0])
const sourceNode = subgraph?.getNodeById(sourceNodeId)
if (!sourceNode) invalid.push(`${node.id}:${entry[0]}`)
}
}
return invalid
}
const before = invalidPseudoEntries()
const hostNode = graph.getNodeById('7')
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
)
throw new Error('Expected preview host subgraph node 7')
;(
graph as unknown as { unpackSubgraph: (node: unknown) => void }
).unpackSubgraph(hostNode)
return {
before,
after: invalidPseudoEntries(),
hasNode7: Boolean(graph.getNodeById('7')),
hasNode8: Boolean(graph.getNodeById('8'))
}
})
expect(cleanupResult.before).toEqual([])
expect(cleanupResult.after).toEqual([])
expect(cleanupResult.hasNode7).toBe(false)
expect(cleanupResult.hasNode8).toBe(true)
const afterNode8 = await getPromotedWidgets(comfyPage, '8')
expect(afterNode8).toEqual([['6', '$$canvas-image-preview']])
})
})

View File

@@ -1,24 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
test.describe('Vue Reroute Node Size', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.workflow.loadWorkflow('links/single_connected_reroute_node')
await comfyPage.vueNodes.waitForNodes()
})
test(
'reroute node visual appearance',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-reroute-node-compact.png'
)
}
)
})

View File

@@ -1,71 +0,0 @@
# 7. NodeExecutionOutput Passthrough Schema Design
Date: 2026-03-11
## Status
Accepted
## Context
`NodeExecutionOutput` represents the output data from a ComfyUI node execution. The backend API is intentionally open-ended: custom nodes can output any key (`gifs`, `3d`, `meshes`, `point_clouds`, etc.) alongside the well-known keys (`images`, `audio`, `video`, `animated`, `text`).
The Zod schema uses `.passthrough()` to allow unknown keys through without validation:
```ts
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional(),
text: z.union([z.string(), z.array(z.string())]).optional()
})
.passthrough()
```
This means unknown keys are typed as `unknown` in TypeScript, requiring runtime validation when iterating over all output entries (e.g., to build a unified media list).
### Why not `.catchall(z.array(zResultItem))`?
`.catchall()` correctly handles this at the Zod runtime level — explicit keys override the catchall, so `animated: [true]` parses fine even when the catchall expects `ResultItem[]`.
However, TypeScript's type inference creates an index signature `[k: string]: ResultItem[]` that **conflicts** with the explicit fields `animated: boolean[]` and `text: string | string[]`. These types don't extend `ResultItem[]`, so TypeScript errors on any assignment.
This is a TypeScript limitation, not a Zod or schema design issue. TypeScript cannot express "index signature applies only to keys not explicitly defined."
### Why not remove `animated` and `text` from the schema?
- `animated` is consumed by `isAnimatedOutput()` in `litegraphUtil.ts` and by `litegraphService.ts` to determine whether to render images as static or animated. Removing it would break typing for the graph editor path.
- `text` is part of the `zExecutedWsMessage` validation pipeline. Removing it from `zOutputs` would cause `.catchall()` to reject `{ text: "hello" }` as invalid (it's not `ResultItem[]`).
- Both are structurally different from media outputs — they are metadata, not file references. Mixing them in the same object is a backend API constraint we cannot change here.
## Decision
1. **Keep `.passthrough()`** on `zOutputs`. It correctly reflects the extensible nature of the backend API.
2. **Use `resultItemType` (the Zod enum) for `type` field validation** in the shared `isResultItem` guard. We cannot use `zResultItem.safeParse()` directly because the Zod schema marks `filename` and `subfolder` as `.optional()` (matching the wire format), but a `ResultItemImpl` needs both fields to construct a valid preview URL. The shared guard requires `filename` and `subfolder` as strings while delegating `type` validation to the Zod enum.
3. **Accept the `unknown[]` cast** when iterating passthrough entries. The cast is honest — passthrough values genuinely are `unknown`, and runtime validation narrows them correctly.
4. **Centralize the `NodeExecutionOutput → ResultItemImpl[]` conversion** into a shared utility (`parseNodeOutput` / `parseTaskOutput` in `src/stores/resultItemParsing.ts`) to eliminate duplicated, inconsistent validation across `flattenNodeOutput.ts`, `jobOutputCache.ts`, and `queueStore.ts`.
## Consequences
### Positive
- Single source of truth for `ResultItem` validation (shared `isResultItem` guard using Zod's `resultItemType` enum)
- Consistent validation strictness across all code paths
- Clear documentation of why `.passthrough()` is intentional, preventing future "fix" attempts
- The `unknown[]` cast is contained to one location
### Negative
- Manual `isResultItem` guard is stricter than `zResultItem` Zod schema (requires `filename` and `subfolder`); if the Zod schema changes, the guard must be updated manually
- The `unknown[]` cast remains necessary — cannot be eliminated without a TypeScript language change or backend API restructuring
## Notes
The backend API's extensible output format is a deliberate design choice for ComfyUI's plugin architecture. Custom nodes define their own output types, and the frontend must handle arbitrary keys gracefully. Any future attempt to make the schema stricter must account for this extensibility requirement.
If TypeScript adds support for "rest index signatures" or "exclusive index signatures" in the future, `.catchall()` could replace `.passthrough()` and the `unknown[]` cast would be eliminated.

View File

@@ -8,15 +8,13 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| ADR | Title | Status | Date |
| --------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
## Creating a New ADR

View File

@@ -152,6 +152,13 @@ export default defineConfig([
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'always'
}
],
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
@@ -298,49 +305,6 @@ export default defineConfig([
}
},
// Layer architecture boundary enforcement
// Layers (bottom to top): base → platform → workbench → renderer
// Each layer may only import from layers below it.
// Existing violations are suppressed with eslint-disable comments.
{
files: [
'src/base/**/*.{ts,vue}',
'src/platform/**/*.{ts,vue}',
'src/workbench/**/*.{ts,vue}'
],
rules: {
'import-x/no-restricted-paths': [
'error',
{
zones: [
{
target: './src/base/**',
from: [
'./src/platform/**',
'./src/workbench/**',
'./src/renderer/**'
],
message:
'base/ cannot import from upper layers (violates layer architecture: base → platform → workbench → renderer)'
},
{
target: './src/platform/**',
from: ['./src/workbench/**', './src/renderer/**'],
message:
'platform/ cannot import from upper layers (violates layer architecture: base → platform → workbench → renderer)'
},
{
target: './src/workbench/**',
from: './src/renderer/**',
message:
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
}
]
}
]
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{

View File

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

410
pnpm-lock.yaml generated
View File

@@ -193,8 +193,8 @@ catalogs:
specifier: ^4.16.1
version: 4.16.1
eslint-plugin-oxlint:
specifier: 1.55.0
version: 1.55.0
specifier: 1.25.0
version: 1.25.0
eslint-plugin-storybook:
specifier: ^10.2.10
version: 10.2.10
@@ -250,14 +250,14 @@ catalogs:
specifier: 22.5.2
version: 22.5.2
oxfmt:
specifier: ^0.40.0
version: 0.40.0
specifier: ^0.34.0
version: 0.34.0
oxlint:
specifier: ^1.55.0
version: 1.55.0
specifier: ^1.49.0
version: 1.49.0
oxlint-tsgolint:
specifier: ^0.17.0
version: 0.17.0
specifier: ^0.14.2
version: 0.14.2
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -659,13 +659,13 @@ importers:
version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.55.0(oxlint-tsgolint@0.17.0))(tailwindcss@4.2.0)(typescript@5.9.3)
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.55.0
version: 1.25.0
eslint-plugin-storybook:
specifier: 'catalog:'
version: 10.2.10(eslint@9.39.1(jiti@2.6.1))(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
@@ -713,13 +713,13 @@ importers:
version: 22.5.2
oxfmt:
specifier: 'catalog:'
version: 0.40.0
version: 0.34.0
oxlint:
specifier: 'catalog:'
version: 1.55.0(oxlint-tsgolint@0.17.0)
version: 1.49.0(oxlint-tsgolint@0.14.2)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.17.0
version: 0.14.2
picocolors:
specifier: 'catalog:'
version: 1.1.1
@@ -2751,276 +2751,276 @@ packages:
cpu: [x64]
os: [win32]
'@oxfmt/binding-android-arm-eabi@0.40.0':
resolution: {integrity: sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw==}
'@oxfmt/binding-android-arm-eabi@0.34.0':
resolution: {integrity: sha512-sqkqjh/Z38l+duOb1HtVqJTAj1grt2ttkobCopC/72+a4Xxz4xUgZPFyQ4HxrYMvyqO/YA0tvM1QbfOu70Gk1Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.40.0':
resolution: {integrity: sha512-/mbS9UUP/5Vbl2D6osIdcYiP0oie63LKMoTyGj5hyMCK/SFkl3EhtyRAfdjPvuvHC0SXdW6ePaTKkBSq1SNcIw==}
'@oxfmt/binding-android-arm64@0.34.0':
resolution: {integrity: sha512-1KRCtasHcVcGOMwfOP9d5Bus2NFsN8yAYM5cBwi8LBg5UtXC3C49WHKrlEa8iF1BjOS6CR2qIqiFbGoA0DJQNQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.40.0':
resolution: {integrity: sha512-wRt8fRdfLiEhnRMBonlIbKrJWixoEmn6KCjKE9PElnrSDSXETGZfPb8ee+nQNTobXkCVvVLytp2o0obAsxl78Q==}
'@oxfmt/binding-darwin-arm64@0.34.0':
resolution: {integrity: sha512-b+Rmw9Bva6e/7PBES2wLO8sEU7Mi0+/Kv+pXSe/Y8i4fWNftZZlGwp8P01eECaUqpXATfSgNxdEKy7+ssVNz7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.40.0':
resolution: {integrity: sha512-fzowhqbOE/NRy+AE5ob0+Y4X243WbWzDb00W+pKwD7d9tOqsAFbtWUwIyqqCoCLxj791m2xXIEeLH/3uz7zCCg==}
'@oxfmt/binding-darwin-x64@0.34.0':
resolution: {integrity: sha512-QGjpevWzf1T9COEokZEWt80kPOtthW1zhRbo7x4Qoz646eTTfi6XsHG2uHeDWJmTbgBoJZPMgj2TAEV/ppEZaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.40.0':
resolution: {integrity: sha512-agZ9ITaqdBjcerRRFEHB8s0OyVcQW8F9ZxsszjxzeSthQ4fcN2MuOtQFWec1ed8/lDa50jSLHVE2/xPmTgtCfQ==}
'@oxfmt/binding-freebsd-x64@0.34.0':
resolution: {integrity: sha512-VMSaC02cG75qL59M9M/szEaqq/RsLfgpzQ4nqUu8BUnX1zkiZIW2gTpUv3ZJ6qpWnHxIlAXiRZjQwmcwpvtbcg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.40.0':
resolution: {integrity: sha512-ZM2oQ47p28TP1DVIp7HL1QoMUgqlBFHey0ksHct7tMXoU5BqjNvPWw7888azzMt25lnyPODVuye1wvNbvVUFOA==}
'@oxfmt/binding-linux-arm-gnueabihf@0.34.0':
resolution: {integrity: sha512-Klm367PFJhH6vYK3vdIOxFepSJZHPaBfIuqwxdkOcfSQ4qqc/M8sgK0UTFnJWWTA/IkhMIh1kW6uEqiZ/xtQqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.40.0':
resolution: {integrity: sha512-RBFPAxRAIsMisKM47Oe6Lwdv6agZYLz02CUhVCD1sOv5ajAcRMrnwCFBPWwGXpazToW2mjnZxFos8TuFjTU15A==}
'@oxfmt/binding-linux-arm-musleabihf@0.34.0':
resolution: {integrity: sha512-nqn0QueVXRfbN9m58/E9Zij0Ap8lzayx591eWBYn0sZrGzY1IRv9RYS7J/1YUXbb0Ugedo0a8qIWzUHU9bWQuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.40.0':
resolution: {integrity: sha512-Nb2XbQ+wV3W2jSIihXdPj7k83eOxeSgYP3N/SRXvQ6ZYPIk6Q86qEh5Gl/7OitX3bQoQrESqm1yMLvZV8/J7dA==}
'@oxfmt/binding-linux-arm64-gnu@0.34.0':
resolution: {integrity: sha512-DDn+dcqW+sMTCEjvLoQvC/VWJjG7h8wcdN/J+g7ZTdf/3/Dx730pQElxPPGsCXPhprb11OsPyMp5FwXjMY3qvA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.40.0':
resolution: {integrity: sha512-tGmWhLD/0YMotCdfezlT6tC/MJG/wKpo4vnQ3Cq+4eBk/BwNv7EmkD0VkD5F/dYkT3b8FNU01X2e8vvJuWoM1w==}
'@oxfmt/binding-linux-arm64-musl@0.34.0':
resolution: {integrity: sha512-H+F8+71gHQoGTFPPJ6z4dD0Fzfzi0UP8Zx94h5kUmIFThLvMq5K1Y/bUUubiXwwHfwb5C3MPjUpYijiy0rj51Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.40.0':
resolution: {integrity: sha512-rVbFyM3e7YhkVnp0IVYjaSHfrBWcTRWb60LEcdNAJcE2mbhTpbqKufx0FrhWfoxOrW/+7UJonAOShoFFLigDqQ==}
'@oxfmt/binding-linux-ppc64-gnu@0.34.0':
resolution: {integrity: sha512-dIGnzTNhCXqQD5pzBwduLg8pClm+t8R53qaE9i5h8iua1iaFAJyLffh4847CNZSlASb7gn1Ofuv7KoG/EpoGZg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.40.0':
resolution: {integrity: sha512-3ZqBw14JtWeEoLiioJcXSJz8RQyPE+3jLARnYM1HdPzZG4vk+Ua8CUupt2+d+vSAvMyaQBTN2dZK+kbBS/j5mA==}
'@oxfmt/binding-linux-riscv64-gnu@0.34.0':
resolution: {integrity: sha512-FGQ2GTTooilDte/ogwWwkHuuL3lGtcE3uKM2EcC7kOXNWdUfMY6Jx3JCodNVVbFoybv4A+HuCj8WJji2uu1Ceg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.40.0':
resolution: {integrity: sha512-JJ4PPSdcbGBjPvb+O7xYm2FmAsKCyuEMYhqatBAHMp/6TA6rVlf9Z/sYPa4/3Bommb+8nndm15SPFRHEPU5qFA==}
'@oxfmt/binding-linux-riscv64-musl@0.34.0':
resolution: {integrity: sha512-2dGbGneJ7ptOIVKMwEIHdCkdZEomh74X3ggo4hCzEXL/rl9HwfsZDR15MkqfQqAs6nVXMvtGIOMxjDYa5lwKaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.40.0':
resolution: {integrity: sha512-Kp0zNJoX9Ik77wUya2tpBY3W9f40VUoMQLWVaob5SgCrblH/t2xr/9B2bWHfs0WCefuGmqXcB+t0Lq77sbBmZw==}
'@oxfmt/binding-linux-s390x-gnu@0.34.0':
resolution: {integrity: sha512-cCtGgmrTrxq3OeSG0UAO+w6yLZTMeOF4XM9SAkNrRUxYhRQELSDQ/iNPCLyHhYNi38uHJQbS5RQweLUDpI4ajA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.40.0':
resolution: {integrity: sha512-7YTCNzleWTaQTqNGUNQ66qVjpoV6DjbCOea+RnpMBly2bpzrI/uu7Rr+2zcgRfNxyjXaFTVQKaRKjqVdeUfeVA==}
'@oxfmt/binding-linux-x64-gnu@0.34.0':
resolution: {integrity: sha512-7AvMzmeX+k7GdgitXp99GQoIV/QZIpAS7rwxQvC/T541yWC45nwvk4mpnU8N+V6dE5SPEObnqfhCjO80s7qIsg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.40.0':
resolution: {integrity: sha512-hWnSzJ0oegeOwfOEeejYXfBqmnRGHusgtHfCPzmvJvHTwy1s3Neo59UKc1CmpE3zxvrCzJoVHos0rr97GHMNPw==}
'@oxfmt/binding-linux-x64-musl@0.34.0':
resolution: {integrity: sha512-uNiglhcmivJo1oDMh3hoN/Z0WsbEXOpRXZdQ3W/IkOpyV8WF308jFjSC1ZxajdcNRXWej0zgge9QXba58Owt+g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.40.0':
resolution: {integrity: sha512-28sJC1lR4qtBJGzSRRbPnSW3GxU2+4YyQFE6rCmsUYqZ5XYH8jg0/w+CvEzQ8TuAQz5zLkcA25nFQGwoU0PT3Q==}
'@oxfmt/binding-openharmony-arm64@0.34.0':
resolution: {integrity: sha512-5eFsTjCyji25j6zznzlMc+wQAZJoL9oWy576xhqd2efv+N4g1swIzuSDcb1dz4gpcVC6veWe9pAwD7HnrGjLwg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.40.0':
resolution: {integrity: sha512-cDkRnyT0dqwF5oIX1Cv59HKCeZQFbWWdUpXa3uvnHFT2iwYSSZspkhgjXjU6iDp5pFPaAEAe9FIbMoTgkTmKPg==}
'@oxfmt/binding-win32-arm64-msvc@0.34.0':
resolution: {integrity: sha512-6id8kK0t5hKfbV6LHDzRO21wRTA6ctTlKGTZIsG/mcoir0rssvaYsedUymF4HDj7tbCUlnxCX/qOajKlEuqbIw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.40.0':
resolution: {integrity: sha512-7rPemBJjqm5Gkv6ZRCPvK8lE6AqQ/2z31DRdWazyx2ZvaSgL7QGofHXHNouRpPvNsT9yxRNQJgigsWkc+0qg4w==}
'@oxfmt/binding-win32-ia32-msvc@0.34.0':
resolution: {integrity: sha512-QHaz+w673mlYqn9v/+fuiKZpjkmagleXQ+NygShDv8tdHpRYX2oYhTJwwt9j1ZfVhRgza1EIUW3JmzCXmtPdhQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.40.0':
resolution: {integrity: sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA==}
'@oxfmt/binding-win32-x64-msvc@0.34.0':
resolution: {integrity: sha512-CXKQM/VaF+yuvGru8ktleHLJoBdjBtTFmAsLGePiESiTN0NjCI/PiaiOCfHMJ1HdP1LykvARUwMvgaN3tDhcrg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.17.0':
resolution: {integrity: sha512-z3XwCDuOAKgk7bO4y5tyH8Zogwr51G56R0XGKC3tlAbrAq8DecoxAd3qhRZqWBMG2Gzl5bWU3Ghu7lrxuLPzYw==}
'@oxlint-tsgolint/darwin-arm64@0.14.2':
resolution: {integrity: sha512-03WxIXguCXf1pTmoG2C6vqRcbrU9GaJCW6uTIiQdIQq4BrJnVWZv99KEUQQRkuHK78lOLa9g7B4K58NcVcB54g==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.17.0':
resolution: {integrity: sha512-TZgVXy0MtI8nt0MYiceuZhHPwHcwlIZ/YwzFTAKrgdHiTvVzFbqHVdXi5wbZfT/o1nHGw9fbGWPlb6qKZ4uZ9Q==}
'@oxlint-tsgolint/darwin-x64@0.14.2':
resolution: {integrity: sha512-ksMLl1cIWz3Jw+U79BhyCPdvohZcJ/xAKri5bpT6oeEM2GVnQCHBk/KZKlYrd7hZUTxz0sLnnKHE11XFnLASNQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.17.0':
resolution: {integrity: sha512-IDfhFl/Y8bjidCvAP6QAxVyBsl78TmfCHlfjtEv2XtJXgYmIwzv6muO18XMp74SZ2qAyD4y2n2dUedrmghGHeA==}
'@oxlint-tsgolint/linux-arm64@0.14.2':
resolution: {integrity: sha512-2BgR535w7GLxBCyQD5DR3dBzbAgiBbG5QX1kAEVzOmWxJhhGxt5lsHdHebRo7ilukYLpBDkerz0mbMErblghCQ==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.17.0':
resolution: {integrity: sha512-Bgdgqx/m8EnfjmmlRLEeYy9Yhdt1GdFrMr5mTu/NyLRGkB1C9VLAikdxB7U9QambAGTAmjMbHNFDFk8Vx69Huw==}
'@oxlint-tsgolint/linux-x64@0.14.2':
resolution: {integrity: sha512-TUHFyVHfbbGtnTQZbUFgwvv3NzXBgzNLKdMUJw06thpiC7u5OW5qdk4yVXIC/xeVvdl3NAqTfcT4sA32aiMubg==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.17.0':
resolution: {integrity: sha512-dO6wyKMDqFWh1vwr+zNZS7/ovlfGgl4S3P1LDy4CKjP6V6NGtdmEwWkWax8j/I8RzGZdfXKnoUfb/qhVg5bx0w==}
'@oxlint-tsgolint/win32-arm64@0.14.2':
resolution: {integrity: sha512-OfYHa/irfVggIFEC4TbawsI7Hwrttppv//sO/e00tu4b2QRga7+VHAwtCkSFWSr0+BsO4InRYVA0+pun5BinpQ==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.17.0':
resolution: {integrity: sha512-lPGYFp3yX2nh6hLTpIuMnJbZnt3Df42VkoA/fSkMYi2a/LXdDytQGpgZOrb5j47TICARd34RauKm0P3OA4Oxbw==}
'@oxlint-tsgolint/win32-x64@0.14.2':
resolution: {integrity: sha512-5gxwbWYE2pP+pzrO4SEeYvLk4N609eAe18rVXUx+en3qtHBkU8VM2jBmMcZdIHn+G05leu4pYvwAvw6tvT9VbA==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.55.0':
resolution: {integrity: sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==}
'@oxlint/binding-android-arm-eabi@1.49.0':
resolution: {integrity: sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.55.0':
resolution: {integrity: sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==}
'@oxlint/binding-android-arm64@1.49.0':
resolution: {integrity: sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.55.0':
resolution: {integrity: sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==}
'@oxlint/binding-darwin-arm64@1.49.0':
resolution: {integrity: sha512-WFocCRlvVkMhChCJ2qpJfp1Gj/IjvyjuifH9Pex8m8yHonxxQa3d8DZYreuDQU3T4jvSY8rqhoRqnpc61Nlbxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.55.0':
resolution: {integrity: sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==}
'@oxlint/binding-darwin-x64@1.49.0':
resolution: {integrity: sha512-BN0KniwvehbUfYztOMwEDkYoojGm/narf5oJf+/ap+6PnzMeWLezMaVARNIS0j3OdMkjHTEP8s3+GdPJ7WDywQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.55.0':
resolution: {integrity: sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==}
'@oxlint/binding-freebsd-x64@1.49.0':
resolution: {integrity: sha512-SnkAc/DPIY6joMCiP/+53Q+N2UOGMU6ULvbztpmvPJNF/jYPGhNbKtN982uj2Gs6fpbxYkmyj08QnpkD4fbHJA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
resolution: {integrity: sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==}
'@oxlint/binding-linux-arm-gnueabihf@1.49.0':
resolution: {integrity: sha512-6Z3EzRvpQVIpO7uFhdiGhdE8Mh3S2VWKLL9xuxVqD6fzPhyI3ugthpYXlCChXzO8FzcYIZ3t1+Kau+h2NY1hqA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
resolution: {integrity: sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==}
'@oxlint/binding-linux-arm-musleabihf@1.49.0':
resolution: {integrity: sha512-wdjXaQYAL/L25732mLlngfst4Jdmi/HLPVHb3yfCoP5mE3lO/pFFrmOJpqWodgv29suWY74Ij+RmJ/YIG5VuzQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.55.0':
resolution: {integrity: sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==}
'@oxlint/binding-linux-arm64-gnu@1.49.0':
resolution: {integrity: sha512-oSHpm8zmSvAG1BWUumbDRSg7moJbnwoEXKAkwDf/xTQJOzvbUknq95NVQdw/AduZr5dePftalB8rzJNGBogUMg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.55.0':
resolution: {integrity: sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==}
'@oxlint/binding-linux-arm64-musl@1.49.0':
resolution: {integrity: sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
resolution: {integrity: sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==}
'@oxlint/binding-linux-ppc64-gnu@1.49.0':
resolution: {integrity: sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
resolution: {integrity: sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==}
'@oxlint/binding-linux-riscv64-gnu@1.49.0':
resolution: {integrity: sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.55.0':
resolution: {integrity: sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==}
'@oxlint/binding-linux-riscv64-musl@1.49.0':
resolution: {integrity: sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.55.0':
resolution: {integrity: sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==}
'@oxlint/binding-linux-s390x-gnu@1.49.0':
resolution: {integrity: sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.55.0':
resolution: {integrity: sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==}
'@oxlint/binding-linux-x64-gnu@1.49.0':
resolution: {integrity: sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.55.0':
resolution: {integrity: sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==}
'@oxlint/binding-linux-x64-musl@1.49.0':
resolution: {integrity: sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.55.0':
resolution: {integrity: sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==}
'@oxlint/binding-openharmony-arm64@1.49.0':
resolution: {integrity: sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.55.0':
resolution: {integrity: sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==}
'@oxlint/binding-win32-arm64-msvc@1.49.0':
resolution: {integrity: sha512-6rrKe/wL9tn0qnOy76i1/0f4Dc3dtQnibGlU4HqR/brVHlVjzLSoaH0gAFnLnznh9yQ6gcFTBFOPrcN/eKPDGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.55.0':
resolution: {integrity: sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==}
'@oxlint/binding-win32-ia32-msvc@1.49.0':
resolution: {integrity: sha512-CXHLWAtLs2xG/aVy1OZiYJzrULlq0QkYpI6cd7VKMrab+qur4fXVE/B1Bp1m0h1qKTj5/FTGg6oU4qaXMjS/ug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.55.0':
resolution: {integrity: sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==}
'@oxlint/binding-win32-x64-msvc@1.49.0':
resolution: {integrity: sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -5556,8 +5556,8 @@ packages:
'@typescript-eslint/parser':
optional: true
eslint-plugin-oxlint@1.55.0:
resolution: {integrity: sha512-5ng7DOuikSE64e7hX2HBqEWdmql+Q4FWppBoBkxKKflLt1j9LXhab5BN3bYJKyrAihuK1/VH2JvfNefeOZAqpA==}
eslint-plugin-oxlint@1.25.0:
resolution: {integrity: sha512-grS4KdR9FAxoQC+wMkepeQHL4osMhoYfUI11Pot6Gitqr4wWi+JZrX0Shr8Bs9fjdWhEjtaZIV6cr4mbfytmyw==}
eslint-plugin-storybook@10.2.10:
resolution: {integrity: sha512-aWkoh2rhTaEsMA4yB1iVIcISM5wb0uffp09ZqhwpoD4GAngCs131uq6un+QdnOMc7vXyAnBBfsuhtOj8WwCUgw==}
@@ -7243,21 +7243,21 @@ packages:
oxc-resolver@11.15.0:
resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==}
oxfmt@0.40.0:
resolution: {integrity: sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ==}
oxfmt@0.34.0:
resolution: {integrity: sha512-t+zTE4XGpzPTK+Zk9gSwcJcFi4pqjl6PwO/ZxPBJiJQ2XCKMucwjPlHxvPHyVKJtkMSyrDGfQ7Ntg/hUr4OgHQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.17.0:
resolution: {integrity: sha512-TdrKhDZCgEYqONFo/j+KvGan7/k3tP5Ouz88wCqpOvJtI2QmcLfGsm1fcMvDnTik48Jj6z83IJBqlkmK9DnY1A==}
oxlint-tsgolint@0.14.2:
resolution: {integrity: sha512-XJsFIQwnYJgXFlNDz2MncQMWYxwnfy4BCy73mdiFN/P13gEZrAfBU4Jmz2XXFf9UG0wPILdi7hYa6t0KmKQLhw==}
hasBin: true
oxlint@1.55.0:
resolution: {integrity: sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==}
oxlint@1.49.0:
resolution: {integrity: sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.15.0'
oxlint-tsgolint: '>=0.14.1'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
@@ -11191,136 +11191,136 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.15.0':
optional: true
'@oxfmt/binding-android-arm-eabi@0.40.0':
'@oxfmt/binding-android-arm-eabi@0.34.0':
optional: true
'@oxfmt/binding-android-arm64@0.40.0':
'@oxfmt/binding-android-arm64@0.34.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.40.0':
'@oxfmt/binding-darwin-arm64@0.34.0':
optional: true
'@oxfmt/binding-darwin-x64@0.40.0':
'@oxfmt/binding-darwin-x64@0.34.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.40.0':
'@oxfmt/binding-freebsd-x64@0.34.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.40.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.34.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.40.0':
'@oxfmt/binding-linux-arm-musleabihf@0.34.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.40.0':
'@oxfmt/binding-linux-arm64-gnu@0.34.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.40.0':
'@oxfmt/binding-linux-arm64-musl@0.34.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.40.0':
'@oxfmt/binding-linux-ppc64-gnu@0.34.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.40.0':
'@oxfmt/binding-linux-riscv64-gnu@0.34.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.40.0':
'@oxfmt/binding-linux-riscv64-musl@0.34.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.40.0':
'@oxfmt/binding-linux-s390x-gnu@0.34.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.40.0':
'@oxfmt/binding-linux-x64-gnu@0.34.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.40.0':
'@oxfmt/binding-linux-x64-musl@0.34.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.40.0':
'@oxfmt/binding-openharmony-arm64@0.34.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.40.0':
'@oxfmt/binding-win32-arm64-msvc@0.34.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.40.0':
'@oxfmt/binding-win32-ia32-msvc@0.34.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.40.0':
'@oxfmt/binding-win32-x64-msvc@0.34.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.17.0':
'@oxlint-tsgolint/darwin-arm64@0.14.2':
optional: true
'@oxlint-tsgolint/darwin-x64@0.17.0':
'@oxlint-tsgolint/darwin-x64@0.14.2':
optional: true
'@oxlint-tsgolint/linux-arm64@0.17.0':
'@oxlint-tsgolint/linux-arm64@0.14.2':
optional: true
'@oxlint-tsgolint/linux-x64@0.17.0':
'@oxlint-tsgolint/linux-x64@0.14.2':
optional: true
'@oxlint-tsgolint/win32-arm64@0.17.0':
'@oxlint-tsgolint/win32-arm64@0.14.2':
optional: true
'@oxlint-tsgolint/win32-x64@0.17.0':
'@oxlint-tsgolint/win32-x64@0.14.2':
optional: true
'@oxlint/binding-android-arm-eabi@1.55.0':
'@oxlint/binding-android-arm-eabi@1.49.0':
optional: true
'@oxlint/binding-android-arm64@1.55.0':
'@oxlint/binding-android-arm64@1.49.0':
optional: true
'@oxlint/binding-darwin-arm64@1.55.0':
'@oxlint/binding-darwin-arm64@1.49.0':
optional: true
'@oxlint/binding-darwin-x64@1.55.0':
'@oxlint/binding-darwin-x64@1.49.0':
optional: true
'@oxlint/binding-freebsd-x64@1.55.0':
'@oxlint/binding-freebsd-x64@1.49.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
'@oxlint/binding-linux-arm-gnueabihf@1.49.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
'@oxlint/binding-linux-arm-musleabihf@1.49.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.55.0':
'@oxlint/binding-linux-arm64-gnu@1.49.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.55.0':
'@oxlint/binding-linux-arm64-musl@1.49.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
'@oxlint/binding-linux-ppc64-gnu@1.49.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
'@oxlint/binding-linux-riscv64-gnu@1.49.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.55.0':
'@oxlint/binding-linux-riscv64-musl@1.49.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.55.0':
'@oxlint/binding-linux-s390x-gnu@1.49.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.55.0':
'@oxlint/binding-linux-x64-gnu@1.49.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.55.0':
'@oxlint/binding-linux-x64-musl@1.49.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.55.0':
'@oxlint/binding-openharmony-arm64@1.49.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.55.0':
'@oxlint/binding-win32-arm64-msvc@1.49.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.55.0':
'@oxlint/binding-win32-ia32-msvc@1.49.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.55.0':
'@oxlint/binding-win32-x64-msvc@1.49.0':
optional: true
'@phenomnomnominal/tsquery@6.1.4(typescript@5.9.3)':
@@ -14007,7 +14007,7 @@ snapshots:
- supports-color
optional: true
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.55.0(oxlint-tsgolint@0.17.0))(tailwindcss@4.2.0)(typescript@5.9.3):
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
@@ -14020,7 +14020,7 @@ snapshots:
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 9.39.1(jiti@2.6.1)
oxlint: 1.55.0(oxlint-tsgolint@0.17.0)
oxlint: 1.49.0(oxlint-tsgolint@0.14.2)
transitivePeerDependencies:
- typescript
@@ -14072,7 +14072,7 @@ snapshots:
- supports-color
optional: true
eslint-plugin-oxlint@1.55.0:
eslint-plugin-oxlint@1.25.0:
dependencies:
jsonc-parser: 3.3.1
@@ -16086,61 +16086,61 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.15.0
'@oxc-resolver/binding-win32-x64-msvc': 11.15.0
oxfmt@0.40.0:
oxfmt@0.34.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.40.0
'@oxfmt/binding-android-arm64': 0.40.0
'@oxfmt/binding-darwin-arm64': 0.40.0
'@oxfmt/binding-darwin-x64': 0.40.0
'@oxfmt/binding-freebsd-x64': 0.40.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.40.0
'@oxfmt/binding-linux-arm-musleabihf': 0.40.0
'@oxfmt/binding-linux-arm64-gnu': 0.40.0
'@oxfmt/binding-linux-arm64-musl': 0.40.0
'@oxfmt/binding-linux-ppc64-gnu': 0.40.0
'@oxfmt/binding-linux-riscv64-gnu': 0.40.0
'@oxfmt/binding-linux-riscv64-musl': 0.40.0
'@oxfmt/binding-linux-s390x-gnu': 0.40.0
'@oxfmt/binding-linux-x64-gnu': 0.40.0
'@oxfmt/binding-linux-x64-musl': 0.40.0
'@oxfmt/binding-openharmony-arm64': 0.40.0
'@oxfmt/binding-win32-arm64-msvc': 0.40.0
'@oxfmt/binding-win32-ia32-msvc': 0.40.0
'@oxfmt/binding-win32-x64-msvc': 0.40.0
'@oxfmt/binding-android-arm-eabi': 0.34.0
'@oxfmt/binding-android-arm64': 0.34.0
'@oxfmt/binding-darwin-arm64': 0.34.0
'@oxfmt/binding-darwin-x64': 0.34.0
'@oxfmt/binding-freebsd-x64': 0.34.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.34.0
'@oxfmt/binding-linux-arm-musleabihf': 0.34.0
'@oxfmt/binding-linux-arm64-gnu': 0.34.0
'@oxfmt/binding-linux-arm64-musl': 0.34.0
'@oxfmt/binding-linux-ppc64-gnu': 0.34.0
'@oxfmt/binding-linux-riscv64-gnu': 0.34.0
'@oxfmt/binding-linux-riscv64-musl': 0.34.0
'@oxfmt/binding-linux-s390x-gnu': 0.34.0
'@oxfmt/binding-linux-x64-gnu': 0.34.0
'@oxfmt/binding-linux-x64-musl': 0.34.0
'@oxfmt/binding-openharmony-arm64': 0.34.0
'@oxfmt/binding-win32-arm64-msvc': 0.34.0
'@oxfmt/binding-win32-ia32-msvc': 0.34.0
'@oxfmt/binding-win32-x64-msvc': 0.34.0
oxlint-tsgolint@0.17.0:
oxlint-tsgolint@0.14.2:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.17.0
'@oxlint-tsgolint/darwin-x64': 0.17.0
'@oxlint-tsgolint/linux-arm64': 0.17.0
'@oxlint-tsgolint/linux-x64': 0.17.0
'@oxlint-tsgolint/win32-arm64': 0.17.0
'@oxlint-tsgolint/win32-x64': 0.17.0
'@oxlint-tsgolint/darwin-arm64': 0.14.2
'@oxlint-tsgolint/darwin-x64': 0.14.2
'@oxlint-tsgolint/linux-arm64': 0.14.2
'@oxlint-tsgolint/linux-x64': 0.14.2
'@oxlint-tsgolint/win32-arm64': 0.14.2
'@oxlint-tsgolint/win32-x64': 0.14.2
oxlint@1.55.0(oxlint-tsgolint@0.17.0):
oxlint@1.49.0(oxlint-tsgolint@0.14.2):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.55.0
'@oxlint/binding-android-arm64': 1.55.0
'@oxlint/binding-darwin-arm64': 1.55.0
'@oxlint/binding-darwin-x64': 1.55.0
'@oxlint/binding-freebsd-x64': 1.55.0
'@oxlint/binding-linux-arm-gnueabihf': 1.55.0
'@oxlint/binding-linux-arm-musleabihf': 1.55.0
'@oxlint/binding-linux-arm64-gnu': 1.55.0
'@oxlint/binding-linux-arm64-musl': 1.55.0
'@oxlint/binding-linux-ppc64-gnu': 1.55.0
'@oxlint/binding-linux-riscv64-gnu': 1.55.0
'@oxlint/binding-linux-riscv64-musl': 1.55.0
'@oxlint/binding-linux-s390x-gnu': 1.55.0
'@oxlint/binding-linux-x64-gnu': 1.55.0
'@oxlint/binding-linux-x64-musl': 1.55.0
'@oxlint/binding-openharmony-arm64': 1.55.0
'@oxlint/binding-win32-arm64-msvc': 1.55.0
'@oxlint/binding-win32-ia32-msvc': 1.55.0
'@oxlint/binding-win32-x64-msvc': 1.55.0
oxlint-tsgolint: 0.17.0
'@oxlint/binding-android-arm-eabi': 1.49.0
'@oxlint/binding-android-arm64': 1.49.0
'@oxlint/binding-darwin-arm64': 1.49.0
'@oxlint/binding-darwin-x64': 1.49.0
'@oxlint/binding-freebsd-x64': 1.49.0
'@oxlint/binding-linux-arm-gnueabihf': 1.49.0
'@oxlint/binding-linux-arm-musleabihf': 1.49.0
'@oxlint/binding-linux-arm64-gnu': 1.49.0
'@oxlint/binding-linux-arm64-musl': 1.49.0
'@oxlint/binding-linux-ppc64-gnu': 1.49.0
'@oxlint/binding-linux-riscv64-gnu': 1.49.0
'@oxlint/binding-linux-riscv64-musl': 1.49.0
'@oxlint/binding-linux-s390x-gnu': 1.49.0
'@oxlint/binding-linux-x64-gnu': 1.49.0
'@oxlint/binding-linux-x64-musl': 1.49.0
'@oxlint/binding-openharmony-arm64': 1.49.0
'@oxlint/binding-win32-arm64-msvc': 1.49.0
'@oxlint/binding-win32-ia32-msvc': 1.49.0
'@oxlint/binding-win32-x64-msvc': 1.49.0
oxlint-tsgolint: 0.14.2
p-limit@3.1.0:
dependencies:

View File

@@ -65,7 +65,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.55.0
eslint-plugin-oxlint: 1.25.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
@@ -84,9 +84,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.5.2
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0
oxfmt: ^0.34.0
oxlint: ^1.49.0
oxlint-tsgolint: ^0.14.2
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -7,9 +7,6 @@ import {
computeStats,
formatSignificance,
isNoteworthy,
sparkline,
trendArrow,
trendDirection,
zScore
} from './perf-stats'
@@ -76,11 +73,8 @@ function groupByName(
function loadHistoricalReports(): PerfReport[] {
if (!existsSync(HISTORY_DIR)) return []
const reports: PerfReport[] = []
for (const entry of readdirSync(HISTORY_DIR)) {
const entryPath = join(HISTORY_DIR, entry)
const filePath = entry.endsWith('.json')
? entryPath
: join(entryPath, 'perf-metrics.json')
for (const dir of readdirSync(HISTORY_DIR)) {
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
if (!existsSync(filePath)) continue
try {
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
@@ -108,27 +102,6 @@ function getHistoricalStats(
return computeStats(values)
}
function getHistoricalTimeSeries(
reports: PerfReport[],
testName: string,
metric: MetricKey
): number[] {
const sorted = [...reports].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)
const values: number[] = []
for (const r of sorted) {
const group = groupByName(r.measurements)
const samples = group.get(testName)
if (samples) {
values.push(
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
)
}
}
return values
}
function computeCV(stats: MetricStats): number {
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
}
@@ -260,34 +233,6 @@ function renderFullReport(
}
lines.push('', '</details>')
const trendRows: string[] = []
for (const [testName] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const series = getHistoricalTimeSeries(historical, testName, key)
if (series.length < 3) continue
const dir = trendDirection(series)
const arrow = trendArrow(dir)
const spark = sparkline(series)
const last = series[series.length - 1]
trendRows.push(
`| ${testName}: ${label} | ${spark} | ${arrow} | ${formatValue(last, unit)} |`
)
}
}
if (trendRows.length > 0) {
lines.push(
'',
`<details><summary>Trend (last ${historical.length} commits on main)</summary>`,
'',
'| Metric | Trend | Dir | Latest |',
'|--------|-------|-----|--------|',
...trendRows,
'',
'</details>'
)
}
return lines
}

View File

@@ -5,9 +5,6 @@ import {
computeStats,
formatSignificance,
isNoteworthy,
sparkline,
trendArrow,
trendDirection,
zScore
} from './perf-stats'
@@ -134,68 +131,3 @@ describe('isNoteworthy', () => {
expect(isNoteworthy('noisy')).toBe(false)
})
})
describe('sparkline', () => {
it('returns empty string for no values', () => {
expect(sparkline([])).toBe('')
})
it('returns mid-height for single value', () => {
expect(sparkline([50])).toBe('▄')
})
it('renders ascending values low to high', () => {
const result = sparkline([0, 25, 50, 75, 100])
expect(result).toBe('▁▃▅▆█')
})
it('renders identical values as flat line', () => {
const result = sparkline([10, 10, 10])
expect(result).toBe('▄▄▄')
})
it('renders descending values high to low', () => {
const result = sparkline([100, 50, 0])
expect(result).toBe('█▅▁')
})
})
describe('trendDirection', () => {
it('returns stable for fewer than 3 values', () => {
expect(trendDirection([])).toBe('stable')
expect(trendDirection([1])).toBe('stable')
expect(trendDirection([1, 2])).toBe('stable')
})
it('detects rising trend', () => {
expect(trendDirection([10, 10, 10, 20, 20, 20])).toBe('rising')
})
it('detects falling trend', () => {
expect(trendDirection([20, 20, 20, 10, 10, 10])).toBe('falling')
})
it('returns stable for flat data', () => {
expect(trendDirection([100, 100, 100, 100])).toBe('stable')
})
it('returns stable for small fluctuations within 10%', () => {
expect(trendDirection([100, 100, 100, 105, 105, 105])).toBe('stable')
})
it('detects rising when baseline is zero but current is non-zero', () => {
expect(trendDirection([0, 0, 0, 5, 5, 5])).toBe('rising')
})
it('returns stable when both halves are zero', () => {
expect(trendDirection([0, 0, 0, 0, 0, 0])).toBe('stable')
})
})
describe('trendArrow', () => {
it('returns correct emoji for each direction', () => {
expect(trendArrow('rising')).toBe('📈')
expect(trendArrow('falling')).toBe('📉')
expect(trendArrow('stable')).toBe('➡️')
})
})

View File

@@ -61,53 +61,3 @@ export function formatSignificance(
export function isNoteworthy(sig: Significance): boolean {
return sig === 'regression'
}
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
export function sparkline(values: number[]): string {
if (values.length === 0) return ''
if (values.length === 1) return SPARK_CHARS[3]
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min
return values
.map((v) => {
if (range === 0) return SPARK_CHARS[3]
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1))
return SPARK_CHARS[idx]
})
.join('')
}
export type TrendDirection = 'rising' | 'falling' | 'stable'
export function trendDirection(values: number[]): TrendDirection {
if (values.length < 3) return 'stable'
const half = Math.floor(values.length / 2)
const firstHalf = values.slice(0, half)
const secondHalf = values.slice(-half)
const firstMean = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
const secondMean = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
if (firstMean === 0) return secondMean > 0 ? 'rising' : 'stable'
const changePct = ((secondMean - firstMean) / firstMean) * 100
if (changePct > 10) return 'rising'
if (changePct < -10) return 'falling'
return 'stable'
}
export function trendArrow(dir: TrendDirection): string {
switch (dir) {
case 'rising':
return '📈'
case 'falling':
return '📉'
case 'stable':
return '➡️'
}
}

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,5 @@
@import '@comfyorg/design-system/css/style.css';
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */
.disable-animations *,
.disable-animations *::before,
.disable-animations *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
@media (prefers-reduced-motion: no-preference) {
/* List transition animations */
.list-scale-move,

View File

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

View File

@@ -10,8 +10,6 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
@@ -19,7 +17,6 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -41,7 +38,6 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
@@ -101,27 +97,21 @@ const mappedSelections = computed((): WidgetEntry[] => {
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
const imageUrl = buildImageUrl()
return {
iconClass: 'icon-[lucide--image]',
imageUrl,
imageUrl: buildImageUrl(),
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}

View File

@@ -12,7 +12,6 @@ import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -24,7 +23,6 @@ const { source, align = 'start' } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const { menuItems } = useWorkflowActionsMenu(
@@ -45,16 +43,6 @@ function handleOpen(open: boolean) {
}
}
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
@@ -64,14 +52,7 @@ function toggleLinearMode() {
const tooltipPt = {
root: {
style: {
transform: 'translateX(calc(50% - 16px))',
whiteSpace: 'nowrap',
maxWidth: 'none'
}
},
text: {
style: { whiteSpace: 'nowrap' }
style: { transform: 'translateX(calc(50% - 16px))' }
},
arrow: {
class: '!left-[16px]'
@@ -87,7 +68,9 @@ const tooltipPt = {
>
<Button
v-tooltip.bottom="{
value: toggleModeTooltip(),
value: canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode'),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt

View File

@@ -80,7 +80,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resolveNode } from '@/utils/litegraphUtil'
import { app } from '@/scripts/app'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -101,7 +101,7 @@ if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = resolveNode(props.nodeId!) ?? null
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@
data-mask
@mousedown="onMaskMouseDown"
@mouseup="onMaskMouseUp"
@keydown.stop="handleKeyDown"
@keydown="handleKeyDown"
>
<!-- Close Button -->
<Button

View File

@@ -165,7 +165,8 @@ export function useMoreOptionsMenu() {
const menuOptions = computed((): MenuOption[] => {
// Reference selection flags to ensure re-computation when they change
void optionsVersion.value
optionsVersion.value
const states = computeSelectionFlags()
// Detect single group selection context (and no nodes explicitly selected)

View File

@@ -5,19 +5,6 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseImageWidgetValue } from '@/utils/imageUtil'
export function extractWidgetStringValue(value: unknown): string | undefined {
if (typeof value === 'string') return value
if (
value &&
typeof value === 'object' &&
'filename' in value &&
typeof value.filename === 'string'
)
return value.filename
return undefined
}
// Private image utility functions
interface ImageLayerFilenames {
@@ -97,23 +84,62 @@ export function useMaskEditorLoader() {
let nodeImageRef = parseImageRef(nodeImageUrl)
const imageWidget = node.widgets?.find((w) => w.name === 'image')
const widgetFilename = imageWidget
? extractWidgetStringValue(imageWidget.value)
: undefined
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
if (imageWidget) {
if (typeof imageWidget.value === 'string') {
widgetFilename = imageWidget.value
} else if (
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}
}
// If we have a widget filename, we should prioritize it over the node image
// because the node image might be stale (e.g. from a previous save)
// while the widget value reflects the current selection.
// Skip internal reference formats (e.g. "$35-0" used by some plugins like Impact-Pack)
if (widgetFilename && !widgetFilename.startsWith('$')) {
const parsed = parseImageWidgetValue(widgetFilename)
nodeImageRef = {
filename: parsed.filename,
type: parsed.type || 'input',
subfolder: parsed.subfolder || undefined
try {
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
let filename = widgetFilename
let subfolder: string | undefined = undefined
let type: string | undefined = 'input' // Default to input for widget values
// Check for type in brackets at the end
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
if (typeMatch) {
type = typeMatch[1]
filename = filename.substring(
0,
filename.length - typeMatch[0].length
)
}
// Check for subfolder (forward slash separator)
const lastSlashIndex = filename.lastIndexOf('/')
if (lastSlashIndex !== -1) {
subfolder = filename.substring(0, lastSlashIndex)
filename = filename.substring(lastSlashIndex + 1)
}
nodeImageRef = {
filename,
type,
subfolder
}
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
} catch (e) {
console.warn('Failed to parse widget filename as ref', e)
}
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
}
const fileToQuery = widgetFilename || nodeImageRef.filename

View File

@@ -1,12 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
}

View File

@@ -1,7 +1,16 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
}
/**
* Composable for handling canvas image previews in nodes

View File

@@ -16,7 +16,7 @@ import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
'nodeOutputs' | 'getNodeImageUrls'
>
const getNodeImageUrls = vi.hoisted(() =>
@@ -35,7 +35,6 @@ vi.mock('@/stores/nodeOutputStore', () => {
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
}
}
@@ -72,24 +71,12 @@ function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
}
}
function seedPreviewImages(
subgraphId: string,
entries: Array<{ nodeId: number | string; urls: string[] }>
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodePreviewImages[locatorId] = urls
}
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
@@ -132,7 +119,7 @@ describe(usePromotedPreviews, () => {
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
@@ -156,7 +143,9 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.webm'
])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('video')
@@ -173,7 +162,9 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.mp3'
])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('audio')
@@ -203,11 +194,13 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10, 20])
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
})
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
@@ -215,58 +208,6 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
})
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
@@ -312,7 +253,7 @@ describe(usePromotedPreviews, () => {
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)

View File

@@ -39,18 +39,16 @@ export function usePromotedPreviews(
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
if (!interiorNode) continue
// Read from both reactive refs to establish Vue dependency
// tracking. getNodeImageUrls reads from non-reactive
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
// Read from the reactive nodeOutputs ref to establish Vue
// dependency tracking. getNodeImageUrls reads from the
// non-reactive app.nodeOutputs, so without this access the
// computed would never re-evaluate when outputs change.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.interiorNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
continue
const _reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
if (!_reactiveOutputs?.images?.length) continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue

View File

@@ -264,7 +264,7 @@ describe('useJobList', () => {
const { jobItems } = initComposable()
await flush()
void jobItems.value
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
@@ -275,7 +275,7 @@ describe('useJobList', () => {
await vi.advanceTimersByTimeAsync(3000)
await flush()
void jobItems.value
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
@@ -292,7 +292,7 @@ describe('useJobList', () => {
const { jobItems } = initComposable()
await flush()
void jobItems.value
jobItems.value
queueStoreMock.pendingTasks = []
await flush()
@@ -303,7 +303,7 @@ describe('useJobList', () => {
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
]
await flush()
void jobItems.value
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',

View File

@@ -4,8 +4,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
type ResizeDirection =
| 'top'
@@ -558,7 +558,10 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value = resolveNode(nodeId) ?? null
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
}
updateImageUrl()

View File

@@ -5,10 +5,7 @@ import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
import type {
AnimationItem,
CameraConfig,
@@ -518,7 +515,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
if (load3d && isAssetPreviewSupported()) {
if (load3d && api.getServerFeature('assets', false)) {
const node = nodeRef.value
const modelWidget = node?.widgets?.find(

View File

@@ -104,13 +104,7 @@ describe('useLoad3dViewer', () => {
}),
forceRender: vi.fn(),
remove: vi.fn(),
setTargetSize: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
setCameraState: vi.fn(),
addEventListener: vi.fn(),
hasAnimations: vi.fn().mockReturnValue(false),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false)
setTargetSize: vi.fn()
}
mockSourceLoad3d = {
@@ -539,9 +533,7 @@ describe('useLoad3dViewer', () => {
describe('handleBackgroundImageUpdate', () => {
it('should upload and set background image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -558,9 +550,7 @@ describe('useLoad3dViewer', () => {
it('should use resource folder for upload', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -605,21 +595,6 @@ describe('useLoad3dViewer', () => {
'toastMessages.failedToUploadBackgroundImage'
)
})
it('should work in standalone mode without a node', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
await viewer.initializeStandaloneViewer(containerRef, 'model.glb')
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(viewer.backgroundImage.value).toBe('uploaded-image.jpg')
})
})
describe('cleanup', () => {
@@ -679,63 +654,4 @@ describe('useLoad3dViewer', () => {
expect(viewer.lightIntensity.value).toBe(1) // Default value
})
})
describe('standalone mode persistence', () => {
it('should save and restore configuration in standalone mode', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
const model1 = 'model1.glb'
const model2 = 'model2.glb'
await viewer.initializeStandaloneViewer(containerRef, model1)
expect(viewer.isStandaloneMode.value).toBe(true)
viewer.backgroundColor.value = '#ff0000'
viewer.showGrid.value = false
viewer.cameraType.value = 'orthographic'
viewer.fov.value = 45
viewer.lightIntensity.value = 2
viewer.backgroundImage.value = 'test.jpg'
viewer.backgroundRenderMode.value = 'tiled'
viewer.upDirection.value = '+y'
viewer.materialMode.value = 'wireframe'
await nextTick()
await viewer.initializeStandaloneViewer(containerRef, model2)
expect(viewer.backgroundColor.value).toBe('#282828')
expect(viewer.showGrid.value).toBe(true)
expect(viewer.backgroundImage.value).toBe('')
await viewer.initializeStandaloneViewer(containerRef, model1)
expect(viewer.backgroundColor.value).toBe('#ff0000')
expect(viewer.showGrid.value).toBe(false)
expect(viewer.cameraType.value).toBe('orthographic')
expect(viewer.fov.value).toBe(45)
expect(viewer.lightIntensity.value).toBe(2)
expect(viewer.backgroundImage.value).toBe('test.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
expect(viewer.backgroundRenderMode.value).toBe('tiled')
expect(viewer.upDirection.value).toBe('+y')
expect(viewer.materialMode.value).toBe('wireframe')
await viewer.initializeStandaloneViewer(containerRef, model2)
expect(viewer.backgroundColor.value).toBe('#282828')
})
it('should save configuration during cleanup in standalone mode', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
const modelUrl = 'model_cleanup.glb'
await viewer.initializeStandaloneViewer(containerRef, modelUrl)
viewer.backgroundColor.value = '#0000ff'
await nextTick()
viewer.cleanup()
const newViewer = useLoad3dViewer()
await newViewer.initializeStandaloneViewer(containerRef, modelUrl)
expect(newViewer.backgroundColor.value).toBe('#0000ff')
})
})
})

View File

@@ -1,5 +1,4 @@
import { ref, toRaw, watch } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
@@ -34,29 +33,9 @@ interface Load3dViewerState {
materialMode: MaterialMode
}
const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
backgroundColor: '#282828',
showGrid: true,
cameraType: 'perspective',
fov: 75,
lightIntensity: 1,
cameraState: null,
backgroundImage: '',
backgroundRenderMode: 'tiled',
upDirection: 'original',
materialMode: 'original'
}
const standaloneConfigCache = new QuickLRU<string, Load3dViewerState>({
maxSize: 50
})
/**
* Composable for managing a 3D viewer instance.
* Supports both node-based mode (applied to a LiteGraph node)
* and standalone mode (for asset previews).
*
* @param node Optional LiteGraph node to sync state with
* @param node Optional node - if provided, viewer works in node mode with apply/restore
* If not provided, viewer works in standalone mode for asset preview
*/
export const useLoad3dViewer = (node?: LGraphNode) => {
const backgroundColor = ref('')
@@ -85,7 +64,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
let currentModelUrl: string | null = null
const initialState = ref<Load3dViewerState>({
backgroundColor: '#282828',
@@ -228,11 +206,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
})
/**
* Seeks to a specific progress point in the current animation.
*
* @param progress Progress percentage (0-100)
*/
const handleSeek = (progress: number) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
@@ -240,9 +213,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Sets up event listeners for animation-related events from the Load3d instance.
*/
const setupAnimationEvents = () => {
if (!load3d) return
@@ -273,10 +243,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
/**
* Initializes the viewer in node mode using a source Load3d instance.
*
* @param containerRef The HTML element to mount the viewer in
* @param source The source Load3d instance to copy state from
* Initialize viewer in node mode (with source Load3d)
*/
const initializeViewer = async (
containerRef: HTMLElement,
@@ -390,11 +357,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
/**
* Initializes the viewer in standalone mode for asset preview.
* Initialize viewer in standalone mode (for asset preview).
* Creates the Load3d instance once; subsequent calls reuse it.
*
* @param containerRef The HTML element to mount the viewer in
* @param modelUrl URL of the model to load
*/
const initializeStandaloneViewer = async (
containerRef: HTMLElement,
@@ -417,8 +381,15 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
})
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
backgroundColor.value = '#282828'
showGrid.value = true
cameraType.value = 'perspective'
fov.value = 75
lightIntensity.value = 1
backgroundRenderMode.value = 'tiled'
upDirection.value = 'original'
materialMode.value = 'original'
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
@@ -439,10 +410,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (!load3d) return
try {
saveStandaloneConfig()
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
} catch (error) {
@@ -451,53 +419,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Saves the current viewer configuration to the standalone cache.
*/
function saveStandaloneConfig() {
if (!currentModelUrl) return
standaloneConfigCache.set(currentModelUrl, {
backgroundColor: backgroundColor.value,
showGrid: showGrid.value,
cameraType: cameraType.value,
fov: fov.value,
lightIntensity: lightIntensity.value,
cameraState: load3d?.getCameraState() ?? null,
backgroundImage: backgroundImage.value,
backgroundRenderMode: backgroundRenderMode.value,
upDirection: upDirection.value,
materialMode: materialMode.value
})
}
/**
* Restores the viewer configuration from the standalone cache for the given model URL.
*
* @param modelUrl URL of the model to restore config for
*/
function restoreStandaloneConfig(modelUrl: string) {
const cached = standaloneConfigCache.get(modelUrl)
const config = cached ?? DEFAULT_STANDALONE_CONFIG
backgroundColor.value = config.backgroundColor
showGrid.value = config.showGrid
cameraType.value = config.cameraType
fov.value = config.fov
lightIntensity.value = config.lightIntensity
backgroundImage.value = config.backgroundImage
hasBackgroundImage.value = !!config.backgroundImage
backgroundRenderMode.value = config.backgroundRenderMode
upDirection.value = config.upDirection
materialMode.value = config.materialMode
if (cached?.cameraState && load3d) {
load3d.setCameraState(cached.cameraState)
}
}
/**
* Exports the current model in the specified format.
*
* @param format The export format (e.g., 'glb', 'obj')
*/
const exportModel = async (format: string) => {
if (!load3d) return
@@ -511,30 +432,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Handles resizing the 3D viewer.
*/
const handleResize = () => {
load3d?.handleResize()
}
/**
* Notifies the viewer that the mouse has entered the viewer area.
*/
const handleMouseEnter = () => {
load3d?.updateStatusMouseOnViewer(true)
}
/**
* Notifies the viewer that the mouse has left the viewer area.
*/
const handleMouseLeave = () => {
load3d?.updateStatusMouseOnViewer(false)
}
/**
* Restores the viewer state to its initial values when the viewer was opened.
*/
const restoreInitialState = () => {
if (!node) return
@@ -574,11 +483,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Applies the current viewer changes back to the source node and its properties.
*
* @returns Promise resolving to true if changes were applied successfully
*/
const applyChanges = async () => {
if (!node || !sourceLoad3d || !load3d) return false
@@ -623,30 +527,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
return true
}
/**
* Refreshes the viewport of the current Load3d instance.
*/
const refreshViewport = () => {
useLoad3dService().handleViewportRefresh(load3d)
}
/**
* Returns the subfolder path for file uploads based on the node properties.
*
* @returns The subfolder string
*/
const getUploadSubfolder = () => {
const resourceFolder = String(
node?.properties?.['Resource Folder'] ?? ''
).trim()
return resourceFolder ? `3d/${resourceFolder}` : '3d'
}
/**
* Handles updating the background image either by clearing it or uploading a new file.
*
* @param file The image file to upload, or null to clear the background
*/
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
backgroundImage.value = ''
@@ -654,16 +538,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
return
}
if (!load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
if (!node) {
return
}
try {
const uploadPath = await Load3dUtils.uploadFile(
file,
getUploadSubfolder()
)
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadPath = await Load3dUtils.uploadFile(file, subfolder)
if (uploadPath) {
backgroundImage.value = uploadPath
@@ -675,22 +561,24 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Handles dropping a new model file into the viewer.
*
* @param file The 3D model file to load
*/
const handleModelDrop = async (file: File) => {
if (!load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
if (!node) {
return
}
try {
const uploadedPath = await Load3dUtils.uploadFile(
file,
getUploadSubfolder()
)
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) {
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
@@ -706,7 +594,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
await load3d.loadModel(modelUrl)
const modelWidget = node?.widgets?.find((w) => w.name === 'model_file')
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
const options = modelWidget.options as { values?: string[] } | undefined
if (options?.values && !options.values.includes(uploadedPath)) {
@@ -720,17 +608,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Cleans up the viewer resources and saves the current standalone config if applicable.
*/
const cleanup = () => {
if (isStandaloneMode.value) {
saveStandaloneConfig()
}
load3d?.remove()
load3d = null
sourceLoad3d = null
currentModelUrl = null
}
return {

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
@@ -27,9 +27,9 @@ import {
} from '@/stores/widgetValueStore'
import {
cleanupComplexPromotionFixtureNodeType,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState,
setupComplexPromotionFixture
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
@@ -48,14 +48,9 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[], string[]] {
): [SubgraphNode, LGraphNode[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
@@ -67,8 +62,7 @@ function setupSubgraph(
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
return [subgraphNode, innerNodes]
}
function setPromotions(
@@ -103,8 +97,13 @@ function callSyncPromotions(node: SubgraphNode) {
)._syncPromotions()
}
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
describe(createPromotedWidgetView, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockDomWidgetStore.widgetStates.clear()
vi.clearAllMocks()
})
@@ -316,10 +315,18 @@ describe(createPromotedWidgetView, () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const store = useWidgetValueStore()
const bareId = String(innerNode.id)
// No displayName → falls back to widgetName
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
// Store label is undefined → falls back to displayName/widgetName
const state = store.getWidget(
subgraphNode.rootGraph.id,
bareId as never,
'myWidget'
)
state!.label = undefined
expect(view1.label).toBe('myWidget')
// With displayName → falls back to displayName
@@ -428,6 +435,10 @@ describe(createPromotedWidgetView, () => {
})
describe('SubgraphNode.widgets getter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('defers promotions while subgraph node id is -1 and flushes on add', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'picker_input', type: '*' }]
@@ -565,7 +576,7 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('input-linked same-name widgets propagate value to all connected nodes while store-promoted peer stays independent', () => {
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
@@ -620,17 +631,53 @@ describe('SubgraphNode.widgets getter', () => {
linkedView.value = 'shared-value'
// Both linked nodes share the same SubgraphInput slot, so the value
// propagates to all connected widgets via getLinkedInputWidgets().
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
const widgetStore = useWidgetValueStore()
const graphId = subgraphNode.rootGraph.id
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent')
promotedView.value = 'independent-updated'
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent-updated')
})
test('duplicate-name promoted views map slot linkage by view identity', () => {
@@ -1006,9 +1053,9 @@ describe('SubgraphNode.widgets getter', () => {
})
test('caches view objects across getter calls (stable references)', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
const first = subgraphNode.widgets[0]
const second = subgraphNode.widgets[0]
@@ -1016,10 +1063,10 @@ describe('SubgraphNode.widgets getter', () => {
})
test('memoizes promotion list by reference', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
const views1 = subgraphNode.widgets
expect(views1).toHaveLength(1)
@@ -1029,52 +1076,52 @@ describe('SubgraphNode.widgets getter', () => {
expect(views2[0]).toBe(views1[0])
// New store value with same content → same cached view object
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
const views3 = subgraphNode.widgets
expect(views3[0]).toBe(views1[0])
})
test('cleans stale cache entries when promotions shrink', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
['1', 'widgetA'],
['1', 'widgetB']
])
expect(subgraphNode.widgets).toHaveLength(2)
const viewA = subgraphNode.widgets[0]
// Remove widgetA from promotion list
setPromotions(subgraphNode, [[innerIds[0], 'widgetB']])
setPromotions(subgraphNode, [['1', 'widgetB']])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
// Re-adding widgetA creates a new view (old one was cleaned)
setPromotions(subgraphNode, [
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
['1', 'widgetB'],
['1', 'widgetA']
])
const newViewA = subgraphNode.widgets[1]
expect(newViewA).not.toBe(viewA)
})
test('deduplicates entries with same nodeId:widgetName', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetA']
['1', 'widgetA'],
['1', 'widgetA']
])
expect(subgraphNode.widgets).toHaveLength(1)
})
test('setter is a no-op', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
// Assigning to widgets does nothing
subgraphNode.widgets = []
@@ -1424,10 +1471,14 @@ describe('SubgraphNode.widgets getter', () => {
})
describe('widgets getter caching', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
@@ -1455,9 +1506,9 @@ describe('widgets getter caching', () => {
})
test('does not re-run reconciliation when only canvas frame advances', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
@@ -1522,19 +1573,19 @@ describe('widgets getter caching', () => {
})
test('preserves view identities when promotion order changes', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
['1', 'widgetA'],
['1', 'widgetB']
])
const [viewA, viewB] = subgraphNode.widgets
setPromotions(subgraphNode, [
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
['1', 'widgetB'],
['1', 'widgetA']
])
expect(subgraphNode.widgets[0]).toBe(viewB)
@@ -1542,15 +1593,15 @@ describe('widgets getter caching', () => {
})
test('deduplicates by key while preserving first-occurrence order', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
['1', 'widgetB'],
['1', 'widgetA'],
['1', 'widgetB'],
['1', 'widgetA']
])
expect(subgraphNode.widgets).toHaveLength(2)
@@ -1559,9 +1610,9 @@ describe('widgets getter caching', () => {
})
test('returns same array reference when promotions unchanged', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
const result1 = subgraphNode.widgets
const result2 = subgraphNode.widgets
@@ -1569,16 +1620,16 @@ describe('widgets getter caching', () => {
})
test('returns new array after promotion change', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
const result1 = subgraphNode.widgets
setPromotions(subgraphNode, [
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
['1', 'widgetA'],
['1', 'widgetB']
])
const result2 = subgraphNode.widgets
@@ -1587,12 +1638,12 @@ describe('widgets getter caching', () => {
})
test('invalidates cache on removeWidget', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
['1', 'widgetA'],
['1', 'widgetB']
])
const result1 = subgraphNode.widgets
@@ -1606,26 +1657,30 @@ describe('widgets getter caching', () => {
})
describe('promote/demote cycle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('promoting adds to store and widgets reflects it', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
expect(subgraphNode.widgets).toHaveLength(0)
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
const view = subgraphNode.widgets[0] as PromotedWidgetView
expect(view.sourceNodeId).toBe(innerIds[0])
expect(view.sourceNodeId).toBe('1')
expect(view.sourceWidgetName).toBe('widgetA')
})
test('demoting via removeWidget removes from store', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
['1', 'widgetA'],
['1', 'widgetB']
])
const viewA = subgraphNode.widgets[0]
@@ -1638,16 +1693,16 @@ describe('promote/demote cycle', () => {
subgraphNode.id
)
expect(entries).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ interiorNodeId: '1', widgetName: 'widgetB' }
])
})
test('full promote → demote → re-promote cycle', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
// Promote
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
const view1 = subgraphNode.widgets[0]
@@ -1656,7 +1711,7 @@ describe('promote/demote cycle', () => {
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote — creates a new view since the cache was cleared
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
setPromotions(subgraphNode, [['1', 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0]).not.toBe(view1)
expect(
@@ -1666,18 +1721,22 @@ describe('promote/demote cycle', () => {
})
describe('disconnected state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('view resolves type when interior widget exists', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('number', 'numWidget', 42, () => {})
setPromotions(subgraphNode, [[innerIds[0], 'numWidget']])
setPromotions(subgraphNode, [['1', 'numWidget']])
expect(subgraphNode.widgets[0].type).toBe('number')
})
test('keeps promoted entry as disconnected when interior node is removed', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'myWidget']])
setPromotions(subgraphNode, [['1', 'myWidget']])
expect(subgraphNode.widgets[0].type).toBe('text')
@@ -1688,9 +1747,9 @@ describe('disconnected state', () => {
})
test('view recovers when interior widget is re-added', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'myWidget']])
setPromotions(subgraphNode, [['1', 'myWidget']])
// Remove widget
innerNodes[0].widgets!.pop()
@@ -1772,6 +1831,10 @@ function createTwoLevelNestedSubgraph() {
}
describe('promoted combo rendering', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('draw shows value even when interior combo is computedDisabled', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
@@ -2088,6 +2151,7 @@ describe('promoted combo rendering', () => {
describe('DOM widget promotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
@@ -2111,9 +2175,9 @@ describe('DOM widget promotion', () => {
}
test('draw registers position override for DOM widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
setPromotions(subgraphNode, [['1', 'textarea']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
@@ -2125,9 +2189,9 @@ describe('DOM widget promotion', () => {
})
test('draw registers position override for component widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
createMockComponentWidget(innerNodes[0], 'compWidget')
setPromotions(subgraphNode, [[innerIds[0], 'compWidget']])
setPromotions(subgraphNode, [['1', 'compWidget']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
@@ -2139,9 +2203,9 @@ describe('DOM widget promotion', () => {
})
test('draw does not register override for non-DOM widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'textWidget', 'val', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'textWidget']])
setPromotions(subgraphNode, [['1', 'textWidget']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30, true)
@@ -2168,14 +2232,14 @@ describe('DOM widget promotion', () => {
})
test('computeLayoutSize delegates to interior DOM widget', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
const domWidget = createMockDOMWidget(innerNodes[0], 'textarea')
domWidget.computeLayoutSize = vi.fn(() => ({
minHeight: 100,
maxHeight: 300,
minWidth: 0
}))
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
setPromotions(subgraphNode, [['1', 'textarea']])
const view = subgraphNode.widgets[0]
const result = view.computeLayoutSize!(subgraphNode)
@@ -2184,9 +2248,9 @@ describe('DOM widget promotion', () => {
})
test('demoting clears position override for DOM widget', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
setPromotions(subgraphNode, [['1', 'textarea']])
const view = subgraphNode.widgets[0]
subgraphNode.removeWidget(view)
@@ -2197,12 +2261,12 @@ describe('DOM widget promotion', () => {
})
test('onRemoved clears position overrides for all promoted DOM widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'widgetA')
createMockDOMWidget(innerNodes[0], 'widgetB')
setPromotions(subgraphNode, [
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
['1', 'widgetA'],
['1', 'widgetB']
])
// Access widgets to populate cache

View File

@@ -264,28 +264,4 @@ describe('promoteRecommendedWidgets', () => {
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
const subgraph = createTestSubgraph()
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
).toBe(true)
})
})

View File

@@ -11,7 +11,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
} from '@/composables/node/useNodeCanvasImagePreview'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'

View File

@@ -6,8 +6,7 @@ import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphI
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -62,7 +61,6 @@ function addLinkedInteriorInput(
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
vi.clearAllMocks()
})
@@ -123,21 +121,6 @@ describe('resolveSubgraphInputLink', () => {
expect(result).toBe('seed_input')
})
test('resolves the first connected link when multiple links exist', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
// First connected wins — consistent with SubgraphNode._resolveLinkedPromotionBySubgraphInput
expect(result).toBe('first_input')
})
test('caches getTargetWidget result within the same callback evaluation', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('model')
const linked = addLinkedInteriorInput(

View File

@@ -19,9 +19,9 @@ export function resolveSubgraphInputLink<TResult>(
)
if (!inputSlot) return undefined
// Iterate forward so the first connected source is the promoted representative,
// matching SubgraphNode._resolveLinkedPromotionBySubgraphInput.
for (const linkId of inputSlot.linkIds) {
// Iterate from newest to oldest so the latest connection wins.
for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) {
const linkId = inputSlot.linkIds[index]
const link = node.subgraph.getLink(linkId)
if (!link) continue

View File

@@ -8,8 +8,7 @@ import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -24,35 +23,33 @@ vi.mock('@/services/litegraphService', () => ({
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[], string[]] {
): [SubgraphNode, LGraphNode[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes: LGraphNode[] = []
const innerNodes = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
return [subgraphNode, innerNodes]
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
@@ -60,20 +57,18 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
])
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
const [subgraphNode, innerNodes] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' },
{ interiorNodeId: innerIds[1], widgetName: 'stringWidget' }
{ interiorNodeId: '1', widgetName: 'stringWidget' },
{ interiorNodeId: '2', widgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
@@ -82,14 +77,14 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
})
test('Will reflect proxyWidgets order changes', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
@@ -97,19 +92,19 @@ describe('Subgraph proxyWidgets', () => {
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' }
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
])
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
@@ -119,12 +114,12 @@ describe('Subgraph proxyWidgets', () => {
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
@@ -138,12 +133,12 @@ describe('Subgraph proxyWidgets', () => {
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Renders placeholder when interior widget is detached', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
@@ -159,7 +154,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
@@ -167,7 +162,7 @@ describe('Subgraph proxyWidgets', () => {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
innerIds[0],
String(innerNodes[0].id),
'stringWidget'
)
expect(subgraphNode.widgets.length).toBe(1)
@@ -179,7 +174,7 @@ describe('Subgraph proxyWidgets', () => {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
innerIds[0],
String(innerNodes[0].id),
'stringWidget'
)
expect(subgraphNode.widgets.length).toBe(1)
@@ -188,19 +183,17 @@ describe('Subgraph proxyWidgets', () => {
).toHaveLength(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
])
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
})
test('removeWidget removes from promotion list and view cache', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
@@ -211,19 +204,19 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }])
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'widgetB' }])
})
test('removeWidgetByName removes from promotion list', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
]
)
@@ -234,12 +227,12 @@ describe('Subgraph proxyWidgets', () => {
})
test('removeWidget cleans up input references', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
@@ -255,12 +248,12 @@ describe('Subgraph proxyWidgets', () => {
})
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
@@ -272,23 +265,23 @@ describe('Subgraph proxyWidgets', () => {
})
test('serialize preserves proxyWidgets in properties', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
]
)
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
['1', 'widgetA'],
['1', 'widgetB']
])
})
})

View File

@@ -1,67 +0,0 @@
import { describe, expect, it } from 'vitest'
import { parseProxyWidgets } from './promotionSchema'
describe('parseProxyWidgets', () => {
describe('valid inputs', () => {
it('returns empty array for undefined', () => {
expect(parseProxyWidgets(undefined)).toEqual([])
})
it('returns empty array for empty array', () => {
expect(parseProxyWidgets([])).toEqual([])
})
it('parses a single entry', () => {
expect(parseProxyWidgets([['1', 'seed']])).toEqual([['1', 'seed']])
})
it('parses multiple entries', () => {
const input = [
['1', 'seed'],
['2', 'steps']
]
expect(parseProxyWidgets(input)).toEqual(input)
})
it('parses a JSON string', () => {
expect(parseProxyWidgets('[["1", "seed"]]')).toEqual([['1', 'seed']])
})
it('parses a double-encoded JSON string', () => {
expect(parseProxyWidgets('"[[\\"1\\", \\"seed\\"]]"')).toEqual([
['1', 'seed']
])
})
})
describe('invalid inputs (resilient)', () => {
it('returns empty array for malformed JSON string', () => {
expect(parseProxyWidgets('not valid json')).toEqual([])
})
it('returns empty array for wrong tuple length', () => {
expect(parseProxyWidgets([['only-one']] as unknown as undefined)).toEqual(
[]
)
})
it('returns empty array for wrong shape', () => {
expect(
parseProxyWidgets({ wrong: 'shape' } as unknown as undefined)
).toEqual([])
})
it('returns empty array for number', () => {
expect(parseProxyWidgets(42)).toEqual([])
})
it('returns empty array for null', () => {
expect(parseProxyWidgets(null as unknown as undefined)).toEqual([])
})
it('returns empty array for empty string', () => {
expect(parseProxyWidgets('')).toEqual([])
})
})
})

View File

@@ -9,17 +9,12 @@ type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
try {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(`Invalid assignment for properties.proxyWidgets:\n${error}`)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgets:', e)
}
return []
const error = fromZodError(result.error)
throw new Error(`Invalid assignment for properties.proxyWidgets:\n${error}`)
}

View File

@@ -33,8 +33,6 @@ app.registerExtension({
this.addInput('', '*')
this.addOutput(this.properties.showOutputText ? '*' : '', '*')
this.setSize(this.computeSize())
// This node is purely frontend and does not impact the resulting prompt so should not be serialized
this.isVirtualNode = true
}

View File

@@ -7,16 +7,14 @@ import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
type SaveMeshOutput = NodeOutputWith<{
'3d'?: ResultItem[]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
@@ -105,7 +103,7 @@ useExtensionService().registerExtension({
config.configureForSaveMesh(loadFolder, filePath)
if (isAssetPreviewSupported()) {
if (api.getServerFeature('assets', false)) {
const filename = fileInfo.filename ?? ''
const onModelLoaded = () => {
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)

View File

@@ -142,10 +142,7 @@ app.registerExtension({
useToastStore().addAlert(err)
throw new Error(err)
}
const data = await resp.json()
const serverName = data.name ?? name
const subfolder = data.subfolder ?? 'webcam'
return `${subfolder}/${serverName} [temp]`
return `webcam/${name} [temp]`
}
// @ts-expect-error fixme ts strict error

View File

@@ -2972,14 +2972,14 @@ export class Subgraph
* @param input The input slot to remove.
*/
removeInput(input: SubgraphInput): void {
input.disconnect()
const index = this.inputs.indexOf(input)
if (index === -1) throw new Error('Input not found')
const mayContinue = this.events.dispatch('removing-input', { input, index })
if (!mayContinue) return
input.disconnect()
this.inputs.splice(index, 1)
const { length } = this.inputs
@@ -2993,6 +2993,8 @@ export class Subgraph
* @param output The output slot to remove.
*/
removeOutput(output: SubgraphOutput): void {
output.disconnect()
const index = this.outputs.indexOf(output)
if (index === -1) throw new Error('Output not found')
@@ -3002,8 +3004,6 @@ export class Subgraph
})
if (!mayContinue) return
output.disconnect()
this.outputs.splice(index, 1)
const { length } = this.outputs

View File

@@ -1207,14 +1207,6 @@ export class LGraphNode
: this.inputs[slot]
}
/**
* Resolves the output source for cross-graph virtual nodes (e.g. Set/Get),
* bypassing {@link getInputLink} when the source lives in a different graph.
*/
resolveVirtualOutput?(
slot: number
): { node: LGraphNode; slot: number } | undefined
/**
* Returns the link info in the connection of an input slot
* @returns object or null

View File

@@ -87,7 +87,7 @@ export { ContextMenu } from './ContextMenu'
export { DragAndScale } from './DragAndScale'
export { Rectangle } from './infrastructure/Rectangle'
export type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
export { RecursionError } from './infrastructure/RecursionError'
export type {
CanvasColour,
ColorOption,

View File

@@ -1,3 +1,4 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,16 +13,10 @@ import {
import {
createNestedSubgraphs,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('ExecutableNodeDTO Creation', () => {
describe.skip('ExecutableNodeDTO Creation', () => {
it('should create DTO from regular node', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -111,7 +106,7 @@ describe('ExecutableNodeDTO Creation', () => {
})
})
describe('ExecutableNodeDTO Path-Based IDs', () => {
describe.skip('ExecutableNodeDTO Path-Based IDs', () => {
it('should generate simple ID for root node', () => {
const graph = new LGraph()
const node = new LGraphNode('Root Node')
@@ -165,7 +160,7 @@ describe('ExecutableNodeDTO Path-Based IDs', () => {
})
})
describe('ExecutableNodeDTO Input Resolution', () => {
describe.skip('ExecutableNodeDTO Input Resolution', () => {
it('should return undefined for unconnected inputs', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -207,7 +202,7 @@ describe('ExecutableNodeDTO Input Resolution', () => {
})
})
describe('ExecutableNodeDTO Output Resolution', () => {
describe.skip('ExecutableNodeDTO Output Resolution', () => {
it('should resolve outputs for simple nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -387,103 +382,7 @@ describe('ALWAYS mode node output resolution', () => {
})
})
describe('Virtual node resolveVirtualOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should resolve through resolveVirtualOutput when implemented', () => {
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
sourceNode.addOutput('out', 'IMAGE')
graph.add(sourceNode)
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => ({ node: sourceNode, slot: 0 })
graph.add(virtualNode)
const nodeDtoMap = new Map()
const sourceDto = new ExecutableNodeDTO(
sourceNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(sourceDto.id, sourceDto)
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(sourceDto)
expect(resolved?.origin_slot).toBe(0)
})
it('should throw when resolveVirtualOutput returns a node with no matching DTO', () => {
const graph = new LGraph()
const unmappedNode = new LGraphNode('Unmapped Source')
unmappedNode.addOutput('out', 'IMAGE')
graph.add(unmappedNode)
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => ({
node: unmappedNode,
slot: 0
})
graph.add(virtualNode)
const nodeDtoMap = new Map()
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
expect(() => virtualDto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No DTO found for virtual source node'
)
})
it('should fall through to getInputLink when resolveVirtualOutput returns undefined', () => {
const graph = new LGraph()
const virtualNode = new LGraphNode('Virtual Passthrough')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => undefined
graph.add(virtualNode)
const nodeDtoMap = new Map()
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
const spy = vi.spyOn(virtualNode, 'getInputLink')
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeUndefined()
expect(spy).toHaveBeenCalledWith(0)
})
})
describe('ExecutableNodeDTO Properties', () => {
describe.skip('ExecutableNodeDTO Properties', () => {
it('should provide access to basic properties', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -518,7 +417,7 @@ describe('ExecutableNodeDTO Properties', () => {
})
})
describe('ExecutableNodeDTO Memory Efficiency', () => {
describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
it('should create lightweight objects', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -542,7 +441,7 @@ describe('ExecutableNodeDTO Memory Efficiency', () => {
expect(dto.hasOwnProperty('widgets')).toBe(false) // Widgets not copied
})
it('should drop local references without explicit disposal', () => {
it('should handle disposal without memory leaks', () => {
const graph = new LGraph()
const nodes: ExecutableNodeDTO[] = []
@@ -585,20 +484,19 @@ describe('ExecutableNodeDTO Memory Efficiency', () => {
})
})
describe('ExecutableNodeDTO Integration', () => {
describe.skip('ExecutableNodeDTO Integration', () => {
it('should work with SubgraphNode flattening', () => {
const subgraph = createTestSubgraph({ nodeCount: 3 })
const subgraphNode = createTestSubgraphNode(subgraph)
const flattened = subgraphNode.getInnerNodes(new Map())
const idPattern = new RegExp(`^${subgraphNode.id}:\\d+$`)
expect(flattened).toHaveLength(3)
expect(flattened[0]).toBeInstanceOf(ExecutableNodeDTO)
expect(flattened[0].id).toMatch(idPattern)
expect(flattened[0].id).toMatch(/^1:\d+$/)
})
it('should handle nested subgraph flattening', () => {
it.skip('should handle nested subgraph flattening', () => {
// FIXME: Complex nested structure requires proper parent graph setup
// This test needs investigation of how resolveSubgraphIdPath works
// Skip for now - will implement in edge cases test file
@@ -660,7 +558,7 @@ describe('ExecutableNodeDTO Integration', () => {
})
})
describe('ExecutableNodeDTO Scale Testing', () => {
describe.skip('ExecutableNodeDTO Scale Testing', () => {
it('should create DTOs at scale', () => {
const graph = new LGraph()
const startTime = performance.now()

View File

@@ -291,20 +291,6 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
return this._resolveSubgraphOutput(slot, type, visited)
if (node.isVirtualNode) {
// Cross-graph virtual nodes (e.g. Set/Get) resolve their source directly.
const virtualSource = this.node.resolveVirtualOutput?.(slot)
if (virtualSource) {
const inputNodeDto = [...this.nodesByExecutionId.values()].find(
(dto) =>
dto instanceof ExecutableNodeDTO && dto.node === virtualSource.node
)
if (!inputNodeDto)
throw new Error(
`No DTO found for virtual source node [${virtualSource.node.id}]`
)
return inputNodeDto.resolveOutput(virtualSource.slot, type, visited)
}
const virtualLink = this.node.getInputLink(slot)
if (virtualLink) {
const { inputNode } = virtualLink.resolve(this.graph)

View File

@@ -1,31 +1,28 @@
// TODO: Fix these tests after migration
/**
* Core Subgraph Tests
*
* This file implements fundamental tests for the Subgraph class that establish
* patterns for the rest of the testing team. These tests cover construction
* and basic I/O management.
* patterns for the rest of the testing team. These tests cover construction,
* basic I/O management, and known issues.
*/
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { createUuidv4, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
createUuidv4,
RecursionError,
LGraph,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
assertSubgraphStructure,
createTestSubgraph,
createTestSubgraphData,
resetSubgraphFixtureState
createTestSubgraphData
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('Subgraph Construction', () => {
describe.skip('Subgraph Construction', () => {
it('should create a subgraph with minimal data', () => {
const subgraph = createTestSubgraph()
@@ -47,10 +44,11 @@ describe('Subgraph Construction', () => {
it('should require a root graph', () => {
const subgraphData = createTestSubgraphData()
const createWithoutRoot = () =>
new Subgraph(null as unknown as LGraph, subgraphData)
expect(createWithoutRoot).toThrow('Root graph is required')
expect(() => {
// @ts-expect-error Testing invalid null parameter
new Subgraph(null, subgraphData)
}).toThrow('Root graph is required')
})
it('should accept custom name and ID', () => {
@@ -65,9 +63,31 @@ describe('Subgraph Construction', () => {
expect(subgraph.id).toBe(customId)
expect(subgraph.name).toBe(customName)
})
it('should initialize with empty inputs and outputs', () => {
const subgraph = createTestSubgraph()
expect(subgraph.inputs).toHaveLength(0)
expect(subgraph.outputs).toHaveLength(0)
expect(subgraph.widgets).toHaveLength(0)
})
it('should have properly configured input and output nodes', () => {
const subgraph = createTestSubgraph()
// Input node should be positioned on the left
expect(subgraph.inputNode.pos[0]).toBeLessThan(100)
// Output node should be positioned on the right
expect(subgraph.outputNode.pos[0]).toBeGreaterThan(300)
// Both should reference the subgraph
expect(subgraph.inputNode.subgraph).toBe(subgraph)
expect(subgraph.outputNode.subgraph).toBe(subgraph)
})
})
describe('Subgraph Input/Output Management', () => {
describe.skip('Subgraph Input/Output Management', () => {
subgraphTest('should add a single input', ({ emptySubgraph }) => {
const input = emptySubgraph.addInput('test_input', 'number')
@@ -144,3 +164,163 @@ describe('Subgraph Input/Output Management', () => {
expect(simpleSubgraph.outputs.indexOf(simpleSubgraph.outputs[0])).toBe(0)
})
})
describe.skip('Subgraph Serialization', () => {
subgraphTest('should serialize empty subgraph', ({ emptySubgraph }) => {
const serialized = emptySubgraph.asSerialisable()
expect(serialized.version).toBe(1)
expect(serialized.id).toBeTruthy()
expect(serialized.name).toBe('Empty Test Subgraph')
expect(serialized.inputs).toHaveLength(0)
expect(serialized.outputs).toHaveLength(0)
expect(serialized.nodes).toHaveLength(0)
expect(typeof serialized.links).toBe('object')
})
subgraphTest(
'should serialize subgraph with inputs and outputs',
({ simpleSubgraph }) => {
const serialized = simpleSubgraph.asSerialisable()
expect(serialized.inputs).toHaveLength(1)
expect(serialized.outputs).toHaveLength(1)
expect(serialized.inputs![0].name).toBe('input')
expect(serialized.inputs![0].type).toBe('number')
expect(serialized.outputs![0].name).toBe('output')
expect(serialized.outputs![0].type).toBe('number')
}
)
subgraphTest(
'should include input and output nodes in serialization',
({ emptySubgraph }) => {
const serialized = emptySubgraph.asSerialisable()
expect(serialized.inputNode).toBeDefined()
expect(serialized.outputNode).toBeDefined()
expect(serialized.inputNode.id).toBe(-10)
expect(serialized.outputNode.id).toBe(-20)
}
)
})
describe.skip('Subgraph Known Issues', () => {
it.skip('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
// This test documents that MAX_NESTED_SUBGRAPHS = 1000 is defined
// but not actually enforced anywhere in the code.
//
// Expected behavior: Should throw error when nesting exceeds limit
// Actual behavior: No validation is performed
//
// This safety limit should be implemented to prevent runaway recursion.
})
it('should provide MAX_NESTED_SUBGRAPHS constant', () => {
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
})
it('should have recursion detection in place', () => {
// Verify that RecursionError is available and can be thrown
expect(() => {
throw new RecursionError('test recursion')
}).toThrow(RecursionError)
expect(() => {
throw new RecursionError('test recursion')
}).toThrow('test recursion')
})
})
describe.skip('Subgraph Root Graph Relationship', () => {
it('should maintain reference to root graph', () => {
const rootGraph = new LGraph()
const subgraphData = createTestSubgraphData()
const subgraph = new Subgraph(rootGraph, subgraphData)
expect(subgraph.rootGraph).toBe(rootGraph)
})
it('should inherit root graph in nested subgraphs', () => {
const rootGraph = new LGraph()
const parentData = createTestSubgraphData({
name: 'Parent Subgraph'
})
const parentSubgraph = new Subgraph(rootGraph, parentData)
// Create a nested subgraph
const nestedData = createTestSubgraphData({
name: 'Nested Subgraph'
})
const nestedSubgraph = new Subgraph(rootGraph, nestedData)
expect(nestedSubgraph.rootGraph).toBe(rootGraph)
expect(parentSubgraph.rootGraph).toBe(rootGraph)
})
})
describe.skip('Subgraph Error Handling', () => {
subgraphTest(
'should handle removing non-existent input gracefully',
({ emptySubgraph }) => {
// Create a fake input that doesn't belong to this subgraph
const fakeInput = emptySubgraph.addInput('temp', 'number')
emptySubgraph.removeInput(fakeInput) // Remove it first
// Now try to remove it again
expect(() => {
emptySubgraph.removeInput(fakeInput)
}).toThrow('Input not found')
}
)
subgraphTest(
'should handle removing non-existent output gracefully',
({ emptySubgraph }) => {
// Create a fake output that doesn't belong to this subgraph
const fakeOutput = emptySubgraph.addOutput('temp', 'number')
emptySubgraph.removeOutput(fakeOutput) // Remove it first
// Now try to remove it again
expect(() => {
emptySubgraph.removeOutput(fakeOutput)
}).toThrow('Output not found')
}
)
})
describe.skip('Subgraph Integration', () => {
it("should work with LGraph's node management", () => {
const subgraph = createTestSubgraph({
nodeCount: 3
})
// Verify nodes were added to the subgraph
expect(subgraph.nodes).toHaveLength(3)
// Verify we can access nodes by ID
const firstNode = subgraph.getNodeById(1)
expect(firstNode).toBeDefined()
expect(firstNode?.title).toContain('Test Node')
})
it('should maintain link integrity', () => {
const subgraph = createTestSubgraph({
nodeCount: 2
})
const node1 = subgraph.nodes[0]
const node2 = subgraph.nodes[1]
// Connect the nodes
node1.connect(0, node2, 0)
// Verify link was created
expect(subgraph.links.size).toBe(1)
// Verify link integrity
const link = Array.from(subgraph.links.values())[0]
expect(link.origin_id).toBe(node1.id)
expect(link.target_id).toBe(node2.id)
})
})

View File

@@ -1,6 +1,5 @@
import { assert, beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
// TODO: Fix these tests after migration
import { assert, describe, expect, it } from 'vitest'
import {
LGraphGroup,
@@ -9,19 +8,11 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNode(
graph: LGraph,
inputs: ISlotType[] = [],
@@ -49,8 +40,8 @@ function createNode(
graph.add(node)
return node
}
describe('SubgraphConversion', () => {
describe('Subgraph Unpacking Functionality', () => {
describe.skip('SubgraphConversion', () => {
describe.skip('Subgraph Unpacking Functionality', () => {
it('Should keep interior nodes and links', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -206,43 +197,4 @@ describe('SubgraphConversion', () => {
expect(linkRefCount).toBe(4)
})
})
describe('Promotion cleanup on unpack', () => {
it('Should clear promotions for the unpacked subgraph node', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNode = createNode(subgraph, [], ['number'])
innerNode.addWidget('text', 'myWidget', 'default', () => {})
const promotionStore = usePromotionStore()
const graphId = graph.id
const subgraphNodeId = subgraphNode.id
promotionStore.promote(
graphId,
subgraphNodeId,
String(innerNode.id),
'myWidget'
)
expect(
promotionStore.isPromoted(
graphId,
subgraphNodeId,
String(innerNode.id),
'myWidget'
)
).toBe(true)
graph.unpackSubgraph(subgraphNode)
expect(graph.getNodeById(subgraphNodeId)).toBeUndefined()
expect(
promotionStore.getPromotions(graphId, subgraphNodeId)
).toHaveLength(0)
})
})
})

View File

@@ -1,28 +1,21 @@
// TODO: Fix these tests after migration
/**
* SubgraphEdgeCases Tests
*
* Tests for edge cases, error handling, and boundary conditions in the subgraph system.
* This covers unusual scenarios, invalid states, and stress testing.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
createNestedSubgraphs,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphEdgeCases - Recursion Detection', () => {
describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
it('should handle circular subgraph references without crashing', () => {
const sub1 = createTestSubgraph({ name: 'Sub1' })
const sub2 = createTestSubgraph({ name: 'Sub2' })
@@ -31,11 +24,14 @@ describe('SubgraphEdgeCases - Recursion Detection', () => {
const node1 = createTestSubgraphNode(sub1, { id: 1 })
const node2 = createTestSubgraphNode(sub2, { id: 2 })
// Current limitation: adding a circular reference overflows recursion depth.
sub1.add(node2)
sub2.add(node1)
// Should not crash or hang - currently throws path resolution error due to circular structure
expect(() => {
sub2.add(node1)
}).toThrow(RangeError)
const executableNodes = new Map()
node1.getInnerNodes(executableNodes)
}).toThrow(/Node \[\d+\] not found/) // Current behavior: path resolution fails
})
it('should handle deep nesting scenarios', () => {
@@ -52,14 +48,20 @@ describe('SubgraphEdgeCases - Recursion Detection', () => {
expect(firstLevel.isSubgraphNode()).toBe(true)
})
it('should throw RangeError for self-referential subgraph', () => {
// Current limitation: creating self-referential subgraph instances overflows recursion depth.
it.skip('should use WeakSet for cycle detection', () => {
// TODO: This test is currently skipped because cycle detection has a bug
// The fix is to pass 'visited' directly instead of 'new Set(visited)' in SubgraphNode.ts:299
const subgraph = createTestSubgraph({ nodeCount: 1 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Add to own subgraph to create cycle
subgraph.add(subgraphNode)
// Should throw due to cycle detection
const executableNodes = new Map()
expect(() => {
subgraph.add(subgraphNode)
}).toThrow(RangeError)
subgraphNode.getInnerNodes(executableNodes)
}).toThrow(/while flattening subgraph/i)
})
it('should respect MAX_NESTED_SUBGRAPHS constant', () => {
@@ -74,7 +76,7 @@ describe('SubgraphEdgeCases - Recursion Detection', () => {
})
})
describe('SubgraphEdgeCases - Invalid States', () => {
describe.skip('SubgraphEdgeCases - Invalid States', () => {
it('should handle removing non-existent inputs gracefully', () => {
const subgraph = createTestSubgraph()
const fakeInput = {
@@ -118,9 +120,7 @@ describe('SubgraphEdgeCases - Invalid States', () => {
expect(() => {
subgraph.addInput(undefinedString, 'number')
}).not.toThrow()
expect(subgraph.inputs).toHaveLength(1)
}).toThrow()
})
it('should handle null/undefined output names', () => {
@@ -135,9 +135,7 @@ describe('SubgraphEdgeCases - Invalid States', () => {
expect(() => {
subgraph.addOutput(undefinedString, 'number')
}).not.toThrow()
expect(subgraph.outputs).toHaveLength(1)
}).toThrow()
})
it('should handle empty string names', () => {
@@ -162,14 +160,11 @@ describe('SubgraphEdgeCases - Invalid States', () => {
// Undefined type should throw error
expect(() => {
subgraph.addInput('test', undefinedString)
}).not.toThrow()
}).toThrow()
expect(() => {
subgraph.addOutput('test', undefinedString)
}).not.toThrow()
expect(subgraph.inputs).toHaveLength(1)
expect(subgraph.outputs).toHaveLength(1)
}).toThrow()
})
it('should handle duplicate slot names', () => {
@@ -190,7 +185,7 @@ describe('SubgraphEdgeCases - Invalid States', () => {
})
})
describe('SubgraphEdgeCases - Boundary Conditions', () => {
describe.skip('SubgraphEdgeCases - Boundary Conditions', () => {
it('should handle empty subgraphs (no nodes, no IO)', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -244,9 +239,35 @@ describe('SubgraphEdgeCases - Boundary Conditions', () => {
const flattened = subgraphNode.getInnerNodes(executableNodes)
expect(flattened).toHaveLength(1) // Original node count
})
it('should handle very long slot names', () => {
const subgraph = createTestSubgraph()
const longName = 'a'.repeat(1000) // 1000 character name
expect(() => {
subgraph.addInput(longName, 'number')
subgraph.addOutput(longName, 'string')
}).not.toThrow()
expect(subgraph.inputs[0].name).toBe(longName)
expect(subgraph.outputs[0].name).toBe(longName)
})
it('should handle Unicode characters in names', () => {
const subgraph = createTestSubgraph()
const unicodeName = '测试_🚀_تست_тест'
expect(() => {
subgraph.addInput(unicodeName, 'number')
subgraph.addOutput(unicodeName, 'string')
}).not.toThrow()
expect(subgraph.inputs[0].name).toBe(unicodeName)
expect(subgraph.outputs[0].name).toBe(unicodeName)
})
})
describe('SubgraphEdgeCases - Type Validation', () => {
describe.skip('SubgraphEdgeCases - Type Validation', () => {
it('should allow connecting mismatched types (no validation currently)', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
@@ -268,6 +289,18 @@ describe('SubgraphEdgeCases - Type Validation', () => {
}).not.toThrow()
})
it('should handle invalid type strings', () => {
const subgraph = createTestSubgraph()
// These should not crash (current behavior)
expect(() => {
subgraph.addInput('test1', 'invalid_type')
subgraph.addInput('test2', '')
subgraph.addInput('test3', '123')
subgraph.addInput('test4', 'special!@#$%')
}).not.toThrow()
})
it('should handle complex type strings', () => {
const subgraph = createTestSubgraph()
@@ -284,7 +317,7 @@ describe('SubgraphEdgeCases - Type Validation', () => {
})
})
describe('SubgraphEdgeCases - Performance and Scale', () => {
describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
it('should handle large numbers of nodes in subgraph', () => {
// Create subgraph with many nodes (keep reasonable for test speed)
const subgraph = createTestSubgraph({ nodeCount: 50 })
@@ -315,4 +348,35 @@ describe('SubgraphEdgeCases - Performance and Scale', () => {
expect(subgraph.inputs).toHaveLength(0)
expect(subgraph.outputs).toHaveLength(0)
})
it('should handle concurrent modifications safely', () => {
// This test ensures the system doesn't crash under concurrent access
// Note: JavaScript is single-threaded, so this tests rapid sequential access
const subgraph = createTestSubgraph({ nodeCount: 5 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Simulate concurrent operations
const operations: Array<() => void> = []
for (let i = 0; i < 20; i++) {
operations.push(
() => {
const executableNodes = new Map()
subgraphNode.getInnerNodes(executableNodes)
},
() => {
subgraph.addInput(`concurrent_${i}`, 'number')
},
() => {
if (subgraph.inputs.length > 0) {
subgraph.removeInput(subgraph.inputs[0])
}
}
)
}
// Execute all operations - should not crash
expect(() => {
for (const op of operations) op()
}).not.toThrow()
})
})

View File

@@ -1,11 +1,10 @@
// TODO: Fix these tests after migration
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import { verifyEventSequence } from './__fixtures__/subgraphHelpers'
describe('SubgraphEvents - Event Payload Verification', () => {
describe.skip('SubgraphEvents - Event Payload Verification', () => {
subgraphTest(
'dispatches input-added with correct payload',
({ eventCapture }) => {
@@ -200,9 +199,9 @@ describe('SubgraphEvents - Event Payload Verification', () => {
)
})
describe('SubgraphEvents - Event Handler Isolation', () => {
describe.skip('SubgraphEvents - Event Handler Isolation', () => {
subgraphTest(
'surfaces handler errors to caller and stops propagation',
'continues dispatching if handler throws',
({ emptySubgraph }) => {
const handler1 = vi.fn(() => {
throw new Error('Handler 1 error')
@@ -214,15 +213,15 @@ describe('SubgraphEvents - Event Handler Isolation', () => {
emptySubgraph.events.addEventListener('input-added', handler2)
emptySubgraph.events.addEventListener('input-added', handler3)
// Current runtime behavior: listener exceptions bubble out of dispatch.
// The operation itself should not throw (error is isolated)
expect(() => {
emptySubgraph.addInput('test', 'number')
}).toThrowError('Handler 1 error')
}).not.toThrow()
// Once the first listener throws, later listeners are not invoked.
// Verify all handlers were called despite the first one throwing
expect(handler1).toHaveBeenCalled()
expect(handler2).not.toHaveBeenCalled()
expect(handler3).not.toHaveBeenCalled()
expect(handler2).toHaveBeenCalled()
expect(handler3).toHaveBeenCalled()
// Verify the throwing handler actually received the event
expect(handler1).toHaveBeenCalledWith(
@@ -230,6 +229,24 @@ describe('SubgraphEvents - Event Handler Isolation', () => {
type: 'input-added'
})
)
// Verify other handlers received correct event data
expect(handler2).toHaveBeenCalledWith(
expect.objectContaining({
type: 'input-added',
detail: expect.objectContaining({
input: expect.objectContaining({
name: 'test',
type: 'number'
})
})
})
)
expect(handler3).toHaveBeenCalledWith(
expect.objectContaining({
type: 'input-added'
})
)
}
)
@@ -288,7 +305,7 @@ describe('SubgraphEvents - Event Handler Isolation', () => {
)
})
describe('SubgraphEvents - Event Sequence Testing', () => {
describe.skip('SubgraphEvents - Event Sequence Testing', () => {
subgraphTest(
'maintains correct event sequence for inputs',
({ eventCapture }) => {
@@ -334,7 +351,7 @@ describe('SubgraphEvents - Event Sequence Testing', () => {
}
)
subgraphTest('fires all listeners synchronously', ({ eventCapture }) => {
subgraphTest('handles concurrent event handling', ({ eventCapture }) => {
const { subgraph, capture } = eventCapture
const handler1 = vi.fn(() => {
@@ -376,7 +393,7 @@ describe('SubgraphEvents - Event Sequence Testing', () => {
)
})
describe('SubgraphEvents - Event Cancellation', () => {
describe.skip('SubgraphEvents - Event Cancellation', () => {
subgraphTest(
'supports preventDefault() for cancellable events',
({ emptySubgraph }) => {
@@ -426,78 +443,71 @@ describe('SubgraphEvents - Event Cancellation', () => {
expect(emptySubgraph.inputs).toHaveLength(0)
expect(allowHandler).toHaveBeenCalled()
})
})
subgraphTest('veto preserves input connections', ({ emptySubgraph }) => {
const input = emptySubgraph.addInput('test', 'number')
const node = new LGraphNode('Interior')
node.addInput('in', 'number')
emptySubgraph.add(node)
input.connect(node.inputs[0], node)
expect(input.linkIds).not.toHaveLength(0)
emptySubgraph.events.addEventListener('removing-input', (event) => {
event.preventDefault()
})
emptySubgraph.removeInput(input)
expect(emptySubgraph.inputs).toContain(input)
expect(input.linkIds).not.toHaveLength(0)
})
subgraphTest('veto preserves output connections', ({ emptySubgraph }) => {
const output = emptySubgraph.addOutput('test', 'number')
const node = new LGraphNode('Interior')
node.addOutput('out', 'number')
emptySubgraph.add(node)
output.connect(node.outputs[0], node)
expect(output.linkIds).not.toHaveLength(0)
emptySubgraph.events.addEventListener('removing-output', (event) => {
event.preventDefault()
})
emptySubgraph.removeOutput(output)
expect(emptySubgraph.outputs).toContain(output)
expect(output.linkIds).not.toHaveLength(0)
})
describe.skip('SubgraphEvents - Event Detail Structure Validation', () => {
subgraphTest(
'rename input cancellation does not prevent rename',
({ emptySubgraph }) => {
const input = emptySubgraph.addInput('original', 'number')
'validates all event detail structures match TypeScript types',
({ eventCapture }) => {
const { subgraph, capture } = eventCapture
const preventHandler = vi.fn((event: Event) => {
event.preventDefault()
const input = subgraph.addInput('test_input', 'number')
subgraph.renameInput(input, 'renamed_input')
subgraph.removeInput(input)
const output = subgraph.addOutput('test_output', 'string')
subgraph.renameOutput(output, 'renamed_output')
subgraph.removeOutput(output)
const addingInputEvent = capture.getEventsByType('adding-input')[0]
expect(addingInputEvent.detail).toEqual({
name: expect.any(String),
type: expect.any(String)
})
emptySubgraph.events.addEventListener('renaming-input', preventHandler)
emptySubgraph.renameInput(input, 'new_name')
expect(input.label).toBe('new_name')
expect(preventHandler).toHaveBeenCalled()
}
)
subgraphTest(
'rename output cancellation does not prevent rename',
({ emptySubgraph }) => {
const output = emptySubgraph.addOutput('original', 'number')
const preventHandler = vi.fn((event: Event) => {
event.preventDefault()
const inputAddedEvent = capture.getEventsByType('input-added')[0]
expect(inputAddedEvent.detail).toEqual({
input: expect.any(Object)
})
emptySubgraph.events.addEventListener('renaming-output', preventHandler)
emptySubgraph.renameOutput(output, 'new_name')
const renamingInputEvent = capture.getEventsByType('renaming-input')[0]
expect(renamingInputEvent.detail).toEqual({
input: expect.any(Object),
index: expect.any(Number),
oldName: expect.any(String),
newName: expect.any(String)
})
expect(output.label).toBe('new_name')
expect(preventHandler).toHaveBeenCalled()
const removingInputEvent = capture.getEventsByType('removing-input')[0]
expect(removingInputEvent.detail).toEqual({
input: expect.any(Object),
index: expect.any(Number)
})
const addingOutputEvent = capture.getEventsByType('adding-output')[0]
expect(addingOutputEvent.detail).toEqual({
name: expect.any(String),
type: expect.any(String)
})
const outputAddedEvent = capture.getEventsByType('output-added')[0]
expect(outputAddedEvent.detail).toEqual({
output: expect.any(Object)
})
const renamingOutputEvent = capture.getEventsByType('renaming-output')[0]
expect(renamingOutputEvent.detail).toEqual({
output: expect.any(Object),
index: expect.any(Number),
oldName: expect.any(String),
newName: expect.any(String)
})
const removingOutputEvent = capture.getEventsByType('removing-output')[0]
expect(removingOutputEvent.detail).toEqual({
output: expect.any(Object),
index: expect.any(Number)
})
}
)
})

View File

@@ -1,3 +1,4 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
@@ -8,23 +7,17 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
type InputWithWidget = {
_widget?: IWidget | { type: string; value: unknown; name: string }
_connection?: { id: number; type: string }
_listenerController?: AbortController
}
describe('SubgraphNode Memory Management', () => {
describe('Event Listener Cleanup', () => {
describe.skip('SubgraphNode Memory Management', () => {
describe.skip('Event Listener Cleanup', () => {
it('should register event listeners on construction', () => {
const subgraph = createTestSubgraph()
@@ -100,8 +93,8 @@ describe('SubgraphNode Memory Management', () => {
})
})
describe('Widget Promotion Memory Management', () => {
it('should not mutate manually injected widget references', () => {
describe.skip('Widget Promotion Memory Management', () => {
it('should clean up promoted widget references', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'testInput', type: 'number' }]
})
@@ -134,8 +127,8 @@ describe('SubgraphNode Memory Management', () => {
subgraphNode.removeWidget(mockWidget)
// removeWidget only affects managed promoted widgets, not manually injected entries.
expect(subgraphNode.widgets).toContain(mockWidget)
// Widget should be removed from array
expect(subgraphNode.widgets).not.toContain(mockWidget)
})
it('should not leak widgets during reconfiguration', () => {
@@ -169,7 +162,7 @@ describe('SubgraphNode Memory Management', () => {
})
})
describe('SubgraphMemory - Event Listener Management', () => {
describe.skip('SubgraphMemory - Event Listener Management', () => {
subgraphTest(
'event handlers still work after node creation',
({ emptySubgraph }) => {
@@ -261,18 +254,35 @@ describe('SubgraphMemory - Event Listener Management', () => {
)
})
describe('SubgraphMemory - Reference Management', () => {
it('maintains proper parent-child references while attached', () => {
describe.skip('SubgraphMemory - Reference Management', () => {
it('properly manages subgraph references in root graph', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph
})
const subgraphId = subgraph.id
// Add subgraph to root graph registry
rootGraph.subgraphs.set(subgraphId, subgraph)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(true)
expect(rootGraph.subgraphs.get(subgraphId)).toBe(subgraph)
// Remove subgraph from registry
rootGraph.subgraphs.delete(subgraphId)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(false)
})
it('maintains proper parent-child references', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph({ nodeCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Add to graph
rootGraph.add(subgraphNode)
expect(subgraphNode.graph).toBe(rootGraph)
expect(rootGraph.nodes).toContain(subgraphNode)
// Remove from graph
rootGraph.remove(subgraphNode)
expect(rootGraph.nodes).not.toContain(subgraphNode)
})
it('prevents circular reference creation', () => {
@@ -288,7 +298,65 @@ describe('SubgraphMemory - Reference Management', () => {
})
})
describe('SubgraphMemory - Widget Reference Management', () => {
describe.skip('SubgraphMemory - Widget Reference Management', () => {
subgraphTest(
'properly sets and clears widget references',
({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const input = subgraphNode.inputs[0]
// Mock widget for testing
const mockWidget = {
type: 'number',
value: 42,
name: 'test_widget'
}
// Set widget reference
if (input && '_widget' in input) {
;(input as InputWithWidget)._widget = mockWidget
expect((input as InputWithWidget)._widget).toBe(mockWidget)
}
// Clear widget reference
if (input && '_widget' in input) {
;(input as InputWithWidget)._widget = undefined
expect((input as InputWithWidget)._widget).toBeUndefined()
}
}
)
subgraphTest('maintains widget count consistency', ({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const initialWidgetCount = subgraphNode.widgets?.length || 0
const widget1 = {
type: 'number',
value: 1,
name: 'widget1',
options: {},
y: 0
} as Partial<IWidget> as IWidget
const widget2 = {
type: 'string',
value: 'test',
name: 'widget2',
options: {},
y: 0
} as Partial<IWidget> as IWidget
if (subgraphNode.widgets) {
subgraphNode.widgets.push(widget1, widget2)
expect(subgraphNode.widgets.length).toBe(initialWidgetCount + 2)
}
if (subgraphNode.widgets) {
subgraphNode.widgets.length = initialWidgetCount
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)
}
})
subgraphTest(
'cleans up references during node removal',
({ simpleSubgraph }) => {
@@ -331,7 +399,7 @@ describe('SubgraphMemory - Widget Reference Management', () => {
)
})
describe('SubgraphMemory - Performance and Scale', () => {
describe.skip('SubgraphMemory - Performance and Scale', () => {
subgraphTest(
'handles multiple subgraphs in same graph',
({ subgraphWithNode }) => {
@@ -382,4 +450,29 @@ describe('SubgraphMemory - Performance and Scale', () => {
expect(rootGraph.nodes.length).toBe(0)
})
it('maintains consistent behavior across multiple cycles', () => {
const subgraph = createTestSubgraph()
const rootGraph = new LGraph()
for (let cycle = 0; cycle < 10; cycle++) {
const instances = []
// Create instances
for (let i = 0; i < 10; i++) {
const instance = createTestSubgraphNode(subgraph)
rootGraph.add(instance)
instances.push(instance)
}
expect(rootGraph.nodes.length).toBe(10)
// Remove instances
for (const instance of instances) {
rootGraph.remove(instance)
}
expect(rootGraph.nodes.length).toBe(0)
}
})
})

View File

@@ -1,29 +1,25 @@
// TODO: Fix these tests after migration
/**
* SubgraphNode Tests
*
* Tests for SubgraphNode instances including construction,
* IO synchronization, and edge cases.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph, Subgraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphNode Construction', () => {
describe.skip('SubgraphNode Construction', () => {
it('should create a SubgraphNode from a subgraph definition', () => {
const subgraph = createTestSubgraph({
name: 'Test Definition',
@@ -106,7 +102,7 @@ describe('SubgraphNode Construction', () => {
)
})
describe('SubgraphNode Synchronization', () => {
describe.skip('SubgraphNode Synchronization', () => {
it('should sync input addition', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -198,7 +194,15 @@ describe('SubgraphNode Synchronization', () => {
})
})
describe('SubgraphNode Lifecycle', () => {
describe.skip('SubgraphNode Lifecycle', () => {
it('should initialize with empty widgets array', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.widgets).toBeDefined()
expect(subgraphNode.widgets).toHaveLength(0)
})
it('should handle reconfiguration', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input1', type: 'number' }],
@@ -250,7 +254,15 @@ describe('SubgraphNode Lifecycle', () => {
})
})
describe('SubgraphNode Basic Functionality', () => {
describe.skip('SubgraphNode Basic Functionality', () => {
it('should identify as subgraph node', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.isSubgraphNode()).toBe(true)
expect(subgraphNode.isVirtualNode).toBe(true)
})
it('should inherit input types correctly', () => {
const subgraph = createTestSubgraph({
inputs: [
@@ -282,7 +294,7 @@ describe('SubgraphNode Basic Functionality', () => {
})
})
describe('SubgraphNode Execution', () => {
describe.skip('SubgraphNode Execution', () => {
it('should flatten to ExecutableNodeDTOs', () => {
const subgraph = createTestSubgraph({ nodeCount: 3 })
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -290,39 +302,32 @@ describe('SubgraphNode Execution', () => {
const executableNodes = new Map()
const flattened = subgraphNode.getInnerNodes(executableNodes)
const nodeId = subgraphNode.id
const idPattern = new RegExp(`^${nodeId}:\\d+$`)
expect(flattened).toHaveLength(3)
expect(flattened[0].id).toMatch(idPattern)
expect(flattened[1].id).toMatch(idPattern)
expect(flattened[2].id).toMatch(idPattern)
expect(flattened[0].id).toMatch(/^1:\d+$/) // Should have path-based ID like "1:1"
expect(flattened[1].id).toMatch(/^1:\d+$/)
expect(flattened[2].id).toMatch(/^1:\d+$/)
})
it('should handle nested subgraph execution', () => {
const rootGraph = new LGraph()
it.skip('should handle nested subgraph execution', () => {
// FIXME: Complex nested structure requires proper parent graph setup
// Skip for now - similar issue to ExecutableNodeDTO nested test
// Will implement proper nested execution test in edge cases file
const childSubgraph = createTestSubgraph({
rootGraph,
name: 'Child',
nodeCount: 1
})
const parentSubgraph = createTestSubgraph({
rootGraph,
name: 'Parent',
nodeCount: 1
})
const childSubgraphNode = createTestSubgraphNode(childSubgraph, {
id: 42,
parentGraph: parentSubgraph
})
const childSubgraphNode = createTestSubgraphNode(childSubgraph, { id: 42 })
parentSubgraph.add(childSubgraphNode)
const parentSubgraphNode = createTestSubgraphNode(parentSubgraph, {
id: 10,
parentGraph: rootGraph
id: 10
})
rootGraph.add(parentSubgraphNode)
const executableNodes = new Map()
const flattened = parentSubgraphNode.getInnerNodes(executableNodes)
@@ -357,16 +362,44 @@ describe('SubgraphNode Execution', () => {
})
it('should prevent infinite recursion', () => {
// Circular self-references currently recurse in traversal; this test documents
// that execution flattening throws instead of silently succeeding.
// Cycle detection properly prevents infinite recursion when a subgraph contains itself
const subgraph = createTestSubgraph({ nodeCount: 1 })
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: subgraph
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Add subgraph node to its own subgraph (circular reference)
// add() itself throws due to recursive forEachNode traversal
expect(() => subgraph.add(subgraphNode)).toThrow()
subgraph.add(subgraphNode)
const executableNodes = new Map()
expect(() => {
subgraphNode.getInnerNodes(executableNodes)
}).toThrow(
/Circular reference detected.*infinite loop in the subgraph hierarchy/i
)
})
it('should handle nested subgraph execution', () => {
// This test verifies that subgraph nodes can be properly executed
// when they contain other nodes and produce correct output
const subgraph = createTestSubgraph({
name: 'Nested Execution Test',
nodeCount: 3
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Verify that we can get executable DTOs for all nested nodes
const executableNodes = new Map()
const flattened = subgraphNode.getInnerNodes(executableNodes)
expect(flattened).toHaveLength(3)
// Each DTO should have proper execution context
for (const dto of flattened) {
expect(dto).toHaveProperty('id')
expect(dto).toHaveProperty('graph')
expect(dto).toHaveProperty('inputs')
expect(dto.id).toMatch(/^\d+:\d+$/) // Path-based ID format
}
})
it('should resolve cross-boundary links', () => {
@@ -394,7 +427,7 @@ describe('SubgraphNode Execution', () => {
})
})
describe('SubgraphNode Edge Cases', () => {
describe.skip('SubgraphNode Edge Cases', () => {
it('should handle deep nesting', () => {
// Create a simpler deep nesting test that works with current implementation
const subgraph = createTestSubgraph({
@@ -418,9 +451,18 @@ describe('SubgraphNode Edge Cases', () => {
expect(dto.id).toMatch(/^\d+:\d+$/)
}
})
it('should validate against MAX_NESTED_SUBGRAPHS', () => {
// Test that the MAX_NESTED_SUBGRAPHS constant exists
// Note: Currently not enforced in the implementation
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
// This test documents the current behavior - limit is not enforced
// TODO: Implement actual limit enforcement when business requirements clarify
})
})
describe('SubgraphNode Integration', () => {
describe.skip('SubgraphNode Integration', () => {
it('should be addable to a parent graph', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -452,13 +494,39 @@ describe('SubgraphNode Integration', () => {
expect(parentGraph.nodes).toContain(subgraphNode)
parentGraph.remove(subgraphNode)
expect(parentGraph.nodes.find((node) => node.id === subgraphNode.id)).toBe(
undefined
)
expect(parentGraph.nodes).not.toContain(subgraphNode)
})
})
describe('SubgraphNode Cleanup', () => {
describe.skip('Foundation Test Utilities', () => {
it('should create test SubgraphNodes with custom options', () => {
const subgraph = createTestSubgraph()
const customPos: [number, number] = [500, 300]
const customSize: [number, number] = [250, 120]
const subgraphNode = createTestSubgraphNode(subgraph, {
pos: customPos,
size: customSize
})
expect(Array.from(subgraphNode.pos)).toEqual(customPos)
expect(Array.from(subgraphNode.size)).toEqual(customSize)
})
subgraphTest(
'fixtures should provide properly configured SubgraphNode',
({ subgraphWithNode }) => {
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
expect(subgraph).toBeDefined()
expect(subgraphNode).toBeDefined()
expect(parentGraph).toBeDefined()
expect(parentGraph.nodes).toContain(subgraphNode)
}
)
})
describe.skip('SubgraphNode Cleanup', () => {
it('should clean up event listeners when removed', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
@@ -476,8 +544,10 @@ describe('SubgraphNode Cleanup', () => {
// Remove node2
rootGraph.remove(node2)
// Now trigger a real event through subgraph API - only node1 should respond
subgraph.addInput('test', 'number')
// Now trigger an event - only node1 should respond
subgraph.events.dispatch('input-added', {
input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput
})
// Only node1 should have added an input
expect(node1.inputs.length).toBe(1) // node1 responds
@@ -501,8 +571,10 @@ describe('SubgraphNode Cleanup', () => {
expect(node.inputs.length).toBe(0)
}
// Trigger an event - no removed nodes should respond
subgraph.addInput('test', 'number')
// Trigger an event - no nodes should respond
subgraph.events.dispatch('input-added', {
input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput
})
// Without cleanup: all 3 removed nodes would have added an input
// With cleanup: no nodes should have added an input
@@ -626,55 +698,6 @@ describe('SubgraphNode duplicate input pruning (#9977)', () => {
})
})
describe('Nested SubgraphNode duplicate input prevention', () => {
it('should not duplicate inputs when the referenced subgraph is reconfigured', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
// Simulate what happens during nested subgraph configure:
// B.configure() calls _configureSubgraph(), which recreates SubgraphInput
// objects and dispatches 'input-added' events with new references.
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized)
// The SubgraphNode's event listener should recognize existing inputs
// by ID and NOT add duplicates.
expect(node.inputs).toHaveLength(2)
expect(node.inputs.every((i) => i._subgraphSlot)).toBe(true)
})
it('should not accumulate inputs across multiple reconfigure cycles', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'x', type: 'IMAGE' },
{ name: 'y', type: 'VAE' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
for (let i = 0; i < 5; i++) {
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized)
}
expect(node.inputs).toHaveLength(2)
expect(node.inputs.map((i) => i.name)).toEqual(['x', 'y'])
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -1,23 +1,22 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
interface MockPointerEvent {
canvasX: number
canvasY: number
}
describe('SubgraphNode Title Button', () => {
describe('Constructor', () => {
describe.skip('SubgraphNode Title Button', () => {
describe.skip('Constructor', () => {
it('should automatically add enter_subgraph button', () => {
const subgraph = createTestSubgraph({
name: 'Test Subgraph',
@@ -31,6 +30,10 @@ describe('SubgraphNode Title Button', () => {
const button = subgraphNode.title_buttons[0]
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBe('enter_subgraph')
expect(button.text).toBe('\uE93B') // pi-window-maximize
expect(button.xOffset).toBe(-10)
expect(button.yOffset).toBe(0)
expect(button.fontSize).toBe(16)
})
it('should preserve enter_subgraph button when adding more buttons', () => {
@@ -49,7 +52,7 @@ describe('SubgraphNode Title Button', () => {
})
})
describe('onTitleButtonClick', () => {
describe.skip('onTitleButtonClick', () => {
it('should open subgraph when enter_subgraph button is clicked', () => {
const subgraph = createTestSubgraph({
name: 'Test Subgraph'
@@ -65,7 +68,7 @@ describe('SubgraphNode Title Button', () => {
subgraphNode.onTitleButtonClick(enterButton, canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
expect(canvas.dispatch).not.toHaveBeenCalled() // Should not call parent implementation
})
@@ -96,8 +99,8 @@ describe('SubgraphNode Title Button', () => {
})
})
describe('Integration with node click handling', () => {
it('should expose button hit testing that canvas uses for click routing', () => {
describe.skip('Integration with node click handling', () => {
it('should handle clicks on enter_subgraph button', () => {
const subgraph = createTestSubgraph({
name: 'Nested Subgraph',
nodeCount: 3
@@ -127,48 +130,66 @@ describe('SubgraphNode Title Button', () => {
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
// Simulate click on the enter button
const event: MockPointerEvent = {
canvasX: 275, // Near right edge where button should be
canvasY: 80 // In title area
}
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
275 - subgraphNode.pos[0], // 275 - 100 = 175
80 - subgraphNode.pos[1] // 80 - 100 = -20
]
expect(
enterButton.isPointInside(
clickPosRelativeToNode[0],
clickPosRelativeToNode[1]
)
).toBe(true)
// @ts-expect-error onMouseDown possibly undefined
const handled = subgraphNode.onMouseDown(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
subgraphNode.onTitleButtonClick(enterButton, canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
expect(handled).toBe(true)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
})
it('does not report hits outside the enter button area', () => {
it('should not interfere with normal node operations', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
subgraphNode.size = [200, 100]
const enterButton = subgraphNode.title_buttons[0]
enterButton.getWidth = vi.fn().mockReturnValue(25)
enterButton.height = 20
enterButton._last_area[0] = 170
enterButton._last_area[1] = -30
enterButton._last_area[2] = 25
enterButton._last_area[3] = 20
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 })
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
const bodyClickRelativeToNode: [number, number] = [100, 50]
// Click in the body of the node, not on button
const event: MockPointerEvent = {
canvasX: 200, // Middle of node
canvasY: 150 // Body area
}
expect(
enterButton.isPointInside(
bodyClickRelativeToNode[0],
bodyClickRelativeToNode[1]
)
).toBe(false)
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
200 - subgraphNode.pos[0], // 200 - 100 = 100
150 - subgraphNode.pos[1] // 150 - 100 = 50
]
const handled = subgraphNode.onMouseDown!(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
expect(handled).toBe(false)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
})
it('keeps enter button metadata but canvas is responsible for collapsed guard', () => {
it('should not process button clicks when node is collapsed', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
@@ -185,18 +206,52 @@ describe('SubgraphNode Title Button', () => {
enterButton._last_area[2] = 25
enterButton._last_area[3] = 20
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 })
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
// Try to click on where the button would be
const event: MockPointerEvent = {
canvasX: 275,
canvasY: 80
}
const clickPosRelativeToNode: [number, number] = [
275 - subgraphNode.pos[0], // 175
80 - subgraphNode.pos[1] // -20
]
expect(
enterButton.isPointInside(
clickPosRelativeToNode[0],
clickPosRelativeToNode[1]
)
).toBe(true)
expect(subgraphNode.flags.collapsed).toBe(true)
const handled = subgraphNode.onMouseDown!(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
// Should not handle the click when collapsed
expect(handled).toBe(false)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
})
})
describe.skip('Visual properties', () => {
it('should have appropriate visual properties for enter button', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const enterButton = subgraphNode.title_buttons[0]
// Check visual properties
expect(enterButton.text).toBe('\uE93B') // pi-window-maximize
expect(enterButton.fontSize).toBe(16) // Icon size
expect(enterButton.xOffset).toBe(-10) // Positioned from right edge
expect(enterButton.yOffset).toBe(0) // Centered vertically
// Should be visible by default
expect(enterButton.visible).toBe(true)
})
})
})

View File

@@ -38,10 +38,6 @@ import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetVie
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -616,14 +612,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const subgraphInput = e.detail.input
const { name, type } = subgraphInput
const existingInput = this.inputs.find(
(input) =>
input._subgraphSlot === subgraphInput ||
(input._subgraphSlot && input._subgraphSlot.id === subgraphInput.id)
(input) => input._subgraphSlot === subgraphInput
)
if (existingInput) {
// Rebind to the new SubgraphInput object and re-register listeners
// (configure recreates SubgraphInput objects with the same id)
this._addSubgraphInputListeners(subgraphInput, existingInput)
const linkId = subgraphInput.linkIds[0]
if (linkId === undefined) return
@@ -996,25 +987,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
this._syncPromotions()
for (const node of this.subgraph.nodes) {
if (!supportsVirtualCanvasImagePreview(node)) continue
if (
store.isPromoted(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
)
continue
store.promote(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
}
}
private _resolveInputWidget(

View File

@@ -1,58 +1,20 @@
// TODO: Fix these tests after migration
/**
* SubgraphSerialization Tests
*
* Tests for saving, loading, and version compatibility of subgraphs.
* This covers serialization, deserialization, data integrity, and migration scenarios.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import {
LGraph,
LGraphNode,
LiteGraph,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
function createRegisteredNode(
graph: LGraph | Subgraph,
inputs: ISlotType[] = [],
outputs: ISlotType[] = [],
title?: string
) {
const type = JSON.stringify({ inputs, outputs })
if (!LiteGraph.registered_node_types[type]) {
class testnode extends LGraphNode {
constructor(title: string) {
super(title)
let i = 0
for (const input of inputs) this.addInput('input_' + i++, input)
let o = 0
for (const output of outputs) this.addOutput('output_' + o++, output)
}
}
LiteGraph.registered_node_types[type] = testnode
}
const node = LiteGraph.createNode(type, title)
if (!node) throw new Error('Failed to create node')
graph.add(node)
return node
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphSerialization - Basic Serialization', () => {
describe.skip('SubgraphSerialization - Basic Serialization', () => {
it('should save and load simple subgraphs', () => {
const original = createTestSubgraph({
name: 'Simple Test',
@@ -160,7 +122,7 @@ describe('SubgraphSerialization - Basic Serialization', () => {
})
})
describe('SubgraphSerialization - Complex Serialization', () => {
describe.skip('SubgraphSerialization - Complex Serialization', () => {
it('should serialize nested subgraphs with multiple levels', () => {
// Create a nested structure
const childSubgraph = createTestSubgraph({
@@ -227,28 +189,35 @@ describe('SubgraphSerialization - Complex Serialization', () => {
}
})
it('should preserve I/O even when nodes are not restored', () => {
const subgraph = createTestSubgraph({
nodeCount: 2,
inputs: [{ name: 'data_in', type: 'number' }],
outputs: [{ name: 'data_out', type: 'string' }]
})
it('should preserve custom node data', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
// Add custom properties to nodes (if supported)
const nodes = subgraph.nodes
if (nodes.length > 0) {
const firstNode = nodes[0]
if (firstNode.properties) {
firstNode.properties.customValue = 42
firstNode.properties.customString = 'test'
}
}
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
// Nodes are not restored without registered types
expect(restored.nodes).toHaveLength(0)
// Test nodes may not be restored if they don't have registered types
// This is expected behavior
// I/O is still preserved
expect(restored.inputs).toHaveLength(1)
expect(restored.inputs[0].name).toBe('data_in')
expect(restored.outputs).toHaveLength(1)
expect(restored.outputs[0].name).toBe('data_out')
// Custom properties preservation depends on node implementation
// This test documents the expected behavior
if (restored.nodes.length > 0 && restored.nodes[0].properties) {
// Properties should be preserved if the node supports them
expect(restored.nodes[0].properties).toBeDefined()
}
})
})
describe('SubgraphSerialization - Version Compatibility', () => {
describe.skip('SubgraphSerialization - Version Compatibility', () => {
it('should handle version field in exports', () => {
const subgraph = createTestSubgraph({ nodeCount: 1 })
const exported = subgraph.asSerialisable()
@@ -354,7 +323,7 @@ describe('SubgraphSerialization - Version Compatibility', () => {
})
})
describe('SubgraphSerialization - Data Integrity', () => {
describe.skip('SubgraphSerialization - Data Integrity', () => {
it('should pass round-trip testing (save → load → save → compare)', () => {
const original = createTestSubgraph({
name: 'Round Trip Test',
@@ -431,48 +400,36 @@ describe('SubgraphSerialization - Data Integrity', () => {
expect(instance.outputs.length).toBe(1)
})
it('should not restore nodes without registered types', () => {
it('should preserve node positions and properties', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
// Nodes exist before serialization
expect(subgraph.nodes).toHaveLength(2)
// Modify node positions if possible
if (subgraph.nodes.length > 0) {
const node = subgraph.nodes[0]
if ('pos' in node) {
node.pos = [100, 200]
}
if ('size' in node) {
node.size = [150, 80]
}
}
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
// Nodes are not restored without registered types
expect(restored.nodes).toHaveLength(0)
})
// Test nodes may not be restored if they don't have registered types
// This is expected behavior
it('should preserve interior link structure through serialization', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
// Position/size preservation depends on node implementation
// This test documents the expected behavior
if (restored.nodes.length > 0) {
const restoredNode = restored.nodes[0]
expect(restoredNode).toBeDefined()
const nodeA = createRegisteredNode(subgraph, [], ['number'], 'A')
const nodeB = createRegisteredNode(subgraph, ['number'], ['string'], 'B')
const nodeC = createRegisteredNode(subgraph, ['string'], [], 'C')
nodeA.connect(0, nodeB, 0)
nodeB.connect(0, nodeC, 0)
expect(subgraph.nodes).toHaveLength(3)
expect(subgraph.links.size).toBe(2)
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
restored.configure(exported)
expect(restored.nodes).toHaveLength(3)
expect(restored.links.size).toBe(2)
for (const [, link] of restored.links) {
const originNode = restored.getNodeById(link.origin_id)
const targetNode = restored.getNodeById(link.target_id)
expect(originNode).toBeDefined()
expect(targetNode).toBeDefined()
expect(link.origin_slot).toBeGreaterThanOrEqual(0)
expect(link.target_slot).toBeGreaterThanOrEqual(0)
expect(originNode!.outputs[link.origin_slot]).toBeDefined()
expect(targetNode!.inputs[link.target_slot]).toBeDefined()
// Properties should be preserved if supported
if ('pos' in restoredNode && restoredNode.pos) {
expect(Array.isArray(restoredNode.pos)).toBe(true)
}
}
})
})

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import {
SUBGRAPH_INPUT_ID,
@@ -18,17 +17,11 @@ import type {
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('Subgraph slot connections', () => {
describe('SubgraphInput connections', () => {
describe.skip('Subgraph slot connections', () => {
describe.skip('SubgraphInput connections', () => {
it('should connect to compatible regular input slots', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'test_input', type: 'number' }]
@@ -91,7 +84,7 @@ describe('Subgraph slot connections', () => {
})
})
describe('SubgraphOutput connections', () => {
describe.skip('SubgraphOutput connections', () => {
it('should connect from compatible regular output slots', () => {
const subgraph = createTestSubgraph()
const node = new LGraphNode('TestNode')
@@ -123,7 +116,7 @@ describe('Subgraph slot connections', () => {
})
})
describe('LinkConnector dragging behavior', () => {
describe.skip('LinkConnector dragging behavior', () => {
it('should drag existing link when dragging from input slot connected to subgraph input node', () => {
// Create a subgraph with one input
const subgraph = createTestSubgraph({
@@ -175,7 +168,7 @@ describe('Subgraph slot connections', () => {
})
})
describe('Type compatibility', () => {
describe.skip('Type compatibility', () => {
it('should respect type compatibility for SubgraphInput connections', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
@@ -230,7 +223,7 @@ describe('Subgraph slot connections', () => {
})
})
describe('Type guards', () => {
describe.skip('Type guards', () => {
it('should correctly identify SubgraphInput', () => {
const subgraph = createTestSubgraph()
const subgraphInput = subgraph.addInput('value', 'number')
@@ -258,7 +251,7 @@ describe('Subgraph slot connections', () => {
})
})
describe('Nested subgraphs', () => {
describe.skip('Nested subgraphs', () => {
it('should handle dragging from SubgraphInput in nested subgraphs', () => {
const parentSubgraph = createTestSubgraph({
inputs: [{ name: 'parent_input', type: 'number' }],

View File

@@ -1,14 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
// TODO: Fix these tests after migration
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
interface MockColorContext {
defaultInputColor: string
@@ -17,15 +13,12 @@ interface MockColorContext {
getDisconnectedColor: ReturnType<typeof vi.fn>
}
describe('SubgraphSlot visual feedback', () => {
describe.skip('SubgraphSlot visual feedback', () => {
let mockCtx: CanvasRenderingContext2D
let mockColorContext: MockColorContext
let globalAlphaValues: number[]
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
// Clear the array before each test
globalAlphaValues = []

View File

@@ -1,6 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import type {
ISlotType,
@@ -12,8 +11,7 @@ import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createEventCapture,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
// Helper to create a node with a widget
@@ -55,13 +53,8 @@ function setupPromotedWidget(
return createTestSubgraphNode(subgraph)
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphWidgetPromotion', () => {
describe('Widget Promotion Functionality', () => {
describe.skip('SubgraphWidgetPromotion', () => {
describe.skip('Widget Promotion Functionality', () => {
it('should promote widgets when connecting node to subgraph input', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
@@ -147,10 +140,7 @@ describe('SubgraphWidgetPromotion', () => {
eventCapture.cleanup()
})
// BUG: removeWidgetByName calls demote but widgets getter rebuilds from
// promotionStore which still has the entry.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/10174
it.skip('should fire widget-demoted event when removing promoted widget', () => {
it('should fire widget-demoted event when removing promoted widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'number' }]
})
@@ -294,7 +284,7 @@ describe('SubgraphWidgetPromotion', () => {
})
})
describe('Tooltip Promotion', () => {
describe.skip('Tooltip Promotion', () => {
it('should preserve widget tooltip when promoting', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]

Some files were not shown because too many files have changed in this diff Show More