Compare commits

..

1 Commits

Author SHA1 Message Date
Glary-Bot
a065087e2f test: add tests for virtual node filtering in API export
Add E2E and unit tests verifying that virtual nodes (Note,
MarkdownNote, Reroute, PrimitiveNode) are excluded from API format
export while preserved in standard workflow format.

Unit tests confirm graphToPrompt correctly filters isVirtualNode
from the output object. E2E tests verify the same through the
browser via getExportedWorkflow({ api: true }).

Includes a new test fixture (note_with_ksampler.json) combining
real and virtual nodes for mixed-workflow assertions.
2026-04-19 00:53:03 +00:00
166 changed files with 1622 additions and 12880 deletions

View File

@@ -1,88 +0,0 @@
name: Resolve PR from workflow_run
description: >
Resolves the PR number from a workflow_run event using pull_requests[0]
with a listPullRequestsAssociatedWithCommit fallback.
Skips closed/merged PRs and stale runs (head SHA mismatch).
inputs:
token:
description: GitHub token for API calls
required: false
default: ${{ github.token }}
outputs:
skip:
description: "'true' when no open PR was found or the run is stale"
value: ${{ steps.resolve.outputs.skip }}
number:
description: The PR number (empty when skip is true)
value: ${{ steps.resolve.outputs.number }}
base:
description: The PR base branch (empty when skip is true)
value: ${{ steps.resolve.outputs.base }}
head-sha:
description: The PR head SHA (empty when skip is true)
value: ${{ steps.resolve.outputs.head-sha }}
runs:
using: composite
steps:
- name: Resolve PR
id: resolve
uses: actions/github-script@v8
with:
github-token: ${{ inputs.token }}
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');
}
// Fork PRs: pull_requests is empty and commit SHA may not be in
// the base repo graph. Fall back to pulls.list with head filter.
if (!pr && context.payload.workflow_run.head_repository?.owner?.login) {
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
per_page: 1,
});
pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.state !== 'open') {
core.info(`PR #${pr.number} is ${livePr.state} — skipping.`);
core.setOutput('skip', 'true');
return;
}
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('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));

View File

@@ -58,6 +58,21 @@ jobs:
retention-days: 30
if-no-files-found: warn
- name: Save PR metadata
if: github.event_name == 'pull_request'
run: |
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'
uses: actions/upload-artifact@v6
with:
name: perf-meta
path: temp/perf-meta/
- name: Save perf baseline to perf-data branch
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
continue-on-error: true

View File

@@ -32,6 +32,13 @@ jobs:
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR metadata
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
- name: Upload size data
uses: actions/upload-artifact@v6
with:

View File

@@ -98,50 +98,3 @@ jobs:
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Generate HTML coverage report
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
- name: Upload HTML report artifact
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
path: coverage/html/
retention-days: 30
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download HTML report
uses: actions/download-artifact@v7
with:
name: e2e-coverage-html
path: coverage/html
- name: Upload to GitHub Pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: coverage/html
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests E2E']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,23 +30,40 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Test Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +72,7 @@ jobs:
path: reports
- name: Handle Test Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -72,6 +85,6 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests Storybook']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,23 +30,40 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Storybook Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +72,7 @@ jobs:
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -69,6 +82,6 @@ jobs:
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -18,12 +18,6 @@ on:
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
@@ -31,8 +25,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
env:
ALIAS_HOST: comfy-website-preview-pr-${{ github.event.pull_request.number }}.vercel.app
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -40,83 +32,28 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build
- name: Fetch head commit metadata
id: head-commit
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
})
const author = data.author?.login || data.commit.author?.name || ''
const message = (data.commit.message || '').split('\n', 1)[0]
core.setOutput('author', author)
core.setOutput('message', message)
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
env:
GIT_COMMIT_REF: ${{ github.event.pull_request.head.ref }}
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GIT_AUTHOR_LOGIN: ${{ steps.head-commit.outputs.author }}
GIT_COMMIT_MESSAGE: ${{ steps.head-commit.outputs.message }}
GIT_PR_ID: ${{ github.event.pull_request.number }}
GIT_REPO: ${{ github.repository }}
run: |
URL=$(vercel deploy --prebuilt \
--meta githubCommitRef="$GIT_COMMIT_REF" \
--meta githubCommitSha="$GIT_COMMIT_SHA" \
--meta githubCommitAuthorLogin="$GIT_AUTHOR_LOGIN" \
--meta githubCommitMessage="$GIT_COMMIT_MESSAGE" \
--meta githubPrId="$GIT_PR_ID" \
--meta githubRepo="$GIT_REPO")
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Alias deployment to stable PR hostname
id: alias-set
continue-on-error: true
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
run: |
vercel alias set "$DEPLOY_URL" "$ALIAS_HOST" --scope="$VERCEL_SCOPE"
- name: Add deployment URL to summary
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
- name: Publish preview outputs
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
- name: Save PR metadata
run: |
if [[ "$ALIAS_OK" == "true" ]]; then
STABLE_URL="https://$ALIAS_HOST"
else
STABLE_URL="$DEPLOY_URL"
fi
mkdir -p temp/vercel-preview
echo "$DEPLOY_URL" > temp/vercel-preview/url.txt
echo "$STABLE_URL" > temp/vercel-preview/stable-url.txt
{
echo "**Preview:** $STABLE_URL"
if [[ "$ALIAS_OK" == "true" ]]; then
echo "**This commit:** $DEPLOY_URL"
else
echo "_Stable alias update failed — URL reflects this commit only._"
fi
} >> "$GITHUB_STEP_SUMMARY"
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
- name: Upload preview metadata
uses: actions/upload-artifact@v6
@@ -134,24 +71,19 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build --prod
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod)
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary

View File

@@ -30,7 +30,42 @@ jobs:
- name: Resolve PR from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
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
if: steps.pr-meta.outputs.skip != 'true'

View File

@@ -12,10 +12,6 @@ permissions:
pull-requests: write
actions: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
@@ -35,24 +31,38 @@ jobs:
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
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;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
- name: Read preview URL
if: steps.pr-meta.outputs.skip != 'true'
id: meta
run: |
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
- name: Write report
if: steps.pr-meta.outputs.skip != 'true'
env:
DEPLOYED_AT: ${{ github.event.workflow_run.updated_at }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
STABLE_URL=$(cat temp/vercel-preview/stable-url.txt)
UNIQUE_URL=$(cat temp/vercel-preview/url.txt)
SHORT_SHA="${HEAD_SHA:0:7}"
cat > preview-report.md <<EOF
**Website Preview:** $STABLE_URL
<sub>This commit: $UNIQUE_URL</sub>
<sub>Last updated: $DEPLOYED_AT for \`$SHORT_SHA\`</sub>
EOF
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'

View File

@@ -44,7 +44,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
- `pnpm dev:electron`: Dev server with Electron API mocks
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally

View File

@@ -23,10 +23,6 @@
"destination": "https://blog.comfy.org/",
"permanent": true
},
{
"source": "/press",
"destination": "/about",
"permanent": true
}
{ "source": "/press", "destination": "/about", "permanent": true }
]
}

View File

@@ -0,0 +1,63 @@
{
"last_node_id": 3,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [400, 50],
"size": [315, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{ "name": "LATENT", "type": "LATENT", "links": [], "slot_index": 0 }
],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
},
{
"id": 2,
"type": "Note",
"pos": [50, 50],
"size": [300, 150],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["This is a reference note"],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 3,
"type": "MarkdownNote",
"pos": [50, 250],
"size": [300, 150],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["# Markdown heading"],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": { "scale": 1, "offset": [0, 0] }
},
"version": 0.4
}

View File

@@ -1,116 +0,0 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -30,6 +30,8 @@ import {
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
@@ -177,6 +179,7 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
public readonly visibleToasts: Locator
@@ -230,6 +233,7 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
@@ -495,6 +499,7 @@ export const comfyPageFixture = base.extend<{
await use(comfyPage)
await comfyPage.assetApi.clearMocks()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {

View File

@@ -1,16 +0,0 @@
import { test as base } from '@playwright/test'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
export const assetApiFixture = base.extend<{
assetApi: AssetHelper
}>({
assetApi: async ({ page }, use) => {
const assetApi = createAssetHelper(page)
await use(assetApi)
await assetApi.clearMocks()
}
})

View File

@@ -1,7 +1,5 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
class ShortcutsTab {
readonly essentialsTab: Locator
readonly viewControlsTab: Locator
@@ -18,26 +16,6 @@ class ShortcutsTab {
}
}
export class LogsTab {
readonly tab: Locator
readonly terminalRoot: Locator
readonly terminalHost: Locator
readonly copyButton: Locator
readonly errorMessage: Locator
readonly loadingSpinner: Locator
readonly xtermScreen: Locator
constructor(readonly page: Page) {
this.tab = page.getByRole('tab', { name: /Logs/i })
this.terminalRoot = page.getByTestId(TestIds.terminal.root)
this.terminalHost = page.getByTestId(TestIds.terminal.host)
this.copyButton = page.getByTestId(TestIds.terminal.copyButton)
this.errorMessage = page.getByTestId(TestIds.terminal.errorMessage)
this.loadingSpinner = page.getByTestId(TestIds.terminal.loadingSpinner)
this.xtermScreen = this.terminalHost.locator('.xterm-screen')
}
}
export class BottomPanel {
readonly root: Locator
readonly keyboardShortcutsButton: Locator
@@ -45,7 +23,6 @@ export class BottomPanel {
readonly closeButton: Locator
readonly resizeGutter: Locator
readonly shortcuts: ShortcutsTab
readonly logs: LogsTab
constructor(readonly page: Page) {
this.root = page.locator('.bottom-panel')
@@ -61,15 +38,6 @@ export class BottomPanel {
'.splitter-overlay-bottom > .p-splitter-gutter'
)
this.shortcuts = new ShortcutsTab(page)
this.logs = new LogsTab(page)
}
async toggleLogs() {
await this.toggleButton.click()
await this.logs.tab.waitFor({ state: 'visible' })
if ((await this.logs.tab.getAttribute('aria-selected')) !== 'true') {
await this.logs.tab.click()
}
}
async resizeByDragging(deltaY: number): Promise<void> {

View File

@@ -7,7 +7,7 @@ export const mockSystemStats: SystemStatsResponse = {
embedded_python: false,
comfyui_version: '0.3.10',
pytorch_version: '2.4.0+cu124',
argv: ['main.py'],
argv: ['main.py', '--listen', '0.0.0.0'],
ram_total: 67108864000,
ram_free: 52428800000
},

View File

@@ -1,184 +0,0 @@
import type { Locator, Page, Route } from '@playwright/test'
import type { components } from '@comfyorg/registry-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
type ReleaseNote = components['schemas']['ReleaseNote']
export type HelpMenuItemKey =
| 'feedback'
| 'help'
| 'docs'
| 'discord'
| 'github'
| 'manager'
| 'update-comfyui'
| 'more'
export class HelpCenterHelper {
public readonly button: Locator
public readonly popup: Locator
public readonly backdrop: Locator
public readonly whatsNewSection: Locator
constructor(public readonly page: Page) {
this.button = page.getByTestId(TestIds.helpCenter.button)
this.popup = page.getByTestId(TestIds.helpCenter.popup)
this.backdrop = page.getByTestId(TestIds.helpCenter.backdrop)
this.whatsNewSection = page.getByTestId(TestIds.dialogs.whatsNewSection)
}
menuItem(key: HelpMenuItemKey): Locator {
return this.page.getByTestId(TestIds.helpCenter.menuItem(key))
}
releaseItem(version: string): Locator {
return this.page.getByTestId(TestIds.helpCenter.releaseItem(version))
}
get releaseItems(): Locator {
return this.whatsNewSection.locator('[data-testid^="help-release-item-"]')
}
async open(): Promise<void> {
await this.button.waitFor({ state: 'visible' })
await this.button.click()
await this.popup.waitFor({ state: 'visible' })
}
async closeViaBackdrop(): Promise<void> {
await this.backdrop.click()
await this.popup.waitFor({ state: 'hidden' })
}
async toggle(): Promise<void> {
await this.button.click()
}
/**
* Mock the Comfy release API so the help center gets a deterministic
* list of releases. Empty array is used when `releases` is omitted.
*/
async mockReleases(releases: ReleaseNote[] = []): Promise<void> {
await this.page.route('**/releases**', async (route: Route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(releases)
})
} else {
await route.continue()
}
})
}
/**
* Intercept the Zendesk support URL so it never actually loads in the
* new tab opened by the Contact Support command.
*/
async stubSupportPage(): Promise<void> {
await this.page
.context()
.route('https://support.comfy.org/**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
/**
* Intercept the docs.comfy.org changelog / guide pages so new tabs opened
* by help center actions don't hit the real site during tests.
*/
async stubDocsPage(): Promise<void> {
await this.page
.context()
.route('https://docs.comfy.org/**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
/**
* Intercept outbound static URLs (discord, github, ...) so new tabs
* opened by help center actions don't navigate to the real sites.
*/
async stubExternalPages(): Promise<void> {
for (const pattern of [
'https://www.comfy.org/**',
'https://github.com/**'
]) {
await this.page.context().route(pattern, (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
}
}
/**
* Arms the `popup` listener, runs the action that triggers `window.open`,
* then waits for the popup's initial navigation to commit so `popup.url()`
* doesn't race and return `about:blank`. Returns a parsed `URL` and closes
* the popup.
*
* @example
* ```ts
* const url = await waitForPopup(page, () => button.click())
* expect(url.hostname).toBe('example.com')
* ```
*/
export async function waitForPopup(
page: Page,
action: () => Promise<void>
): Promise<URL> {
const popupPromise = page.waitForEvent('popup')
await action()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const url = new URL(popup.url())
await popup.close()
return url
}
export function createMockRelease(
overrides: Partial<ReleaseNote> = {}
): ReleaseNote {
return {
id: 1,
project: 'comfyui',
version: '0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString(),
...overrides
}
}
/**
* Extends the main comfyPageFixture so that depending on `helpCenter`
* automatically boots the full Comfy app (via the underlying comfyPage
* fixture's setup). Tests only need to destructure `helpCenter`.
*/
export const helpCenterFixture = comfyPageFixture.extend<{
helpCenter: HelpCenterHelper
}>({
helpCenter: async ({ comfyPage }, use) => {
await use(new HelpCenterHelper(comfyPage.page))
}
})

View File

@@ -1,75 +0,0 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
)
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
let resolve!: () => void
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await pending
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
})
return resolve
}
async mockRawLogsError() {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
}
static buildWsLogFrame(messages: string[]): string {
return JSON.stringify({
type: 'logs',
data: { entries: LogsTerminalHelper.buildEntries(messages) }
})
}
private static buildRawLogsResponse(messages: string[]): LogsRawResponse {
return {
size: { cols: 80, row: 24 },
entries: LogsTerminalHelper.buildEntries(messages)
}
}
private static buildEntries(messages: string[]) {
return messages.map((m) => ({
t: '1970-01-01T00:00:00.000Z',
m: m.endsWith('\n') ? m : `${m}\n`
}))
}
}
export const logsTerminalFixture = base.extend<{
logsTerminal: LogsTerminalHelper
}>({
logsTerminal: async ({ page }, use) => {
await use(new LogsTerminalHelper(page))
}
})

View File

@@ -1,10 +1,7 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position, Size } from '@e2e/fixtures/types'
@@ -123,27 +120,6 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -226,13 +202,3 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -1,95 +0,0 @@
import type { Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
export type { CanvasRect }
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
export async function measureSelectionBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
const isLegacyCanvasNodeWithoutVueDom = !nodeEl
if (isLegacyCanvasNodeWithoutVueDom) {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (!node) {
throw new Error(`Node ${id} not found in graph`)
}
const rect = node.boundingRect
nodeVisualBounds[id] = {
x: rect[0],
y: rect[1],
w: rect[2],
h: rect[3]
}
continue
}
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -115,13 +115,6 @@ export const TestIds = {
menu: {
moreMenuContent: 'more-menu-content'
},
helpCenter: {
button: 'help-center-button',
popup: 'help-center-popup',
backdrop: 'help-center-backdrop',
menuItem: (key: string) => `help-menu-item-${key}`,
releaseItem: (version: string) => `help-release-item-${version}`
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
@@ -206,13 +199,6 @@ export const TestIds = {
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
},
terminal: {
root: 'terminal-root',
host: 'terminal-host',
copyButton: 'terminal-copy-button',
errorMessage: 'terminal-error-message',
loadingSpinner: 'terminal-loading-spinner'
},
imageCompare: {
viewport: 'image-compare-viewport',
empty: 'image-compare-empty',
@@ -225,11 +211,34 @@ export const TestIds = {
}
} as const
export type TestId<K extends keyof typeof TestIds> = Exclude<
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
(...args: never[]) => string
>
export type TestIdValue = {
[K in keyof typeof TestIds]: TestId<K>
}[keyof typeof TestIds]
/**
* Helper type for accessing nested TestIds (excludes function values)
*/
export type TestIdValue =
| (typeof TestIds.sidebar)[keyof typeof TestIds.sidebar]
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.menu)[keyof typeof TestIds.menu]
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]

View File

@@ -4,7 +4,6 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
return {
@@ -332,22 +331,6 @@ export class NodeReference {
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
/**
* Toggle the node's collapsed state by simulating the same user interaction
* the runtime uses: DOM collapse button click in Vue mode, canvas icon click
* in legacy mode. Mode is detected by the presence of a Vue-rendered DOM
* element with `data-node-id`.
*/
async toggleCollapse() {
const vueLocator = this.comfyPage.page.locator(
`[data-node-id="${this.id}"]`
)
if ((await vueLocator.count()) > 0) {
await new VueNodeFixture(vueLocator).toggleCollapse()
return
}
await this.click('collapse')
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}

View File

@@ -1,7 +1,6 @@
import { expect, mergeTests } from '@playwright/test'
import { expect } from '@playwright/test'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
createAssetHelper,
withModels,
@@ -18,8 +17,6 @@ import {
STABLE_OUTPUT
} from '@e2e/fixtures/data/assetFixtures'
const test = mergeTests(comfyPageFixture, assetApiFixture)
test.describe('AssetHelper', () => {
test.describe('operators and configuration', () => {
test('creates helper with models via withModels operator', async ({
@@ -69,7 +66,8 @@ test.describe('AssetHelper', () => {
})
test.describe('mock API routes', () => {
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
test('GET /assets returns all assets', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_INPUT_IMAGE)
@@ -89,12 +87,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(2)
expect(data.has_more).toBe(false)
await assetApi.clearMocks()
})
test('GET /assets respects pagination params', async ({
comfyPage,
assetApi
}) => {
test('GET /assets respects pagination params', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withModels(5),
withPagination({ total: 10, hasMore: true })
@@ -112,12 +110,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(10)
expect(data.has_more).toBe(true)
await assetApi.clearMocks()
})
test('GET /assets filters by include_tags', async ({
comfyPage,
assetApi
}) => {
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
@@ -131,12 +129,14 @@ test.describe('AssetHelper', () => {
const data = body as { assets: Array<{ id: string }> }
expect(data.assets).toHaveLength(1)
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
await assetApi.clearMocks()
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
`${comfyPage.url}/api/assets/nonexistent-id`
)
expect(notFound.status).toBe(404)
await assetApi.clearMocks()
})
test('PUT /assets/:id updates asset in store', async ({
comfyPage,
assetApi
}) => {
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -175,12 +175,14 @@ test.describe('AssetHelper', () => {
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
'renamed.safetensors'
)
await assetApi.clearMocks()
})
test('DELETE /assets/:id removes asset from store', async ({
comfyPage,
assetApi
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
await assetApi.mock()
@@ -191,12 +193,11 @@ test.describe('AssetHelper', () => {
expect(status).toBe(204)
expect(assetApi.assetCount).toBe(1)
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
await assetApi.clearMocks()
})
test('POST /assets returns upload response', async ({
comfyPage,
assetApi
}) => {
test('POST /assets returns upload response', async ({ comfyPage }) => {
const customUpload = {
id: 'custom-upload-001',
name: 'custom.safetensors',
@@ -204,6 +205,7 @@ test.describe('AssetHelper', () => {
created_at: '2025-01-01T00:00:00Z',
created_new: true
}
const { assetApi } = comfyPage
assetApi.configure(withUploadResponse(customUpload))
await assetApi.mock()
@@ -215,12 +217,14 @@ test.describe('AssetHelper', () => {
const data = body as { id: string; name: string }
expect(data.id).toBe('custom-upload-001')
expect(data.name).toBe('custom.safetensors')
await assetApi.clearMocks()
})
test('POST /assets/download returns async download response', async ({
comfyPage,
assetApi
comfyPage
}) => {
const { assetApi } = comfyPage
await assetApi.mock()
const { status, body } = await assetApi.fetch(
@@ -231,14 +235,14 @@ test.describe('AssetHelper', () => {
const data = body as { task_id: string; status: string }
expect(data.task_id).toBe('download-task-001')
expect(data.status).toBe('created')
await assetApi.clearMocks()
})
})
test.describe('mutation tracking', () => {
test('tracks POST, PUT, DELETE mutations', async ({
comfyPage,
assetApi
}) => {
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -261,12 +265,12 @@ test.describe('AssetHelper', () => {
expect(mutations[0].method).toBe('POST')
expect(mutations[1].method).toBe('PUT')
expect(mutations[2].method).toBe('DELETE')
await assetApi.clearMocks()
})
test('GET requests are not tracked as mutations', async ({
comfyPage,
assetApi
}) => {
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -276,14 +280,14 @@ test.describe('AssetHelper', () => {
)
expect(assetApi.getMutations()).toHaveLength(0)
await assetApi.clearMocks()
})
})
test.describe('mockError', () => {
test('returns error status for all asset routes', async ({
comfyPage,
assetApi
}) => {
test('returns error status for all asset routes', async ({ comfyPage }) => {
const { assetApi } = comfyPage
await assetApi.mockError(503, 'Service Unavailable')
const { status, body } = await assetApi.fetch(
@@ -292,14 +296,16 @@ test.describe('AssetHelper', () => {
expect(status).toBe(503)
const data = body as { error: string }
expect(data.error).toBe('Service Unavailable')
await assetApi.clearMocks()
})
})
test.describe('clearMocks', () => {
test('resets store, mutations, and unroutes handlers', async ({
comfyPage,
assetApi
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()

View File

@@ -1,151 +1,98 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
LogsTerminalHelper,
logsTerminalFixture
} from '@e2e/fixtures/helpers/LogsTerminalHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import {
getClipboardText,
interceptClipboardWrite
} from '@e2e/helpers/clipboardSpy'
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
test.describe('panel', () => {
test.beforeEach(async ({ logsTerminal }) => {
await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs([])
})
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
test('opens to Logs tab via toggle button', async ({ comfyPage }) => {
await expect(comfyPage.bottomPanel.root).toBeHidden()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.tab).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
})
test('closes via toggle button', async ({ comfyPage }) => {
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleButton.click()
await expect(comfyPage.bottomPanel.root).toBeHidden()
})
test('switches from shortcuts to Logs tab', async ({ comfyPage }) => {
await comfyPage.bottomPanel.keyboardShortcutsButton.click()
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.tab).toBeVisible()
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeHidden()
})
await expect(bottomPanel.root).toBeHidden()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
})
test.describe('terminal', () => {
test.beforeEach(async ({ logsTerminal }) => {
await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs([])
})
test('should show Logs tab when terminal panel opens', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test('shows loading spinner while logs are loading', async ({
comfyPage,
logsTerminal
}) => {
const resolveRaw = await logsTerminal.mockRawLogsPending()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeVisible()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
})
resolveRaw()
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeHidden()
})
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
test('renders initial log entries from the raw-logs API', async ({
comfyPage,
logsTerminal
}) => {
const logLine = 'Hello from ComfyUI backend!'
await logsTerminal.mockRawLogs([logLine])
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeHidden()
})
await expect(comfyPage.bottomPanel.logs.xtermScreen).toBeVisible()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
logLine
)
})
test('should switch between shortcuts and terminal panels', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test('appends log entries received via WebSocket', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
const ws = await getWebSocket()
const firstLine = 'First live log line'
const secondLine = 'Second live log line'
await bottomPanel.toggleButton.click()
ws.send(LogsTerminalHelper.buildWsLogFrame([firstLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
firstLine
)
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeHidden()
})
ws.send(LogsTerminalHelper.buildWsLogFrame([secondLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
firstLine
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
secondLine
)
})
test('should persist Logs tab content in bottom panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test('copy button copies terminal contents to clipboard', async ({
comfyPage,
logsTerminal
}) => {
const logLine = 'Copy me to the clipboard'
await logsTerminal.mockRawLogs([logLine])
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
logLine
)
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await interceptClipboardWrite(comfyPage.page)
const isAlreadyActive =
(await logsTab.getAttribute('aria-selected')) === 'true'
if (!isAlreadyActive) {
await logsTab.click()
}
await comfyPage.bottomPanel.logs.terminalRoot.hover()
await expect(comfyPage.bottomPanel.logs.copyButton).toBeVisible()
await comfyPage.bottomPanel.logs.copyButton.click()
const xtermContainer = bottomPanel.root.locator('.xterm')
await expect(xtermContainer).toBeVisible()
})
await expect
.poll(() => getClipboardText(comfyPage.page))
.toContain(logLine)
})
test('should render xterm container in terminal panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test('shows error message when raw-logs API fails', async ({
comfyPage,
logsTerminal
}) => {
await logsTerminal.mockRawLogsError()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await expect(comfyPage.bottomPanel.logs.errorMessage).toBeVisible()
await expect(comfyPage.bottomPanel.logs.errorMessage).toContainText(
'Unable to load logs'
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
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()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -167,7 +167,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
...mockSystemStats,
system: {
...mockSystemStats.system,
argv: ['main.py', '--enable-manager']
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {

View File

@@ -131,38 +131,6 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
expect(switched).toBe(true)
})
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
try {
await comfyPage.settings.setSetting(settingId, !initialValue)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.LiteGraph!.middle_click_slot_add_default_node
)
)
.toBe(!initialValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
test('Dropdown setting can be changed and persists', async ({
comfyPage
}) => {

View File

@@ -1,174 +0,0 @@
import { expect } from '@playwright/test'
import {
createMockRelease,
helpCenterFixture as test,
waitForPopup
} from '@e2e/fixtures/helpers/HelpCenterHelper'
test.describe('Help Center', () => {
test.describe('popup visibility', () => {
test('opens the popup and shows the backdrop when the sidebar button is clicked', async ({
helpCenter
}) => {
await helpCenter.toggle()
await expect(helpCenter.popup).toBeVisible()
await expect(helpCenter.backdrop).toBeVisible()
})
test('closes when the backdrop is clicked', async ({ helpCenter }) => {
await helpCenter.open()
await helpCenter.closeViaBackdrop()
await expect(helpCenter.popup).toBeHidden()
})
test('closes after clicking a menu item that opens an external tab', async ({
helpCenter
}) => {
await helpCenter.stubDocsPage()
await helpCenter.open()
await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('docs').click()
)
await expect(helpCenter.popup).toBeHidden()
})
})
test.describe('popup positioning', () => {
test('anchors to the left when sidebar location is left', async ({
comfyPage,
helpCenter
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await helpCenter.open()
await expect(helpCenter.popup).toHaveClass(/sidebar-left/)
await expect(helpCenter.popup).not.toHaveClass(/sidebar-right/)
})
test('anchors to the right when sidebar location is right', async ({
comfyPage,
helpCenter
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await helpCenter.open()
await expect(helpCenter.popup).toHaveClass(/sidebar-right/)
await expect(helpCenter.popup).not.toHaveClass(/sidebar-left/)
})
})
test.describe('menu item actions', () => {
test.beforeEach(async ({ helpCenter }) => {
await helpCenter.stubDocsPage()
await helpCenter.stubExternalPages()
await helpCenter.stubSupportPage()
await helpCenter.open()
})
test('Docs item opens docs.comfy.org/ in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('docs').click()
)
expect(url.hostname).toBe('docs.comfy.org')
expect(url.pathname).toBe('/')
})
test('Discord item opens comfy.org/discord in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('discord').click()
)
expect(url.hostname).toBe('www.comfy.org')
expect(url.pathname).toBe('/discord')
})
test('Github item opens the ComfyUI repo in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('github').click()
)
expect(url.hostname).toBe('github.com')
expect(url.pathname).toBe('/Comfy-Org/ComfyUI')
})
test('Help & Support item opens the Zendesk support form with OSS tag', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('help').click()
)
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
})
test('Give Feedback item opens Contact Support in OSS mode', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('feedback').click()
)
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
})
})
test.describe("What's New releases", () => {
test('renders only the three most recent releases', async ({
comfyPage,
helpCenter
}) => {
const versions = ['0.4.10', '0.4.9', '0.4.8', '0.4.7', '0.4.6']
const now = Date.now()
const releases = versions.map((version, idx) =>
createMockRelease({
id: idx + 1,
version,
published_at: new Date(now - idx * 60_000).toISOString()
})
)
await helpCenter.mockReleases(releases)
await comfyPage.setup({ mockReleases: false })
await helpCenter.open()
await expect(helpCenter.whatsNewSection).toBeVisible()
await expect(helpCenter.releaseItems).toHaveCount(3)
await expect(helpCenter.releaseItem('0.4.10')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.9')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.8')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.7')).toHaveCount(0)
})
test('clicking a release opens the changelog with a version anchor', async ({
comfyPage,
helpCenter
}) => {
const release = createMockRelease({ version: '0.3.50' })
await helpCenter.mockReleases([release])
await helpCenter.stubDocsPage()
await comfyPage.setup({ mockReleases: false })
await helpCenter.open()
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.releaseItem('0.3.50').click()
)
expect(url.hostname).toBe('docs.comfy.org')
expect(url.pathname).toBe('/changelog')
expect(url.hash).toBe('#v0-3-50')
await expect(helpCenter.popup).toBeHidden()
})
})
})

View File

@@ -34,35 +34,10 @@ export class Load3DHelper {
return this.node.getByText(name, { exact: true })
}
get gizmoToggleButton(): Locator {
return this.node.getByRole('button', { name: 'Gizmo' })
}
get gizmoTranslateButton(): Locator {
return this.node.getByRole('button', { name: 'Translate' })
}
get gizmoRotateButton(): Locator {
return this.node.getByRole('button', { name: 'Rotate' })
}
get gizmoScaleButton(): Locator {
return this.node.getByRole('button', { name: 'Scale' })
}
get gizmoResetButton(): Locator {
return this.node.getByRole('button', { name: 'Reset Transform' })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async openGizmoCategory(): Promise<void> {
await this.openMenu()
await this.getMenuCategory('Gizmo').click()
}
async setBackgroundColor(hex: string): Promise<void> {
await this.colorInput.evaluate((el, value) => {
;(el as HTMLInputElement).value = value

View File

@@ -1,87 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
const getGizmoConfig = (page: Page) =>
page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const modelConfig = n?.properties?.['Model Config'] as
| { gizmo?: { enabled: boolean; mode: string } }
| undefined
return modelConfig?.gizmo
})
test.describe('Load3D Gizmo Controls', () => {
test(
'Gizmo category appears in the controls menu',
{ tag: '@smoke' },
async ({ load3d }) => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
}
)
test(
'Selecting Gizmo category shows the toggle button',
{ tag: '@smoke' },
async ({ load3d }) => {
await load3d.openGizmoCategory()
await expect(load3d.gizmoToggleButton).toBeVisible()
await expect(load3d.gizmoTranslateButton).toBeHidden()
await expect(load3d.gizmoRotateButton).toBeHidden()
await expect(load3d.gizmoScaleButton).toBeHidden()
await expect(load3d.gizmoResetButton).toBeHidden()
}
)
test(
'Toggling gizmo reveals mode buttons and updates node state',
{ tag: '@smoke' },
async ({ comfyPage, load3d }) => {
await load3d.openGizmoCategory()
await load3d.gizmoToggleButton.click()
await expect(load3d.gizmoTranslateButton).toBeVisible()
await expect(load3d.gizmoRotateButton).toBeVisible()
await expect(load3d.gizmoScaleButton).toBeVisible()
await expect(load3d.gizmoResetButton).toBeVisible()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.enabled))
.toBe(true)
await load3d.gizmoToggleButton.click()
await expect(load3d.gizmoTranslateButton).toBeHidden()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.enabled))
.toBe(false)
}
)
test(
'Selecting a gizmo mode updates node state',
{ tag: '@smoke' },
async ({ comfyPage, load3d }) => {
await load3d.openGizmoCategory()
await load3d.gizmoToggleButton.click()
await load3d.gizmoRotateButton.click()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
.toBe('rotate')
await load3d.gizmoScaleButton.click()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
.toBe('scale')
await load3d.gizmoTranslateButton.click()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
.toBe('translate')
}
)
})

View File

@@ -0,0 +1,82 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Note Node API Export', { tag: '@node' }, () => {
test('excludes Note and MarkdownNote from API format export', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const classTypes = Object.values(apiWorkflow).map((n) => n.class_type)
expect(classTypes, 'API output should not contain Note').not.toContain(
'Note'
)
expect(
classTypes,
'API output should not contain MarkdownNote'
).not.toContain('MarkdownNote')
expect(
Object.keys(apiWorkflow),
'All-virtual workflow should produce empty API output'
).toHaveLength(0)
})
test('preserves real nodes while filtering virtual ones', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const entries = Object.values(apiWorkflow)
expect(entries, 'Exactly one real node in API output').toHaveLength(1)
expect(entries[0].class_type).toBe('KSampler')
})
test('standard workflow export still includes Note nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
const workflow = await comfyPage.workflow.getExportedWorkflow()
const noteNodes = workflow.nodes.filter(
(n) => n.type === 'Note' || n.type === 'MarkdownNote'
)
expect(
noteNodes,
'Standard export must preserve both Note and MarkdownNote'
).toHaveLength(2)
})
test('no virtual node types leak through graphToPrompt', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
const virtualNodeCheck = await comfyPage.page.evaluate(async () => {
const { output } = await window.app!.graphToPrompt()
const virtualTypes = ['Note', 'MarkdownNote', 'Reroute', 'PrimitiveNode']
const leaked: string[] = []
for (const node of Object.values(output)) {
if (virtualTypes.includes(node.class_type)) {
leaked.push(node.class_type)
}
}
return { leaked, totalNodes: Object.keys(output).length }
})
expect(
virtualNodeCheck.leaked,
'No virtual node types should leak into API output'
).toHaveLength(0)
expect(virtualNodeCheck.totalNodes).toBeGreaterThan(0)
})
})

View File

@@ -1,235 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
type Layout = { ref: [number, number]; target: [number, number] }
const LAYOUTS: Record<string, Layout> = {
'bottom-left': { ref: [200, 100], target: [150, 500] },
'bottom-right': { ref: [100, 100], target: [600, 500] }
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
async function toggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
await nodeRef.click('title')
await comfyPage.keyboard.bypass()
}
async function assertSelectionEncompassesNodes(
page: Page,
comfyPage: ComfyPage,
nodeIds: string[]
) {
await comfyPage.canvas.press('Control+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(2)
await comfyPage.nextFrame()
const result = await measureSelectionBounds(page, nodeIds)
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
for (const nodeId of nodeIds) {
const vis = result.nodeVisualBounds[nodeId]
expect(vis).toBeDefined()
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
}
}
test.describe(
'Selection bounding box (Vue mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const vueCases: ReadonlyArray<{
type: NodeType
state: NodeState
pos: Position
}> = [
{ type: 'subgraph', state: 'expanded', pos: 'bottom-left' },
{ type: 'subgraph', state: 'expanded', pos: 'bottom-right' },
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-left' },
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-right' },
{ type: 'regular', state: 'expanded', pos: 'bottom-left' },
{ type: 'regular', state: 'expanded', pos: 'bottom-right' },
{ type: 'regular', state: 'collapsed', pos: 'bottom-left' },
{ type: 'regular', state: 'collapsed', pos: 'bottom-right' }
]
for (const { type, state, pos } of vueCases) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const targetId = getTargetId(type)
const refId = getRefId(type)
await comfyPage.nodeOps.repositionNodes({
[refId]: LAYOUTS[pos].ref,
[targetId]: LAYOUTS[pos].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
refId,
targetId
])
})
}
}
)
test.describe(
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('collapsed node narrows bounding box when bypass is removed', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
[REGULAR_ID]: LAYOUTS['bottom-right'].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
test('collapsed node widens bounding box when bypass is added', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
[REGULAR_ID]: LAYOUTS['bottom-right'].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
)
test.describe(
'Selection bounding box (legacy mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const legacyCases: ReadonlyArray<{ state: NodeState; pos: Position }> = [
{ state: 'expanded', pos: 'bottom-left' },
{ state: 'expanded', pos: 'bottom-right' },
{ state: 'collapsed', pos: 'bottom-left' },
{ state: 'collapsed', pos: 'bottom-right' }
]
for (const { state, pos } of legacyCases) {
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS[pos].ref,
[REGULAR_ID]: LAYOUTS[pos].target
})
await comfyPage.nextFrame()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
}
)

View File

@@ -1,81 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Topbar menu commands', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setup()
})
test('New command creates a new workflow tab', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await topbar.triggerTopbarCommand(['New'])
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test('Edit > Undo undoes the last action', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Edit > Redo restores an undone action', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('File > Save opens save dialog', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['File', 'Save'])
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
await expect(saveDialog).toBeVisible()
})
test('View > Bottom Panel toggles bottom panel', async ({ comfyPage }) => {
await expect(comfyPage.bottomPanel.root).toBeHidden()
await comfyPage.menu.topbar.triggerTopbarCommand(['View', 'Bottom Panel'])
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.menu.topbar.triggerTopbarCommand(['View', 'Bottom Panel'])
await expect(comfyPage.bottomPanel.root).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
)
test(
'Empty state matches the screenshot baseline',
'Empty state matches screenshot baseline',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -26,8 +26,6 @@ An Entity Component System (ECS) separates **identity** (entities), **data** (co
- **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types)
- **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management
For the full problem catalog with line-level code references, see [Entity System Structural Problems](../architecture/entity-problems.md). For a map of all current entity relationships, see [Entity Interactions](../architecture/entity-interactions.md).
## Decision
Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise.
@@ -174,7 +172,7 @@ Systems are pure functions that query the World for entities with specific compo
- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout
- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing
System design is deferred to a future ADR. For detailed before/after walkthroughs of how lifecycle operations (node removal, link creation, subgraph nesting, etc.) transform under ECS, see [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md).
System design is deferred to a future ADR.
### Migration Strategy
@@ -184,8 +182,6 @@ System design is deferred to a future ADR. For detailed before/after walkthrough
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md).
### Relationship to ADR 0003 (Command Pattern / CRDT)
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
@@ -235,23 +231,6 @@ Planned mitigations for the ECS render path:
The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.
## Supporting Documents
Companion architecture documents that expand on the design in this ADR:
| Document | Description |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
## Notes
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.6",
"version": "1.44.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -102,6 +102,7 @@
"fuse.js": "^7.0.0",
"glob": "catalog:",
"jsonata": "catalog:",
"jsondiffpatch": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",

View File

@@ -28,7 +28,6 @@ export type {
BillingPlansResponse,
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -45,6 +44,11 @@ export type {
CheckHubUsernameErrors,
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClaimInviteCodeData,
ClaimInviteCodeError,
ClaimInviteCodeErrors,
ClaimInviteCodeResponse,
ClaimInviteCodeResponses,
ClientOptions,
CreateAssetDownloadData,
CreateAssetDownloadError,
@@ -109,13 +113,6 @@ export type {
CreateWorkflowVersionRequest,
CreateWorkflowVersionResponse,
CreateWorkflowVersionResponses,
CreateWorkspaceApiKeyData,
CreateWorkspaceApiKeyError,
CreateWorkspaceApiKeyErrors,
CreateWorkspaceApiKeyRequest,
CreateWorkspaceApiKeyResponse,
CreateWorkspaceApiKeyResponse2,
CreateWorkspaceApiKeyResponses,
CreateWorkspaceData,
CreateWorkspaceError,
CreateWorkspaceErrors,
@@ -240,16 +237,12 @@ export type {
GetBillingStatusErrors,
GetBillingStatusResponse,
GetBillingStatusResponses,
GetCustomNodeProxyData,
GetCustomNodeProxyErrors,
GetCustomNodeProxyResponses,
GetDeletionRequestData,
GetDeletionRequestError,
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetExtensionsData,
GetExtensionsResponse,
GetExtensionsResponses,
GetFeaturesData,
GetFeaturesResponse,
@@ -270,9 +263,7 @@ export type {
GetGlobalSubgraphsResponse,
GetGlobalSubgraphsResponses,
GetHealthData,
GetHealthError,
GetHealthErrors,
GetHealthResponse,
GetHealthResponses,
GetHistoryData,
GetHistoryError,
@@ -294,6 +285,11 @@ export type {
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetInviteCodeStatusData,
GetInviteCodeStatusError,
GetInviteCodeStatusErrors,
GetInviteCodeStatusResponse,
GetInviteCodeStatusResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
@@ -343,19 +339,9 @@ export type {
GetMyHubProfileErrors,
GetMyHubProfileResponse,
GetMyHubProfileResponses,
GetNodeByIdData,
GetNodeByIdErrors,
GetNodeByIdResponses,
GetNodeInfoData,
GetNodeInfoResponse,
GetNodeInfoResponses,
GetNodeInfoSchemaData,
GetNodeInfoSchemaResponses,
GetNodeReplacementsData,
GetNodeReplacementsError,
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetPaymentPortalData,
@@ -436,15 +422,11 @@ export type {
GetUserErrors,
GetUserResponse,
GetUserResponses,
GetUsersInfoData,
GetUsersInfoError,
GetUsersInfoErrors,
GetUsersInfoResponse,
GetUsersInfoResponses,
GetUsersRawData,
GetUsersRawErrors,
GetUsersRawResponses,
GetVhsQueryVideoData,
GetVhsQueryVideoError,
GetVhsQueryVideoErrors,
GetVhsQueryVideoResponse,
GetVhsQueryVideoResponses,
GetVhsViewAudioData,
GetVhsViewAudioErrors,
@@ -505,6 +487,8 @@ export type {
InterruptJobError,
InterruptJobErrors,
InterruptJobResponses,
InviteCodeClaimResponse,
InviteCodeStatusResponse,
JobDetailResponse,
JobEntry,
JobsListResponse,
@@ -567,12 +551,6 @@ export type {
ListWorkflowsErrors,
ListWorkflowsResponse,
ListWorkflowsResponses,
ListWorkspaceApiKeysData,
ListWorkspaceApiKeysError,
ListWorkspaceApiKeysErrors,
ListWorkspaceApiKeysResponse,
ListWorkspaceApiKeysResponse2,
ListWorkspaceApiKeysResponses,
ListWorkspaceInvitesData,
ListWorkspaceInvitesError,
ListWorkspaceInvitesErrors,
@@ -623,9 +601,6 @@ export type {
PostAssetsFromWorkflowErrors,
PostAssetsFromWorkflowResponse,
PostAssetsFromWorkflowResponses,
PostCustomNodeProxyData,
PostCustomNodeProxyErrors,
PostCustomNodeProxyResponses,
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
@@ -686,11 +661,6 @@ export type {
ResubscribeResponse,
ResubscribeResponse2,
ResubscribeResponses,
RevokeWorkspaceApiKeyData,
RevokeWorkspaceApiKeyError,
RevokeWorkspaceApiKeyErrors,
RevokeWorkspaceApiKeyResponse,
RevokeWorkspaceApiKeyResponses,
RevokeWorkspaceInviteData,
RevokeWorkspaceInviteError,
RevokeWorkspaceInviteErrors,
@@ -698,6 +668,13 @@ export type {
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretResponse,
SendUserInviteEmailData,
SendUserInviteEmailError,
SendUserInviteEmailErrors,
SendUserInviteEmailRequest,
SendUserInviteEmailResponse,
SendUserInviteEmailResponse2,
SendUserInviteEmailResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
@@ -741,12 +718,6 @@ export type {
UpdateHubProfileRequest,
UpdateHubProfileResponse,
UpdateHubProfileResponses,
UpdateHubWorkflowData,
UpdateHubWorkflowError,
UpdateHubWorkflowErrors,
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -763,11 +734,6 @@ export type {
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
UpdateSubscriptionCacheResponse,
UpdateSubscriptionCacheResponses,
UpdateWorkflowData,
UpdateWorkflowError,
UpdateWorkflowErrors,
@@ -799,13 +765,6 @@ export type {
UserResponse,
ValidationError,
ValidationResult,
VerifyApiKeyRequest,
VerifyApiKeyResponse,
VerifyWorkspaceApiKeyData,
VerifyWorkspaceApiKeyError,
VerifyWorkspaceApiKeyErrors,
VerifyWorkspaceApiKeyResponse,
VerifyWorkspaceApiKeyResponses,
ViewFileData,
ViewFileError,
ViewFileErrors,
@@ -820,7 +779,6 @@ export type {
WorkflowVersionContentResponse,
WorkflowVersionResponse,
Workspace,
WorkspaceApiKeyInfo,
WorkspaceSummary,
WorkspaceWithRole
} from './types.gen'

File diff suppressed because it is too large Load Diff

View File

@@ -20,32 +20,6 @@ export const zHubAssetUploadUrlRequest = z.object({
content_type: z.string()
})
/**
* Partial update for a published hub workflow (admin moderation). All fields are optional. Semantics match UpdateHubProfileRequest / avatar_token:
*
* * field omitted or null — leave unchanged
* * string field = "" — clear (for clearable string fields)
* * array field = [] — clear the list
* * any other value — set to the provided value
*
* Array fields use full-replacement (PUT) semantics when a value is supplied. The two single-value thumbnail token fields accept only upload tokens (not existing URLs) since omitting them already expresses "keep the current value".
* Backend note: cleared string columns are persisted as the empty string "" in the Ent schema (description, thumbnail_url, thumbnail_comparison_url, tutorial_url). thumbnail_type is the only true SQL-nullable column but is not clearable via this endpoint.
*
*/
export const zUpdateHubWorkflowRequest = z.object({
name: z.string().min(1).nullish(),
description: z.string().nullish(),
tags: z.array(z.string()).nullish(),
models: z.array(z.string()).nullish(),
custom_nodes: z.array(z.string()).nullish(),
tutorial_url: z.string().nullish(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_token: z.string().nullish(),
thumbnail_comparison_token: z.string().nullish(),
sample_image_tokens_or_urls: z.array(z.string()).nullish(),
metadata: z.record(z.unknown()).nullish()
})
export const zPublishHubWorkflowRequest = z.object({
username: z.string(),
name: z.string(),
@@ -160,43 +134,8 @@ export const zHubWorkflowTemplateEntry = z.object({
thumbnailVariant: z.string().optional(),
mediaType: z.string().optional(),
mediaSubtype: z.string().optional(),
size: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
vram: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
usage: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
searchRank: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
isEssential: z.boolean().optional(),
size: z.number().optional(),
vram: z.number().optional(),
openSource: z.boolean().optional(),
profile: zHubProfileSummary.optional(),
tutorialUrl: z.string().optional(),
@@ -702,53 +641,6 @@ export const zJwksResponse = z.object({
keys: z.array(zJwkKey)
})
export const zVerifyApiKeyResponse = z.object({
user_id: z.string(),
email: z.string(),
name: z.string(),
is_admin: z.boolean(),
workspace_id: z.string(),
workspace_type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member']),
has_funds: z.boolean(),
is_active: z.boolean(),
permissions: z.array(z.string())
})
export const zVerifyApiKeyRequest = z.object({
api_key: z.string()
})
export const zWorkspaceApiKeyInfo = z.object({
id: z.string().uuid(),
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
revoked_at: z.string().datetime().optional(),
created_at: z.string().datetime()
})
export const zListWorkspaceApiKeysResponse = z.object({
api_keys: z.array(zWorkspaceApiKeyInfo)
})
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
created_at: z.string().datetime()
})
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
expires_at: z.string().datetime().optional()
})
export const zAcceptInviteResponse = z.object({
workspace_id: z.string(),
workspace_name: z.string()
@@ -1087,6 +979,22 @@ export const zAssetCreated = zAsset.and(
})
)
/**
* Response after sending an invite email
*/
export const zSendUserInviteEmailResponse = z.object({
success: z.boolean(),
message: z.string()
})
/**
* Request to send an invite email to a user
*/
export const zSendUserInviteEmailRequest = z.object({
email: z.string(),
force: z.boolean().optional().default(false)
})
export const zSetReviewStatusResponse = z.object({
share_ids: z.array(z.string()),
status: z.enum(['approved', 'rejected'])
@@ -1097,6 +1005,22 @@ export const zSetReviewStatusRequest = z.object({
status: z.enum(['approved', 'rejected'])
})
/**
* Response after successfully claiming an invite code
*/
export const zInviteCodeClaimResponse = z.object({
success: z.boolean(),
message: z.string()
})
/**
* Invite code status response
*/
export const zInviteCodeStatusResponse = z.object({
claimed: z.boolean(),
expired: z.boolean()
})
/**
* Response after deleting a session cookie
*/
@@ -1116,7 +1040,6 @@ export const zCreateSessionResponse = z.object({
* User information response
*/
export const zUserResponse = z.object({
id: z.string(),
status: z.string()
})
@@ -1271,16 +1194,8 @@ export const zQueueManageRequest = z.object({
* Queue information with pending and running jobs
*/
export const zQueueInfo = z.object({
queue_running: z
.array(
z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()])
)
.optional(),
queue_pending: z
.array(
z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()])
)
.optional()
queue_running: z.array(z.array(z.unknown())).optional(),
queue_pending: z.array(z.array(z.unknown())).optional()
})
/**
@@ -1400,10 +1315,6 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
export const zBindingErrorResponse = z.object({
message: z.string()
})
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
@@ -1516,17 +1427,6 @@ export const zGetFeaturesResponse = z.object({
max_upload_size: z.number().int().optional()
})
export const zGetNodeReplacementsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - Node replacement mappings
*/
export const zGetNodeReplacementsResponse = z.record(z.unknown())
export const zGetWorkflowTemplatesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1688,7 +1588,7 @@ export const zViewFileData = z.object({
})
/**
* Processed PNG image with extracted channel
* Success - File content returned (used when channel or res parameter is present)
*/
export const zViewFileResponse = z.string()
@@ -2529,56 +2429,6 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* List of API keys
*/
export const zListWorkspaceApiKeysResponse2 = zListWorkspaceApiKeysResponse
export const zCreateWorkspaceApiKeyData = z.object({
body: zCreateWorkspaceApiKeyRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* API key created (plaintext returned once)
*/
export const zCreateWorkspaceApiKeyResponse2 = zCreateWorkspaceApiKeyResponse
export const zRevokeWorkspaceApiKeyData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* API key revoked
*/
export const zRevokeWorkspaceApiKeyResponse = z.void()
export const zVerifyWorkspaceApiKeyData = z.object({
body: zVerifyApiKeyRequest,
path: z.never().optional(),
query: z
.object({
include_billing: z.boolean().optional().default(false)
})
.optional()
})
/**
* Key is valid
*/
export const zVerifyWorkspaceApiKeyResponse = zVerifyApiKeyResponse
export const zGetUserData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2590,6 +2440,43 @@ export const zGetUserData = z.object({
*/
export const zGetUserResponse = zUserResponse
export const zGetInviteCodeStatusData = z.object({
body: z.never().optional(),
path: z.object({
code: z.string()
}),
query: z.never().optional()
})
/**
* Success - invite code exists
*/
export const zGetInviteCodeStatusResponse = zInviteCodeStatusResponse
export const zClaimInviteCodeData = z.object({
body: z.never().optional(),
path: z.object({
code: z.string()
}),
query: z.never().optional()
})
/**
* Success - invite code claimed successfully
*/
export const zClaimInviteCodeResponse = zInviteCodeClaimResponse
export const zSendUserInviteEmailData = z.object({
body: zSendUserInviteEmailRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - invite email sent successfully
*/
export const zSendUserInviteEmailResponse2 = zSendUserInviteEmailResponse
export const zSetReviewStatusData = z.object({
body: zSetReviewStatusRequest,
path: z.never().optional(),
@@ -2601,19 +2488,6 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Updated hub workflow detail
*/
export const zUpdateHubWorkflowResponse = zHubWorkflowDetail
export const zGetDeletionRequestData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2653,23 +2527,6 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
is_active: z.boolean(),
tier: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Cache updated successfully
*/
export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zGetJobStatusData = z.object({
body: z.never().optional(),
path: z.object({
@@ -3134,25 +2991,6 @@ export const zGetExtensionsData = z.object({
query: z.never().optional()
})
/**
* URL paths (relative to web root) of available extension JS files
*/
export const zGetExtensionsResponse = z.array(z.string())
export const zGetNodeInfoSchemaData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetNodeByIdData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetVhsViewVideoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3181,32 +3019,12 @@ export const zGetVhsQueryVideoData = z.object({
})
})
/**
* Video metadata
*/
export const zGetVhsQueryVideoResponse = z.object({
source: z.object({
size: z.tuple([z.number().int(), z.number().int()]),
fps: z.number(),
frames: z.number().int(),
duration: z.number()
})
})
export const zGetUsersInfoData = z.object({
export const zGetUsersRawData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Userdata storage information
*/
export const zGetUsersInfoResponse = z.object({
storage: z.string(),
migrated: z.boolean()
})
export const zGetApiViewVideoAliasData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3247,11 +3065,6 @@ export const zGetHealthData = z.object({
query: z.never().optional()
})
/**
* Service is healthy
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3321,19 +3134,3 @@ export const zGetStaticExtensionsData = z.object({
}),
query: z.never().optional()
})
export const zGetCustomNodeProxyData = z.object({
body: z.never().optional(),
path: z.object({
path: z.string()
}),
query: z.never().optional()
})
export const zPostCustomNodeProxyData = z.object({
body: z.never().optional(),
path: z.object({
path: z.string()
}),
query: z.never().optional()
})

View File

@@ -15951,35 +15951,40 @@ export interface components {
QuiverTextToSVGRequest: {
/**
* @description Model identifier for SVG generation
* @example arrow-1.1
* @default arrow-preview
*/
model: string;
/** @description Text description of the desired SVG output */
prompt: string;
/** @description Additional style or formatting guidance */
instructions?: string;
/** @description Optional reference images to guide style/composition. Accepts URL object, base64 object, or URL string shorthand. Runtime limits are model-specific. */
references?: (components["schemas"]["QuiverImageObject"] | string)[];
/** @description Up to 4 reference images (URL or base64) */
references?: components["schemas"]["QuiverImageObject"][];
/**
* @description Number of SVGs to generate
* @default 1
*/
n: number;
/**
* @description Sampling temperature
* @description Enable Server-Sent Events streaming
* @default false
*/
stream: boolean;
/**
* @description Randomness control
* @default 1
*/
temperature: number;
/**
* @description Nucleus sampling probability
* @description Nucleus sampling parameter
* @default 1
*/
top_p: number;
/**
* @description Penalty for tokens already present in prior output
* @description Token presence penalty
* @default 0
*/
presence_penalty: number | null;
presence_penalty: number;
/** @description Maximum number of output tokens */
max_output_tokens?: number;
};
@@ -15987,37 +15992,42 @@ export interface components {
QuiverImageToSVGRequest: {
/**
* @description Model identifier for SVG vectorization
* @example arrow-1.1
* @default arrow-preview
*/
model: string;
image: components["schemas"]["QuiverImageObject"];
/**
* @description Auto-crop image to the dominant subject before vectorization
* @description Automatically crop to dominant subject
* @default false
*/
auto_crop: boolean;
/** @description Square resize target in pixels */
target_size?: number;
/**
* @description Number of SVGs to generate
* @default 1
*/
n: number;
/**
* @description Enable Server-Sent Events streaming
* @default false
*/
stream: boolean;
/**
* @description Sampling temperature
* @description Randomness control
* @default 1
*/
temperature: number;
/**
* @description Nucleus sampling probability
* @description Nucleus sampling parameter
* @default 1
*/
top_p: number;
/**
* @description Penalty for tokens already present in prior output
* @description Token presence penalty
* @default 0
*/
presence_penalty: number | null;
presence_penalty: number;
/** @description Maximum number of output tokens */
max_output_tokens?: number;
};
@@ -16034,39 +16044,24 @@ export interface components {
/** @description Response from Quiver AI SVG generation/vectorization */
QuiverSVGResponse: {
/** @description Unique identifier for the generation */
id: string;
id?: string;
/** @description Unix timestamp of creation */
created: number;
data: {
/** @description Raw SVG markup */
svg: string;
created?: number;
data?: {
/** @description Generated SVG content */
svg?: string;
/**
* @description MIME type of the output
* @enum {string}
* @default image/svg+xml
*/
mime_type: "image/svg+xml";
mime_type: string;
}[];
/** @description Credit cost for this request. Use this for billing instead of usage tokens. */
credits?: number;
/**
* @deprecated
* @description Deprecated. Use credits for billing values.
*/
usage?: {
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
/** @description Total tokens used */
total_tokens?: number;
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
/** @description Input tokens used */
input_tokens?: number;
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
/** @description Output tokens used */
output_tokens?: number;
};
};

View File

@@ -7,7 +7,6 @@ import {
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
truncateFilename
} from './formatUtil'
@@ -358,12 +357,4 @@ describe('formatUtil', () => {
expect(isPreviewableMediaType('other')).toBe(false)
})
})
describe('isCivitaiModelUrl', () => {
it('recognizes civitai.red model URLs', () => {
expect(
isCivitaiModelUrl('https://civitai.red/api/download/models/123456')
).toBe(true)
})
})
})

View File

@@ -361,17 +361,9 @@ export const generateUUID = (): string => {
*/
export const isCivitaiModelUrl = (url: string): boolean => {
if (!isValidUrl(url)) return false
if (!url.includes('civitai.com')) return false
const urlObj = new URL(url)
const hostname = urlObj.hostname.toLowerCase()
const isCivitaiHost =
hostname === 'civitai.com' ||
hostname.endsWith('.civitai.com') ||
hostname === 'civitai.red' ||
hostname.endsWith('.civitai.red')
if (!isCivitaiHost) {
return false
}
const pathname = urlObj.pathname
return (

20
pnpm-lock.yaml generated
View File

@@ -267,6 +267,9 @@ catalogs:
jsonata:
specifier: ^2.1.0
version: 2.1.0
jsondiffpatch:
specifier: ^0.7.3
version: 0.7.3
knip:
specifier: ^6.3.1
version: 6.3.1
@@ -554,6 +557,9 @@ importers:
jsonata:
specifier: 'catalog:'
version: 2.1.0
jsondiffpatch:
specifier: 'catalog:'
version: 0.7.3
loglevel:
specifier: ^1.9.2
version: 1.9.2
@@ -1774,6 +1780,9 @@ packages:
'@cyberalien/svg-utils@1.1.1':
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
'@dmsnell/diff-match-patch@1.1.0':
resolution: {integrity: sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==}
'@dual-bundle/import-meta-resolve@4.2.1':
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
@@ -7260,6 +7269,11 @@ packages:
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
jsondiffpatch@0.7.3:
resolution: {integrity: sha512-zd4dqFiXSYyant2WgSXAZ9+yYqilNVvragVNkNRn2IFZKgjyULNrKRznqN4Zon0MkLueCg+3QaPVCnDAVP20OQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@@ -11225,6 +11239,8 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@dmsnell/diff-match-patch@1.1.0': {}
'@dual-bundle/import-meta-resolve@4.2.1': {}
'@emmetio/abbreviation@2.3.3':
@@ -17124,6 +17140,10 @@ snapshots:
jsonc-parser@3.3.1: {}
jsondiffpatch@0.7.3:
dependencies:
'@dmsnell/diff-match-patch': 1.1.0
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1

View File

@@ -90,6 +90,7 @@ catalog:
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.3.1
lenis: ^1.3.21
lint-staged: ^16.2.7

View File

@@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'node:fs'
const TARGET = 80
const MILESTONE_STEP = 5
const MIN_DELTA = 0.05
const BAR_WIDTH = 20
interface CoverageData {
@@ -72,9 +71,8 @@ function formatPct(value: number): string {
}
function formatDelta(delta: number): string {
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
const sign = rounded >= 0 ? '+' : ''
return sign + rounded.toFixed(1) + '%'
const sign = delta >= 0 ? '+' : ''
return sign + delta.toFixed(1) + '%'
}
function crossedMilestone(prev: number, curr: number): number | null {
@@ -152,18 +150,15 @@ function main() {
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
const unitDelta =
unitCurrent !== null && unitBaseline !== null
? unitCurrent.percentage - unitBaseline.percentage
: 0
const unitImproved =
unitCurrent !== null &&
unitBaseline !== null &&
unitCurrent.percentage > unitBaseline.percentage
const e2eDelta =
e2eCurrent !== null && e2eBaseline !== null
? e2eCurrent.percentage - e2eBaseline.percentage
: 0
const unitImproved = unitDelta >= MIN_DELTA
const e2eImproved = e2eDelta >= MIN_DELTA
const e2eImproved =
e2eCurrent !== null &&
e2eBaseline !== null &&
e2eCurrent.percentage > e2eBaseline.percentage
if (!unitImproved && !e2eImproved) {
process.exit(0)
@@ -177,12 +172,12 @@ function main() {
)
summaryLines.push('')
if (unitImproved) {
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
if (unitCurrent && unitBaseline) {
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
}
if (e2eImproved) {
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
if (e2eCurrent && e2eBaseline) {
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
}
summaryLines.push('')

View File

@@ -1,10 +0,0 @@
/** Padding applied around the selection bounding rect (both screen + canvas). */
export const SELECTION_BOUNDS_PADDING = 10
/** Rectangle expressed in canvas-world coordinates. */
export interface CanvasRect {
x: number
y: number
w: number
h: number
}

View File

@@ -1,22 +1,13 @@
<template>
<div
ref="rootEl"
data-testid="terminal-root"
class="relative size-full overflow-hidden bg-neutral-900"
>
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
<div class="p-terminal size-full rounded-none p-2">
<div
ref="terminalEl"
data-testid="terminal-host"
class="terminal-host h-full"
/>
<div ref="terminalEl" class="terminal-host h-full" />
</div>
<Button
v-tooltip.left="{
value: tooltipText,
showDelay: 300
}"
data-testid="terminal-copy-button"
variant="secondary"
size="sm"
:class="

View File

@@ -1,15 +1,10 @@
<template>
<div class="size-full bg-transparent">
<p
v-if="errorMessage"
data-testid="terminal-error-message"
class="p-4 text-center"
>
<p v-if="errorMessage" class="p-4 text-center">
{{ errorMessage }}
</p>
<ProgressSpinner
v-else-if="loading"
data-testid="terminal-loading-spinner"
class="relative inset-0 z-10 flex h-full items-center justify-center"
/>
<BaseTerminal v-show="!loading" @created="terminalCreated" />

View File

@@ -405,8 +405,8 @@ import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'

View File

@@ -14,7 +14,6 @@
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
:data-testid="`help-menu-item-${menuItem.key}`"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@@ -104,7 +103,6 @@
v-for="release in releaseStore.recentReleases"
:key="release.id || release.version"
class="release-menu-item flex h-12 min-h-6 cursor-pointer items-center gap-2 self-stretch rounded-sm p-2 transition-colors hover:bg-interface-menu-component-surface-hovered"
:data-testid="`help-release-item-${release.version}`"
role="button"
tabindex="0"
@click="onReleaseClick(release)"

View File

@@ -4,7 +4,6 @@
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
data-testid="help-center-popup"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
@@ -39,7 +38,6 @@
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
data-testid="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>

View File

@@ -1,9 +1,8 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import type { SelectOption } from '@/components/ui/select/types'
import MultiSelect from './MultiSelect.vue'
import type { SelectOption } from './types'
const meta: Meta<typeof MultiSelect> = {
title: 'Components/Select/MultiSelect',

View File

@@ -155,6 +155,9 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import {
selectContentClass,
selectDropdownClass,
@@ -162,10 +165,8 @@ import {
selectItemVariants,
selectTriggerVariants,
stopEscapeToDocument
} from '@/components/ui/select/select.variants'
import type { SelectOption } from '@/components/ui/select/types'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
} from './select.variants'
import type { SelectOption } from './types'
defineOptions({
inheritAttrs: false

View File

@@ -1,9 +1,8 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import MultiSelect from './MultiSelect.vue'
import SingleSelect from './SingleSelect.vue'
import type { SelectOption } from './types'
const meta: Meta = {

View File

@@ -84,16 +84,17 @@ import {
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import {
selectContentClass,
selectDropdownClass,
selectItemVariants,
selectTriggerVariants,
stopEscapeToDocument
} from '@/components/ui/select/select.variants'
import type { SelectOption } from '@/components/ui/select/types'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
} from './select.variants'
import type { SelectOption } from './types'
defineOptions({
inheritAttrs: false

View File

@@ -28,9 +28,6 @@
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
@toggle-gizmo="handleToggleGizmo"
@set-gizmo-mode="handleSetGizmoMode"
@reset-gizmo-transform="handleResetGizmoTransform"
/>
<AnimationControls
v-if="animations && animations.length > 0"
@@ -43,27 +40,9 @@
@seek="handleSeek"
/>
</div>
<div class="pointer-events-auto absolute top-12 right-2 z-20">
<div class="flex flex-col rounded-lg bg-backdrop/30">
<Button
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.fitToViewer')"
@click="handleFitToViewer"
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
</div>
</div>
<div
v-if="enable3DViewer && node"
class="pointer-events-auto absolute top-24 right-2 z-20"
class="pointer-events-auto absolute top-12 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
</div>
@@ -72,8 +51,8 @@
v-if="!isPreview"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-24': !enable3DViewer,
'top-36': enable3DViewer
'top-12': !enable3DViewer,
'top-24': enable3DViewer
}"
>
<RecordingControls
@@ -98,7 +77,6 @@ import Load3DScene from '@/components/load3d/Load3DScene.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Button from '@/components/ui/button/Button.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -165,10 +143,6 @@ const {
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
handleToggleGizmo,
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

@@ -92,14 +92,6 @@
v-if="showExportControls"
@export-model="handleExportModel"
/>
<GizmoControls
v-if="showGizmoControls"
v-model:gizmo-config="modelConfig!.gizmo"
@toggle-gizmo="handleToggleGizmo"
@set-gizmo-mode="handleSetGizmoMode"
@reset-gizmo-transform="handleResetGizmoTransform"
/>
</div>
</div>
</template>
@@ -110,7 +102,6 @@ import { computed, ref } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
import GizmoControls from '@/components/load3d/controls/GizmoControls.vue'
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
@@ -118,7 +109,6 @@ import SceneControls from '@/components/load3d/controls/SceneControls.vue'
import Button from '@/components/ui/button/Button.vue'
import type {
CameraConfig,
GizmoMode,
LightConfig,
ModelConfig,
SceneConfig
@@ -158,7 +148,6 @@ const categoryLabels: Record<string, string> = {
model: 'load3d.model',
camera: 'load3d.camera',
light: 'load3d.light',
gizmo: 'load3d.gizmo.label',
export: 'load3d.export'
}
@@ -167,7 +156,7 @@ const availableCategories = computed(() => {
return ['scene', 'model', 'camera']
}
return ['scene', 'model', 'camera', 'light', 'gizmo', 'export']
return ['scene', 'model', 'camera', 'light', 'export']
})
const showSceneControls = computed(
@@ -186,9 +175,6 @@ const showLightControls = computed(
!!modelConfig.value
)
const showExportControls = computed(() => activeCategory.value === 'export')
const showGizmoControls = computed(
() => activeCategory.value === 'gizmo' && !!modelConfig.value
)
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
@@ -204,7 +190,6 @@ const categoryIcons = {
model: 'icon-[lucide--box]',
camera: 'icon-[lucide--camera]',
light: 'icon-[lucide--sun]',
gizmo: 'icon-[lucide--move-3d]',
export: 'icon-[lucide--download]'
} as const
@@ -220,9 +205,6 @@ const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'updateHdriFile', file: File | null): void
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
}>()
const handleBackgroundImageUpdate = (file: File | null) => {
@@ -236,16 +218,4 @@ const handleExportModel = (format: string) => {
const handleHDRIFileUpdate = (file: File | null) => {
emit('updateHdriFile', file)
}
const handleToggleGizmo = (enabled: boolean) => {
emit('toggleGizmo', enabled)
}
const handleSetGizmoMode = (mode: GizmoMode) => {
emit('setGizmoMode', mode)
}
const handleResetGizmoTransform = () => {
emit('resetGizmoTransform')
}
</script>

View File

@@ -74,14 +74,6 @@
/>
</div>
<div class="space-y-4 p-2">
<GizmoControls
v-model:gizmo-enabled="viewer.gizmoEnabled.value"
v-model:gizmo-mode="viewer.gizmoMode.value"
@reset-transform="viewer.resetGizmoTransform"
/>
</div>
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
<ExportControls @export-model="viewer.exportModel" />
</div>
@@ -107,7 +99,6 @@ import { useI18n } from 'vue-i18n'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
import GizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue'
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'

View File

@@ -1,155 +0,0 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import GizmoControls from '@/components/load3d/controls/GizmoControls.vue'
import type { GizmoConfig } from '@/extensions/core/load3d/interfaces'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
gizmo: {
toggle: 'Gizmo',
translate: 'Translate',
rotate: 'Rotate',
scale: 'Scale',
reset: 'Reset Transform'
}
}
}
}
})
function makeConfig(overrides: Partial<GizmoConfig> = {}): GizmoConfig {
return {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 },
...overrides
}
}
function renderComponent(initial: Partial<GizmoConfig> = {}) {
const gizmoConfig = ref<GizmoConfig>(makeConfig(initial))
const utils = render(GizmoControls, {
props: {
gizmoConfig: gizmoConfig.value,
'onUpdate:gizmoConfig': (v: GizmoConfig | undefined) => {
if (v) gizmoConfig.value = v
}
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, gizmoConfig, user: userEvent.setup() }
}
describe('GizmoControls', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renders only the toggle button when gizmo is disabled', () => {
renderComponent({ enabled: false })
expect(screen.getByRole('button', { name: 'Gizmo' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Rotate' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Scale' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Reset Transform' })).toBeNull()
})
it('renders mode and reset buttons when gizmo is enabled', () => {
renderComponent({ enabled: true })
expect(screen.getByRole('button', { name: 'Translate' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Rotate' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Scale' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Reset Transform' })).toBeTruthy()
})
it('flips enabled and emits toggleGizmo when the toggle is clicked', async () => {
const { user, gizmoConfig, emitted } = renderComponent({ enabled: false })
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
expect(gizmoConfig.value.enabled).toBe(true)
expect(emitted().toggleGizmo).toEqual([[true]])
})
it('turns off gizmo and emits false when toggled from enabled state', async () => {
const { user, gizmoConfig, emitted } = renderComponent({ enabled: true })
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
expect(gizmoConfig.value.enabled).toBe(false)
expect(emitted().toggleGizmo).toEqual([[false]])
})
it.each([
['Translate', 'translate'],
['Rotate', 'rotate'],
['Scale', 'scale']
] as const)(
'sets mode to %s and emits setGizmoMode when clicked',
async (label, mode) => {
const { user, gizmoConfig, emitted } = renderComponent({ enabled: true })
await user.click(screen.getByRole('button', { name: label }))
expect(gizmoConfig.value.mode).toBe(mode)
expect(emitted().setGizmoMode).toEqual([[mode]])
}
)
it('emits resetGizmoTransform without mutating config on reset click', async () => {
const { user, gizmoConfig, emitted } = renderComponent({
enabled: true,
mode: 'rotate'
})
await user.click(screen.getByRole('button', { name: 'Reset Transform' }))
expect(emitted().resetGizmoTransform).toEqual([[]])
expect(gizmoConfig.value.mode).toBe('rotate')
expect(gizmoConfig.value.enabled).toBe(true)
})
it('highlights the active mode button with a ring', () => {
renderComponent({ enabled: true, mode: 'rotate' })
const translate = screen.getByRole('button', { name: 'Translate' })
const rotate = screen.getByRole('button', { name: 'Rotate' })
const scale = screen.getByRole('button', { name: 'Scale' })
expect(rotate.className).toContain('ring-2')
expect(translate.className).not.toContain('ring-2')
expect(scale.className).not.toContain('ring-2')
})
it('does nothing when clicked with no model value bound', async () => {
const user = userEvent.setup()
const { emitted } = render(GizmoControls, {
props: { gizmoConfig: undefined },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
expect(emitted().toggleGizmo).toBeUndefined()
})
})

View File

@@ -1,122 +0,0 @@
<template>
<div class="flex flex-col">
<Button
v-tooltip.right="{ value: t('load3d.gizmo.toggle'), showDelay: 300 }"
variant="textonly"
size="icon"
:class="cn('rounded-full', gizmoEnabled && 'ring-2 ring-white/50')"
:aria-label="t('load3d.gizmo.toggle')"
@click="toggleGizmo"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
<template v-if="gizmoEnabled">
<Button
v-tooltip.right="{
value: t('load3d.gizmo.translate'),
showDelay: 300
}"
variant="textonly"
size="icon"
:class="
cn(
'rounded-full',
gizmoMode === 'translate' && 'ring-2 ring-white/50'
)
"
:aria-label="t('load3d.gizmo.translate')"
@click="setMode('translate')"
>
<i class="pi pi-arrows-alt text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: t('load3d.gizmo.rotate'),
showDelay: 300
}"
variant="textonly"
size="icon"
:class="
cn('rounded-full', gizmoMode === 'rotate' && 'ring-2 ring-white/50')
"
:aria-label="t('load3d.gizmo.rotate')"
@click="setMode('rotate')"
>
<i class="pi pi-sync text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: t('load3d.gizmo.scale'),
showDelay: 300
}"
variant="textonly"
size="icon"
:class="
cn('rounded-full', gizmoMode === 'scale' && 'ring-2 ring-white/50')
"
:aria-label="t('load3d.gizmo.scale')"
@click="setMode('scale')"
>
<i class="pi pi-expand text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: t('load3d.gizmo.reset'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="t('load3d.gizmo.reset')"
@click="resetTransform"
>
<i class="pi pi-refresh text-lg text-base-foreground" />
</Button>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
GizmoConfig,
GizmoMode
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const gizmoConfig = defineModel<GizmoConfig>('gizmoConfig')
const gizmoEnabled = computed(() => gizmoConfig.value?.enabled ?? false)
const gizmoMode = computed(() => gizmoConfig.value?.mode ?? 'translate')
const emit = defineEmits<{
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
}>()
const toggleGizmo = () => {
if (!gizmoConfig.value) return
gizmoConfig.value.enabled = !gizmoConfig.value.enabled
emit('toggleGizmo', gizmoConfig.value.enabled)
}
const setMode = (mode: GizmoMode) => {
if (!gizmoConfig.value) return
gizmoConfig.value.mode = mode
emit('setGizmoMode', mode)
}
const resetTransform = () => {
emit('resetGizmoTransform')
}
</script>

View File

@@ -1,133 +0,0 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import ViewerGizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { on: 'On', off: 'Off' },
load3d: {
gizmo: {
toggle: 'Gizmo',
translate: 'Translate',
rotate: 'Rotate',
scale: 'Scale',
reset: 'Reset Transform'
}
}
}
}
})
function renderComponent(
initial: { enabled?: boolean; mode?: GizmoMode } = {}
) {
const enabled = ref<boolean>(initial.enabled ?? false)
const mode = ref<GizmoMode>(initial.mode ?? 'translate')
const utils = render(ViewerGizmoControls, {
props: {
gizmoEnabled: enabled.value,
'onUpdate:gizmoEnabled': (v: boolean | undefined) => {
if (v !== undefined) enabled.value = v
},
gizmoMode: mode.value,
'onUpdate:gizmoMode': (v: GizmoMode | undefined) => {
if (v) mode.value = v
}
},
global: {
plugins: [i18n]
}
})
return { ...utils, enabled, mode, user: userEvent.setup() }
}
describe('ViewerGizmoControls', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renders only the on/off toggle when gizmo is disabled', () => {
renderComponent({ enabled: false })
expect(screen.getByText('Gizmo')).toBeTruthy()
expect(screen.getByText('Off')).toBeTruthy()
expect(screen.getByText('On')).toBeTruthy()
expect(screen.queryByText('Translate')).toBeNull()
expect(screen.queryByText('Rotate')).toBeNull()
expect(screen.queryByText('Scale')).toBeNull()
expect(screen.queryByText('Reset Transform')).toBeNull()
})
it('renders mode toggles and reset button when gizmo is enabled', () => {
renderComponent({ enabled: true })
expect(screen.getByText('Translate')).toBeTruthy()
expect(screen.getByText('Rotate')).toBeTruthy()
expect(screen.getByText('Scale')).toBeTruthy()
expect(screen.getByText('Reset Transform')).toBeTruthy()
})
it('enables gizmo when the On item is clicked', async () => {
const { user, enabled } = renderComponent({ enabled: false })
await user.click(screen.getByText('On'))
expect(enabled.value).toBe(true)
})
it('disables gizmo when the Off item is clicked from an enabled state', async () => {
const { user, enabled } = renderComponent({ enabled: true })
await user.click(screen.getByText('Off'))
expect(enabled.value).toBe(false)
})
it.each([
['Translate', 'translate'],
['Rotate', 'rotate'],
['Scale', 'scale']
] as const)(
'updates mode to %s when its toggle item is clicked',
async (label, expected) => {
const { user, mode } = renderComponent({
enabled: true,
mode: 'translate'
})
await user.click(screen.getByText(label))
expect(mode.value).toBe(expected)
}
)
it('emits reset-transform when the reset button is clicked', async () => {
const { user, emitted } = renderComponent({
enabled: true,
mode: 'rotate'
})
await user.click(screen.getByRole('button', { name: /reset transform/i }))
expect(emitted()['reset-transform']).toEqual([[]])
})
it('leaves mode unchanged when deselecting the active mode', async () => {
const { user, mode } = renderComponent({ enabled: true, mode: 'scale' })
await user.click(screen.getByText('Scale'))
expect(mode.value).toBe('scale')
})
})

View File

@@ -1,63 +0,0 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<label>{{ $t('load3d.gizmo.toggle') }}</label>
<ToggleGroup
type="single"
:model-value="gizmoEnabled ? 'on' : 'off'"
@update:model-value="(v) => (gizmoEnabled = v === 'on')"
>
<ToggleGroupItem value="off" size="sm">
{{ $t('g.off') }}
</ToggleGroupItem>
<ToggleGroupItem value="on" size="sm">
{{ $t('g.on') }}
</ToggleGroupItem>
</ToggleGroup>
</div>
<template v-if="gizmoEnabled">
<div>
<ToggleGroup
type="single"
:model-value="gizmoMode"
@update:model-value="
(v) => {
if (v) gizmoMode = v as GizmoMode
}
"
>
<ToggleGroupItem value="translate">
{{ $t('load3d.gizmo.translate') }}
</ToggleGroupItem>
<ToggleGroupItem value="rotate">
{{ $t('load3d.gizmo.rotate') }}
</ToggleGroupItem>
<ToggleGroupItem value="scale">
{{ $t('load3d.gizmo.scale') }}
</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<Button variant="secondary" @click="$emit('reset-transform')">
<i class="pi pi-refresh" />
{{ $t('load3d.gizmo.reset') }}
</Button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
const gizmoEnabled = defineModel<boolean>('gizmoEnabled')
const gizmoMode = defineModel<GizmoMode>('gizmoMode')
defineEmits<{
(e: 'reset-transform'): void
}>()
</script>

View File

@@ -1,185 +0,0 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import type {
IWidgetRangeOptions,
RangeValue
} from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const upstreamHolder = vi.hoisted(() => ({
ref: null as { value: unknown } | null
}))
vi.mock('@/composables/useUpstreamValue', async () => {
const { ref } = await import('vue')
return {
useUpstreamValue: () => {
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
return upstreamHolder.ref
},
singleValueExtractor: () => () => undefined
}
})
const outputsHolder = vi.hoisted(() => ({
nodeOutputs: {} as Record<string, unknown>
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => outputsHolder
}))
import WidgetRange from './WidgetRange.vue'
const RangeEditorStub = defineComponent({
name: 'RangeEditor',
props: {
modelValue: { type: Object, default: () => ({ min: 0, max: 1 }) },
disabled: { type: Boolean, default: false },
histogram: { type: Object, default: null },
display: { type: String, default: '' }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `
<div data-testid="range-editor"
:data-disabled="String(disabled)"
:data-has-histogram="String(!!histogram)"
:data-model="JSON.stringify(modelValue)"
:data-display="display"
@click="$emit('update:modelValue', { min: 5, max: 10 })"
/>
`
})
function makeWidget(
options: Partial<IWidgetRangeOptions> = {},
widgetOverrides: Partial<
SimplifiedWidget<RangeValue, IWidgetRangeOptions>
> = {}
): SimplifiedWidget<RangeValue, IWidgetRangeOptions> {
return {
name: 'range_w',
type: 'range',
value: { min: 0, max: 1 },
options: options as IWidgetRangeOptions,
...widgetOverrides
} as SimplifiedWidget<RangeValue, IWidgetRangeOptions>
}
function setUpstream(value: RangeValue | undefined) {
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
upstreamHolder.ref.value = value
}
function renderWidget(
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>,
initialModel: RangeValue = { min: 0, max: 1 }
) {
const value = ref<RangeValue>(initialModel)
const Harness = defineComponent({
components: { WidgetRange },
setup: () => ({ value, widget }),
template: '<WidgetRange v-model="value" :widget="widget" />'
})
const utils = render(Harness, {
global: { stubs: { RangeEditor: RangeEditorStub } }
})
return { ...utils, value }
}
describe('WidgetRange', () => {
beforeEach(() => {
upstreamHolder.ref = null
outputsHolder.nodeOutputs = {}
})
describe('Value pass-through', () => {
it('forwards modelValue to the RangeEditor', () => {
renderWidget(makeWidget(), { min: 0.2, max: 0.8 })
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.2, max: 0.8 })
})
it('propagates editor updates back to v-model', async () => {
const { value } = renderWidget(makeWidget())
const user = userEvent.setup()
await user.click(screen.getByTestId('range-editor'))
expect(value.value).toEqual({ min: 5, max: 10 })
})
it('forwards the display option to the RangeEditor', () => {
renderWidget(makeWidget({ display: 'histogram' }))
expect(screen.getByTestId('range-editor').dataset.display).toBe(
'histogram'
)
})
})
describe('Disabled state', () => {
it('passes disabled=true when widget.options.disabled is set', () => {
renderWidget(makeWidget({ disabled: true }))
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('true')
})
it('passes disabled=false by default', () => {
renderWidget(makeWidget())
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('false')
})
it('shows upstream value when disabled with a valid upstream', () => {
setUpstream({ min: 0.3, max: 0.7 })
renderWidget(
makeWidget({ disabled: true } as IWidgetRangeOptions, {
linkedUpstream: { nodeId: 'n1' }
}),
{ min: 0, max: 1 }
)
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.3, max: 0.7 })
})
it('ignores upstream value when not disabled', () => {
setUpstream({ min: 0.3, max: 0.7 })
renderWidget(makeWidget({}, { linkedUpstream: { nodeId: 'n1' } }), {
min: 0,
max: 1
})
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0, max: 1 })
})
})
describe('Histogram', () => {
it('passes null histogram when nodeLocatorId is absent', () => {
renderWidget(makeWidget())
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)
})
it('passes a histogram when node output has a matching histogram entry', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
)
})
it('treats an empty histogram array as null', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)
})
})
})

View File

@@ -50,7 +50,7 @@ vi.mock('@/stores/commandStore', () => ({
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
staticUrls: {
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues'
}
}))
}))
@@ -284,7 +284,7 @@ describe('ErrorNodeCard.vue', () => {
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('github.com/Comfy-Org/ComfyUI/issues?q='),
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
'_blank',
'noopener,noreferrer'
)

View File

@@ -1,159 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useErrorActions } from './useErrorActions'
const mocks = vi.hoisted(() => ({
trackUiButtonClicked: vi.fn(),
trackHelpResourceClicked: vi.fn(),
execute: vi.fn(),
telemetry: null as {
trackUiButtonClicked: ReturnType<typeof vi.fn>
trackHelpResourceClicked: ReturnType<typeof vi.fn>
} | null,
staticUrls: {
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
}
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mocks.execute
})
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: mocks.staticUrls
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => mocks.telemetry
}))
describe('useErrorActions', () => {
let windowOpenSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mocks.telemetry = {
trackUiButtonClicked: mocks.trackUiButtonClicked,
trackHelpResourceClicked: mocks.trackHelpResourceClicked
}
mocks.trackUiButtonClicked.mockReset()
mocks.trackHelpResourceClicked.mockReset()
mocks.execute.mockReset()
windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null as unknown as Window)
})
afterEach(() => {
windowOpenSpy.mockRestore()
})
describe('openGitHubIssues', () => {
it('tracks the button click and opens the GitHub issues URL in a new tab', () => {
const { openGitHubIssues } = useErrorActions()
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
'_blank',
'noopener,noreferrer'
)
})
it('still opens the link when telemetry is unavailable', () => {
mocks.telemetry = null
const { openGitHubIssues } = useErrorActions()
openGitHubIssues()
expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled()
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
'_blank',
'noopener,noreferrer'
)
})
})
describe('contactSupport', () => {
it('tracks the help resource click and executes the contact support command', () => {
mocks.execute.mockReturnValue('executed')
const { contactSupport } = useErrorActions()
const result = contactSupport()
expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(result).toBe('executed')
})
it('returns the execute promise when the command is async', async () => {
mocks.execute.mockResolvedValue('done')
const { contactSupport } = useErrorActions()
await expect(contactSupport()).resolves.toBe('done')
})
it('still executes the command when telemetry is unavailable', () => {
mocks.telemetry = null
const { contactSupport } = useErrorActions()
void contactSupport()
expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled()
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
})
})
describe('findOnGitHub', () => {
it('tracks the click and opens a URL-encoded issue search with " is:issue" appended', () => {
const { findOnGitHub } = useErrorActions()
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(
`${mocks.staticUrls.githubIssues}?q=${expectedQuery}`,
'_blank',
'noopener,noreferrer'
)
})
it('URL-encodes messages with special characters', () => {
const { findOnGitHub } = useErrorActions()
findOnGitHub('error with spaces & symbols?')
const [[url]] = windowOpenSpy.mock.calls as unknown as [[string]]
expect(url).toContain('?q=')
const queryPart = url.split('?q=')[1]
expect(decodeURIComponent(queryPart)).toBe(
'error with spaces & symbols? is:issue'
)
})
it('still opens the link when telemetry is unavailable', () => {
mocks.telemetry = null
const { findOnGitHub } = useErrorActions()
findOnGitHub('boom')
expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled()
expect(windowOpenSpy).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,392 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { useErrorReport } from './useErrorReport'
async function flushPromises() {
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
}
const mocks = vi.hoisted(() => {
// Helpers only — imports happen inside factories below.
return {
getLogs: vi.fn(),
serialize: vi.fn(),
refetchSystemStats: vi.fn(),
generateErrorReport: vi.fn()
}
})
const storeState = vi.hoisted(() => {
// Plain objects wired up in beforeEach. Tests use setStoreState to swap values.
return {
systemStats: null as unknown,
isLoading: false
}
})
vi.mock('@/scripts/api', () => ({
api: {
getLogs: mocks.getLogs
}
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: mocks.serialize
}
}
}))
vi.mock('@/utils/errorReportUtil', () => ({
generateErrorReport: mocks.generateErrorReport
}))
vi.mock('@/stores/systemStatsStore', async () => {
const { ref: vueRef } = await import('vue')
const systemStatsRef = vueRef<unknown>(null)
const isLoadingRef = vueRef(false)
return {
useSystemStatsStore: () => ({
get systemStats() {
return systemStatsRef.value
},
set systemStats(value: unknown) {
systemStatsRef.value = value
},
get isLoading() {
return isLoadingRef.value
},
set isLoading(value: boolean) {
isLoadingRef.value = value
},
refetchSystemStats: mocks.refetchSystemStats,
__setSystemStats(value: unknown) {
systemStatsRef.value = value
},
__setIsLoading(value: boolean) {
isLoadingRef.value = value
}
})
}
})
type TestStore = ReturnType<typeof useSystemStatsStore> & {
__setSystemStats: (value: unknown) => void
__setIsLoading: (value: boolean) => void
}
async function getStore(): Promise<TestStore> {
const mod = await import('@/stores/systemStatsStore')
return mod.useSystemStatsStore() as unknown as TestStore
}
const sampleSystemStats = {
system: {
os: 'Linux',
comfyui_version: '1.0.0',
argv: [],
python_version: '3.11',
embedded_python: false,
pytorch_version: '2.3.0'
},
devices: []
}
function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: '42',
errors: [],
...overrides
}
}
describe('useErrorReport', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
mocks.getLogs.mockReset()
mocks.serialize.mockReset()
mocks.refetchSystemStats.mockReset()
mocks.generateErrorReport.mockReset()
storeState.systemStats = null
storeState.isLoading = false
const store = await getStore()
store.__setSystemStats(null)
store.__setIsLoading(false)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('returns early without enrichment when the card has no runtime errors', async () => {
const card = makeCard({
errors: [{ message: 'static', details: 'details' }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'details' })
})
it('enriches each runtime error with a generated report when systemStats is present', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('server logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionType }: { exceptionType: string }) =>
`report:${exceptionType}`
)
const card = makeCard({
errors: [
{
message: 'CUDA oom',
details: 'trace-0',
isRuntimeError: true,
exceptionType: 'RuntimeError'
},
{
message: 'static',
details: 'skip-me'
},
{
message: 'Other runtime error',
details: 'trace-2',
isRuntimeError: true
}
]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).toHaveBeenCalledTimes(1)
expect(mocks.generateErrorReport).toHaveBeenCalledTimes(2)
expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(1, {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: '42',
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',
workflow: { nodes: [] }
})
expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
exceptionType: 'Runtime Error',
exceptionMessage: 'Other runtime error',
traceback: 'trace-2'
})
)
expect(displayedDetailsMap.value).toEqual({
0: 'report:RuntimeError',
1: 'skip-me',
2: 'report:Runtime Error'
})
})
it('awaits the systemStats loading flag before proceeding', async () => {
const store = await getStore()
store.__setIsLoading(true)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockReturnValue('report')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'trace' })
store.__setSystemStats(sampleSystemStats)
store.__setIsLoading(false)
await flushPromises()
expect(mocks.getLogs).toHaveBeenCalledTimes(1)
expect(displayedDetailsMap.value).toEqual({ 0: 'report' })
})
it('calls refetchSystemStats when not loading and stats are missing', async () => {
const store = await getStore()
mocks.refetchSystemStats.mockImplementation(async () => {
store.__setSystemStats(sampleSystemStats)
})
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockReturnValue('report')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
useErrorReport(card)
await flushPromises()
expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1)
expect(mocks.generateErrorReport).toHaveBeenCalledTimes(1)
})
it('returns early and warns when refetchSystemStats throws', async () => {
mocks.refetchSystemStats.mockRejectedValue(new Error('boom'))
mocks.getLogs.mockResolvedValue('logs')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
useErrorReport(card)
await flushPromises()
expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1)
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalled()
})
it('returns early and warns when workflow serialization throws', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockImplementation(() => {
throw new Error('serialize failed')
})
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'trace' })
})
it('falls back to original error.details when generateErrorReport throws', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(() => {
throw new Error('generate failed')
})
const card = makeCard({
errors: [
{ message: 'runtime', details: 'fallback', isRuntimeError: true }
]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(warnSpy).toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'fallback' })
})
it('re-enriches and clears stale enriched details when the card ref changes', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionMessage }: { exceptionMessage: string }) =>
`report:${exceptionMessage}`
)
const cardRef = ref<ErrorCardData>(
makeCard({
id: 'first',
errors: [
{ message: 'first-err', details: 'first', isRuntimeError: true }
]
})
)
const { displayedDetailsMap } = useErrorReport(cardRef)
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'report:first-err' })
cardRef.value = makeCard({
id: 'second',
errors: [{ message: 'plain', details: 'plain-details' }]
})
await nextTick()
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'plain-details' })
})
it('drops stale results when the card changes mid-flight', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionMessage }: { exceptionMessage: string }) =>
`report:${exceptionMessage}`
)
const firstLogsDeferred: {
resolve: (value: string) => void
promise: Promise<string>
} = (() => {
let resolve: (value: string) => void = () => {}
const promise = new Promise<string>((r) => {
resolve = r
})
return { resolve, promise }
})()
mocks.getLogs.mockImplementationOnce(() => firstLogsDeferred.promise)
mocks.getLogs.mockImplementationOnce(async () => 'second-logs')
const cardRef = ref<ErrorCardData>(
makeCard({
id: 'first',
errors: [
{ message: 'first-err', details: 'first', isRuntimeError: true }
]
})
)
const { displayedDetailsMap } = useErrorReport(cardRef)
await flushPromises()
cardRef.value = makeCard({
id: 'second',
errors: [
{ message: 'second-err', details: 'second', isRuntimeError: true }
]
})
await nextTick()
await flushPromises()
firstLogsDeferred.resolve('stale-logs')
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'report:second-err' })
})
})

View File

@@ -2,7 +2,6 @@
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h, ref } from 'vue'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
@@ -103,13 +103,11 @@ vi.mock('@/stores/authStore', () => ({
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
isActiveSubscription: { value: true },
subscriptionTierName: { value: 'Creator' },
subscriptionTier: { value: 'CREATOR' },
fetchStatus: mockFetchStatus
}))
}))
@@ -190,7 +188,6 @@ describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsFreeTier.value = false
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
@@ -409,43 +406,14 @@ describe('CurrentUserPopoverLegacy', () => {
})
})
describe('cloud free tier', () => {
beforeEach(() => {
mockIsCloud.value = true
mockIsFreeTier.value = true
})
it('shows upgrade-to-add-credits button and hides add-credits button', () => {
renderComponent()
expect(
screen.getByTestId('upgrade-to-add-credits-button')
).toBeInTheDocument()
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
})
})
describe('non-cloud distribution', () => {
beforeEach(() => {
mockIsCloud.value = false
})
it('still shows credits balance', () => {
it('hides credits section', () => {
renderComponent()
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows add-credits button and hides upgrade-to-add-credits button', () => {
renderComponent()
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
expect(
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
})
it('hides upgrade-to-add-credits button even when on free tier', () => {
mockIsFreeTier.value = true
renderComponent()
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
expect(
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
@@ -456,9 +424,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {
it('hides partner nodes menu item', () => {
renderComponent()
expect(screen.getByTestId('partner-nodes-menu-item')).toBeInTheDocument()
expect(
screen.queryByTestId('partner-nodes-menu-item')
).not.toBeInTheDocument()
})
it('hides plans & pricing menu item', () => {
@@ -468,9 +438,11 @@ describe('CurrentUserPopoverLegacy', () => {
).not.toBeInTheDocument()
})
it('still shows manage plan menu item', () => {
it('hides manage plan menu item', () => {
renderComponent()
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
expect(
screen.queryByTestId('manage-plan-menu-item')
).not.toBeInTheDocument()
})
it('still shows user settings menu item', () => {

View File

@@ -29,8 +29,11 @@
</span>
</div>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<!-- Credits Section (cloud only) -->
<div
v-if="isCloud && isActiveSubscription"
class="flex items-center gap-2 px-4 py-2"
>
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
@@ -46,7 +49,7 @@
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
/>
<Button
v-if="isCloud && isFreeTier"
v-if="isFreeTier"
variant="gradient"
size="sm"
data-testid="upgrade-to-add-credits-button"
@@ -79,7 +82,7 @@
<Divider class="mx-0 my-2" />
<div
v-if="isActiveSubscription"
v-if="isCloud && isActiveSubscription"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
@@ -109,7 +112,7 @@
</div>
<div
v-if="isActiveSubscription"
v-if="isCloud && isActiveSubscription"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"

View File

@@ -1,202 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import type { WorkflowStatus } from '@/stores/executionStore'
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockWorkflowStatus: shallowRef<Map<string, WorkflowStatus>>(new Map()),
mockCloseWorkflow: vi.fn().mockResolvedValue(true)
}
})
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isLoading: false
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isInitialized: true
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
get workflowStatus() {
return mockWorkflowStatus.value
}
})
}))
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
usePragmaticDraggable: vi.fn(),
usePragmaticDroppable: vi.fn()
}))
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
useWorkflowActionsMenu: () => ({
menuItems: { value: [] }
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
closeWorkflow: mockCloseWorkflow
})
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
getThumbnail: vi.fn(() => null)
})
}))
vi.mock('./WorkflowTabPopover.vue', () => ({
default: {
render: () => null,
methods: {
showPopover: () => {},
hidePopover: () => {},
togglePopover: () => {}
}
}
}))
import WorkflowTab from './WorkflowTab.vue'
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
const statusAriaLabels: Record<WorkflowStatus, string> = {
running: 'Running',
completed: 'Completed',
failed: 'Failed'
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close', ...statusAriaLabels }
}
}
})
function makeWorkflowOption(overrides: Record<string, unknown> = {}) {
return {
value: 'test-key',
workflow: {
key: 'test-key',
path: '/workflows/test.json',
filename: 'test.json',
isPersisted: true,
isModified: false,
initialMode: 'default',
activeMode: 'default',
changeTracker: null,
...overrides
}
} as unknown as WorkflowTabProps['workflowOption']
}
function renderTab({
workflowOverrides = {},
activeWorkflowKey = 'other-key'
} = {}) {
return render(WorkflowTab, {
global: {
plugins: [
createTestingPinia({
stubActions: false,
initialState: {
workspace: { shiftDown: false },
workflow: {
activeWorkflow: { key: activeWorkflowKey }
},
setting: {}
}
}),
i18n
],
stubs: {
WorkflowActionsList: true,
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
},
props: {
workflowOption: makeWorkflowOption(workflowOverrides),
isFirst: false,
isLast: false
}
})
}
describe('WorkflowTab - job state indicator', () => {
beforeEach(() => {
mockWorkflowStatus.value = new Map()
mockCloseWorkflow.mockClear()
})
it.for(['running', 'completed', 'failed'] as const)(
'shows %s indicator from store with translated aria-label',
(status) => {
mockWorkflowStatus.value = new Map([['/workflows/test.json', status]])
renderTab()
const indicator = screen.getByTestId('job-state-indicator')
expect(indicator.getAttribute('data-state')).toBe(status)
expect(indicator.getAttribute('role')).toBe('status')
expect(indicator.getAttribute('aria-label')).toBe(
statusAriaLabels[status]
)
}
)
it('shows unsaved dot when no job state and workflow is unsaved', () => {
renderTab({ workflowOverrides: { isPersisted: false } })
expect(screen.queryByTestId('job-state-indicator')).toBeNull()
const dot = screen.getByTestId('unsaved-indicator')
expect(dot.textContent).toBe('•')
})
it('does not show job indicator on active tab', () => {
mockWorkflowStatus.value = new Map([['/workflows/test.json', 'completed']])
renderTab({ activeWorkflowKey: 'test-key' })
expect(screen.queryByTestId('job-state-indicator')).toBeNull()
})
it('job state replaces unsaved dot', () => {
mockWorkflowStatus.value = new Map([['/workflows/test.json', 'running']])
renderTab({ workflowOverrides: { isPersisted: false } })
const indicator = screen.getByTestId('job-state-indicator')
expect(indicator.getAttribute('data-state')).toBe('running')
})
it('delegates close to workflow service with the tab workflow', async () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByTestId('close-workflow-button'))
expect(mockCloseWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ key: 'test-key' }),
expect.anything()
)
})
})

View File

@@ -21,27 +21,8 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<i
v-if="jobState"
data-testid="job-state-indicator"
role="status"
:data-state="jobState"
:aria-label="jobStateLabel"
:class="
cn(
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
jobState === 'running' &&
'icon-[lucide--loader-circle] animate-spin text-muted-foreground',
jobState === 'completed' &&
'icon-[lucide--circle-check] text-success-background',
jobState === 'failed' &&
'icon-[lucide--octagon-alert] text-destructive-background'
)
"
/>
<span
v-else-if="shouldShowStatusIndicator"
data-testid="unsaved-indicator"
v-if="shouldShowStatusIndicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
>
@@ -50,7 +31,6 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
data-testid="close-workflow-button"
@click.stop="onCloseWorkflow(workflowOption)"
>
<i class="pi pi-times" />
@@ -104,11 +84,8 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import type { WorkflowStatus } from '@/stores/executionStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import { cn } from '@/utils/tailwindUtil'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -135,7 +112,6 @@ const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
@@ -183,22 +159,6 @@ const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const jobState = computed(() => {
const path = props.workflowOption.workflow.path
if (!path || isActiveTab.value) return null
return executionStore.workflowStatus.get(path) ?? null
})
const jobStateI18nKeys: Record<WorkflowStatus, string> = {
running: 'g.running',
completed: 'g.completed',
failed: 'g.failed'
}
const jobStateLabel = computed(() =>
jobState.value ? t(jobStateI18nKeys[jobState.value]) : undefined
)
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})

View File

@@ -1,86 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Button from './Button.vue'
describe('Button', () => {
it('renders slot content inside a button by default', () => {
render(Button, {
slots: { default: 'Click me' }
})
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('fires click events when enabled', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(Button, {
slots: { default: 'Click me' },
attrs: { onClick }
})
await user.click(screen.getByRole('button', { name: 'Click me' }))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('hides slot content, shows a spinner, and disables the button while loading', () => {
const { container } = render(Button, {
props: { loading: true },
slots: { default: 'Submit' }
})
expect(screen.queryByText('Submit')).not.toBeInTheDocument()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue spinner icon has no accessible role
expect(container.querySelector('.pi-spin')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeDisabled()
})
it('does not fire click when loading', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(Button, {
props: { loading: true },
attrs: { onClick }
})
await user.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
it('disables the button when disabled prop is true', () => {
render(Button, {
props: { disabled: true },
slots: { default: 'Nope' }
})
expect(screen.getByRole('button', { name: 'Nope' })).toBeDisabled()
})
it('renders as an anchor when as="a"', () => {
const { container } = render(Button, {
props: { as: 'a' },
slots: { default: 'Link' }
})
// eslint-disable-next-line testing-library/no-node-access -- root element tag is the contract under test
const root = container.firstElementChild
expect(root?.tagName).toBe('A')
})
it('applies variant classes through buttonVariants', () => {
render(Button, {
props: { variant: 'primary' },
slots: { default: 'Primary' }
})
expect(screen.getByRole('button', { name: 'Primary' })).toHaveClass(
'bg-primary-background'
)
})
})

View File

@@ -1,141 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import Slider from './Slider.vue'
async function flush() {
await nextTick()
await nextTick()
}
describe('Slider', () => {
it('renders a single thumb with role="slider" for a single-value model', async () => {
render(Slider, { props: { modelValue: [50] } })
await flush()
const thumbs = screen.getAllByRole('slider')
expect(thumbs).toHaveLength(1)
})
it('renders one thumb per value for a range model', async () => {
render(Slider, { props: { modelValue: [20, 50] } })
await flush()
const thumbs = screen.getAllByRole('slider')
expect(thumbs).toHaveLength(2)
})
it('exposes min/max/step via ARIA on the thumb', async () => {
render(Slider, {
props: { modelValue: [10], min: 0, max: 200, step: 5 }
})
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemin', '0')
expect(thumb).toHaveAttribute('aria-valuemax', '200')
expect(thumb).toHaveAttribute('aria-valuenow', '10')
})
it('emits update:modelValue with an increased value on ArrowRight', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalled()
const latest = onUpdate.mock.calls.at(-1)?.[0]
expect(latest?.[0]).toBeGreaterThan(50)
})
it('emits update:modelValue with a decreased value on ArrowLeft', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowLeft}')
expect(onUpdate).toHaveBeenCalled()
const latest = onUpdate.mock.calls.at(-1)?.[0]
expect(latest?.[0]).toBeLessThan(50)
})
it('respects step size when emitting updates', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 10,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalledWith([60])
})
it('marks the root as disabled when disabled prop is set', async () => {
const { container } = render(Slider, {
props: { modelValue: [30], disabled: true }
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Reka exposes disabled state as a data attribute on the root
const root = container.querySelector('[data-slot="slider"]')
expect(root).toHaveAttribute('data-disabled')
})
it('does not emit updates via keyboard when disabled', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
disabled: true,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).not.toHaveBeenCalled()
})
})

View File

@@ -1,71 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Textarea from './Textarea.vue'
describe('Textarea', () => {
it('renders a textarea element', () => {
render(Textarea)
expect(screen.getByRole('textbox')).toBeInstanceOf(HTMLTextAreaElement)
})
it('populates the textarea with the initial v-model value', () => {
render(Textarea, { props: { modelValue: 'initial text' } })
expect(screen.getByRole('textbox')).toHaveValue('initial text')
})
it('emits update:modelValue as the user types', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: string | number | undefined) => void>()
render(Textarea, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
}
})
await user.type(screen.getByRole('textbox'), 'hi')
expect(onUpdate).toHaveBeenCalled()
expect(onUpdate.mock.calls.at(-1)?.[0]).toBe('hi')
})
it('forwards placeholder and rows attrs to the native textarea', () => {
render(Textarea, {
attrs: { placeholder: 'Write something', rows: 6 }
})
const textarea = screen.getByPlaceholderText('Write something')
expect(textarea).toHaveAttribute('rows', '6')
})
it('does not accept typed input when disabled', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Textarea, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
},
attrs: { disabled: true }
})
const textarea = screen.getByRole('textbox')
expect(textarea).toBeDisabled()
await user.type(textarea, 'blocked')
expect(onUpdate).not.toHaveBeenCalled()
expect(textarea).toHaveValue('')
})
it('forwards custom class alongside internal classes', () => {
render(Textarea, { props: { class: 'custom-extra-class' } })
expect(screen.getByRole('textbox')).toHaveClass('custom-extra-class')
})
})

View File

@@ -131,8 +131,8 @@ import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'

View File

@@ -7,9 +7,9 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'

View File

@@ -43,9 +43,11 @@ describe('useExternalLink', () => {
// Static URLs
expect(staticUrls.discord).toBe('https://www.comfy.org/discord')
expect(staticUrls.github).toBe('https://github.com/Comfy-Org/ComfyUI')
expect(staticUrls.github).toBe(
'https://github.com/comfyanonymous/ComfyUI'
)
expect(staticUrls.githubIssues).toBe(
'https://github.com/Comfy-Org/ComfyUI/issues'
'https://github.com/comfyanonymous/ComfyUI/issues'
)
expect(staticUrls.githubFrontend).toBe(
'https://github.com/Comfy-Org/ComfyUI_frontend'

View File

@@ -85,8 +85,8 @@ export function useExternalLink() {
const staticUrls = {
// Static external URLs
discord: 'https://www.comfy.org/discord',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues',
github: 'https://github.com/comfyanonymous/ComfyUI',
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
githubElectron: 'https://github.com/Comfy-Org/electron',
forum: 'https://forum.comfy.org/',

View File

@@ -146,12 +146,6 @@ describe('useLoad3d', () => {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
setGizmoEnabled: vi.fn(),
setGizmoMode: vi.fn(),
resetGizmoTransform: vi.fn(),
applyGizmoTransform: vi.fn(),
fitToViewer: vi.fn(),
setAnimationTime: vi.fn(),
renderer: {
domElement: mockCanvas
} as Partial<Load3d['renderer']> as Load3d['renderer']
@@ -175,6 +169,38 @@ describe('useLoad3d', () => {
})
describe('initialization', () => {
it('should initialize with default values', () => {
const composable = useLoad3d(mockNode)
expect(composable.sceneConfig.value).toEqual({
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
})
expect(composable.modelConfig.value).toEqual({
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
})
expect(composable.cameraConfig.value).toEqual({
cameraType: 'perspective',
fov: 75
})
expect(composable.lightConfig.value).toEqual({
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
})
expect(composable.isRecording.value).toBe(false)
expect(composable.hasRecording.value).toBe(false)
expect(composable.loading.value).toBe(false)
})
it('should initialize Load3d with container and node', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -203,6 +229,8 @@ describe('useLoad3d', () => {
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true)
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000')
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled')
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original')
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75)
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
@@ -243,29 +271,53 @@ describe('useLoad3d', () => {
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
})
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
it('should load model if model_file widget exists', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'test.glb',
type: 'text'
} as IWidget)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'subfolder',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/test.glb'
)
})
it('should restore camera config from node properties', async () => {
;(
mockNode.properties!['Camera Config'] as Record<string, unknown>
).state = {
it('should restore camera state after loading model', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'test.glb',
type: 'text'
} as IWidget)
;(mockNode.properties!['Camera Config'] as { state: unknown }).state = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
}
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'subfolder',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -273,7 +325,7 @@ describe('useLoad3d', () => {
await composable.initializeLoad3d(containerRef)
await nextTick()
expect(composable.cameraConfig.value.state).toEqual({
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
})
@@ -321,39 +373,6 @@ describe('useLoad3d', () => {
})
})
describe('preserves existing node callbacks through initializeLoad3d', () => {
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
// unregisters the component widget from the DOM widget store. If
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
// cleanup is lost and the interactive UI persists with a stale reference.
it('chains node.onRemoved with a preexisting callback', async () => {
const existingOnRemoved = vi.fn()
mockNode.onRemoved = existingOnRemoved
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onRemoved?.()
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
})
it('chains node.onResize with a preexisting callback', async () => {
const existingOnResize = vi.fn()
mockNode.onResize = existingOnResize
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onResize?.([512, 512] as Size)
expect(existingOnResize).toHaveBeenCalledTimes(1)
})
})
describe('waitForLoad3d', () => {
it('should execute callback immediately if Load3d exists', async () => {
const composable = useLoad3d(mockNode)
@@ -441,13 +460,11 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
const savedModelConfig = mockNode.properties['Model Config'] as Record<
string,
unknown
>
expect(savedModelConfig.upDirection).toBe('+y')
expect(savedModelConfig.materialMode).toBe('wireframe')
expect(savedModelConfig.showSkeleton).toBe(false)
expect(mockNode.properties['Model Config']).toEqual({
upDirection: '+y',
materialMode: 'wireframe',
showSkeleton: false
})
})
it('should update camera config when values change', async () => {
@@ -845,72 +862,79 @@ describe('useLoad3d', () => {
})
})
describe('handleModelDrop', () => {
it('should upload file, construct URL, and load model', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/uploaded/model.glb'
)
describe('getModelUrl', () => {
it('should handle http URLs directly', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'http://example.com/model.glb',
type: 'text'
} as IWidget)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/uploaded/model.glb'
'http://example.com/model.glb'
)
})
it('should use resource folder for upload subfolder', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
it('should construct URL for local files', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'models/test.glb',
type: 'text'
} as IWidget)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
'models',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
'/api/view/models/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/uploaded/model.glb'
'http://localhost/api/view/models/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb')
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'models',
'test.glb',
'input'
)
expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb')
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/models/test.glb'
)
})
it('should not load model when load3d is not initialized', async () => {
it('should use output type for preview mode', async () => {
mockNode.widgets = [
{ name: 'model_file', value: 'test.glb', type: 'text' } as IWidget
] // No width/height widgets
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dScene'
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'',
'test.glb',
'output'
)
})
})
@@ -1047,241 +1071,4 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
})
})
describe('gizmo controls', () => {
it('should include default gizmo config in modelConfig', () => {
const composable = useLoad3d(mockNode)
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
})
it('should restore gizmo config from node properties', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
})
})
it('should add default gizmo config when missing from saved config', async () => {
mockNode.properties!['Model Config'] = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo).toBeDefined()
expect(composable.modelConfig.value.gizmo!.enabled).toBe(false)
})
it('should add default scale when gizmo config lacks scale', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 }
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo!.scale).toEqual({
x: 1,
y: 1,
z: 1
})
})
it('handleToggleGizmo should enable gizmo and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true)
expect(composable.modelConfig.value.gizmo!.enabled).toBe(true)
})
it('handleToggleGizmo should disable gizmo and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleToggleGizmo(false)
expect(mockLoad3d.setGizmoEnabled).toHaveBeenLastCalledWith(false)
expect(composable.modelConfig.value.gizmo!.enabled).toBe(false)
})
it('handleSetGizmoMode should set mode and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleSetGizmoMode('rotate')
expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate')
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('handleResetGizmoTransform should call resetGizmoTransform', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleResetGizmoTransform()
expect(mockLoad3d.resetGizmoTransform).toHaveBeenCalled()
})
it('should persist gizmo config to node properties via modelConfig watcher', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
await nextTick()
const savedConfig = mockNode.properties['Model Config'] as {
gizmo: { enabled: boolean; mode: string }
}
expect(savedConfig.gizmo.enabled).toBe(true)
expect(savedConfig.gizmo.mode).toBe('rotate')
})
it('should register gizmoTransformChange event handler', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const gizmoEventCall = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)
expect(gizmoEventCall).toBeDefined()
})
it('gizmoTransformChange event should update modelConfig', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const gizmoEventCall = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)
const handler = gizmoEventCall![1] as (data: unknown) => void
handler({
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7 },
scale: { x: 3, y: 3, z: 3 },
enabled: true,
mode: 'rotate'
})
expect(composable.modelConfig.value.gizmo!.position).toEqual({
x: 5,
y: 6,
z: 7
})
expect(composable.modelConfig.value.gizmo!.rotation).toEqual({
x: 0.5,
y: 0.6,
z: 0.7
})
expect(composable.modelConfig.value.gizmo!.scale).toEqual({
x: 3,
y: 3,
z: 3
})
expect(composable.modelConfig.value.gizmo!.enabled).toBe(true)
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('should reset gizmo config on model switch (not first load)', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const loadingStartCall = addEventCalls.find(
([event]) => event === 'modelLoadingStart'
)
const loadingStartHandler = loadingStartCall![1] as () => void
const loadingEndCall = addEventCalls.find(
([event]) => event === 'modelLoadingEnd'
)
const loadingEndHandler = loadingEndCall![1] as () => void
loadingEndHandler()
loadingStartHandler()
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
})
it('should not call gizmo methods when load3d is not initialized', () => {
const composable = useLoad3d(mockNode)
// These should not throw
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
composable.handleResetGizmoTransform()
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,9 +2,8 @@ import type { MaybeRef } from 'vue'
import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { ref, toRaw, watch } from 'vue'
import { nextTick, ref, toRaw, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
@@ -17,8 +16,6 @@ import type {
CameraState,
CameraType,
EventCallback,
GizmoConfig,
GizmoMode,
LightConfig,
MaterialMode,
ModelConfig,
@@ -41,7 +38,6 @@ const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const nodeRef = toRef(nodeOrRef)
let load3d: Load3d | null = null
let isFirstModelLoad = true
const sceneConfig = ref<SceneConfig>({
showGrid: true,
@@ -53,14 +49,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const modelConfig = ref<ModelConfig>({
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
showSkeleton: false
})
const hasSkeleton = ref(false)
@@ -134,32 +123,30 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
await restoreConfigurationsFromNode(node)
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
node.onMouseEnter = function () {
load3d?.refreshViewport()
load3d?.updateStatusMouseOnNode(true)
})
}
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
node.onMouseLeave = function () {
load3d?.updateStatusMouseOnNode(false)
})
}
node.onResize = useChainCallback(node.onResize, () => {
node.onResize = function () {
load3d?.handleResize()
})
}
node.onDrawBackground = useChainCallback(
node.onDrawBackground,
function (this: LGraphNode) {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
node.onDrawBackground = function () {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
)
}
node.onRemoved = useChainCallback(node.onRemoved, () => {
node.onRemoved = function () {
useLoad3dService().removeLoad3d(node)
pendingCallbacks.delete(node)
})
}
nodeToLoad3dMap.set(node, load3d)
@@ -196,24 +183,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const savedModelConfig = node.properties['Model Config'] as ModelConfig
if (savedModelConfig) {
modelConfig.value = {
...savedModelConfig,
gizmo: savedModelConfig.gizmo
? {
...savedModelConfig.gizmo,
scale: savedModelConfig.gizmo.scale ?? { x: 1, y: 1, z: 1 }
}
: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
modelConfig.value = savedModelConfig
}
const savedCameraConfig = node.properties['Camera Config'] as CameraConfig
const cameraStateToRestore = savedCameraConfig?.state
if (savedCameraConfig) {
cameraConfig.value = savedCameraConfig
@@ -261,6 +235,31 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget?.value) {
const modelUrl = getModelUrl(modelWidget.value as string)
if (modelUrl) {
loading.value = true
loadingMessage.value = t('load3d.reloadingModel')
try {
await load3d.loadModel(modelUrl)
if (cameraStateToRestore) {
await nextTick()
load3d.setCameraState(cameraStateToRestore)
}
} catch (error) {
console.error('Failed to reload model:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
} finally {
loading.value = false
loadingMessage.value = ''
}
}
} else if (cameraStateToRestore) {
load3d.setCameraState(cameraStateToRestore)
}
applySceneConfigToLoad3d()
applyLightConfigToLoad3d()
}
@@ -277,31 +276,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const applyGizmoConfigToLoad3d = () => {
if (!load3d) return
const gizmo = modelConfig.value.gizmo
if (!gizmo) return
const hasTransform =
gizmo.position.x !== 0 ||
gizmo.position.y !== 0 ||
gizmo.position.z !== 0 ||
gizmo.rotation.x !== 0 ||
gizmo.rotation.y !== 0 ||
gizmo.rotation.z !== 0 ||
gizmo.scale.x !== 1 ||
gizmo.scale.y !== 1 ||
gizmo.scale.z !== 1
if (hasTransform) {
load3d.applyGizmoTransform(gizmo.position, gizmo.rotation, gizmo.scale)
}
if (gizmo.enabled) {
load3d.setGizmoEnabled(true)
}
if (gizmo.mode !== 'translate') {
load3d.setGizmoMode(gizmo.mode)
}
}
const applyLightConfigToLoad3d = () => {
if (!load3d) return
const cfg = lightConfig.value
@@ -320,6 +294,29 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const getModelUrl = (modelPath: string): string | null => {
if (!modelPath) return null
try {
if (modelPath.startsWith('http')) {
return modelPath
}
const trimmed = modelPath.trim()
const hasOutputSuffix = trimmed.endsWith('[output]')
const cleanPath = hasOutputSuffix
? trimmed.replace(/\s*\[output\]$/, '')
: trimmed
const type = hasOutputSuffix || isPreview.value ? 'output' : 'input'
const [subfolder, filename] = Load3dUtils.splitFilePath(cleanPath)
return api.apiURL(Load3dUtils.getResourceURL(subfolder, filename, type))
} catch (error) {
console.error('Failed to construct model URL:', error)
return null
}
}
const waitForLoad3d = (callback: Load3dReadyCallback) => {
const rawNode = toRaw(nodeRef.value)
if (!rawNode) return
@@ -383,34 +380,16 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
watch(
modelConfig,
(newValue) => {
if (nodeRef.value) {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Model Config'] = newValue
load3d.setUpDirection(newValue.upDirection)
load3d.setMaterialMode(newValue.materialMode)
load3d.setShowSkeleton(newValue.showSkeleton)
}
},
{ deep: true }
)
watch(
() => modelConfig.value.upDirection,
(newValue) => {
if (load3d) load3d.setUpDirection(newValue)
}
)
watch(
() => modelConfig.value.materialMode,
(newValue) => {
if (load3d) load3d.setMaterialMode(newValue)
}
)
watch(
() => modelConfig.value.showSkeleton,
(newValue) => {
if (load3d) load3d.setShowSkeleton(newValue)
}
)
watch(
cameraConfig,
(newValue) => {
@@ -762,20 +741,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelLoadingStart: () => {
loadingMessage.value = t('load3d.loadingModel')
loading.value = true
if (!isFirstModelLoad) {
modelConfig.value = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}
},
modelLoadingEnd: () => {
loadingMessage.value = ''
@@ -783,8 +748,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
hasSkeleton.value = load3d?.hasSkeleton() ?? false
applyGizmoConfigToLoad3d()
isFirstModelLoad = false
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
if (load3d && isAssetPreviewSupported()) {
const node = nodeRef.value
@@ -851,44 +816,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
}
},
gizmoTransformChange: (data: GizmoConfig) => {
if (modelConfig.value.gizmo && nodeRef.value) {
modelConfig.value.gizmo.position = data.position
modelConfig.value.gizmo.rotation = data.rotation
modelConfig.value.gizmo.scale = data.scale
modelConfig.value.gizmo.enabled = data.enabled
modelConfig.value.gizmo.mode = data.mode
}
}
} as const
const handleToggleGizmo = (enabled: boolean) => {
if (load3d && modelConfig.value.gizmo) {
modelConfig.value.gizmo.enabled = enabled
load3d.setGizmoEnabled(enabled)
}
}
const handleSetGizmoMode = (mode: GizmoMode) => {
if (load3d && modelConfig.value.gizmo) {
modelConfig.value.gizmo.mode = mode
load3d.setGizmoMode(mode)
}
}
const handleFitToViewer = () => {
if (load3d) {
load3d.fitToViewer()
}
}
const handleResetGizmoTransform = () => {
if (load3d) {
load3d.resetGizmoTransform()
}
}
const handleEvents = (action: 'add' | 'remove') => {
Object.entries(eventConfig).forEach(([event, handler]) => {
const method = `${action}EventListener` as const
@@ -948,10 +878,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
handleToggleGizmo,
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
cleanup
}
}

View File

@@ -110,15 +110,7 @@ describe('useLoad3dViewer', () => {
addEventListener: vi.fn(),
hasAnimations: vi.fn().mockReturnValue(false),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
setGizmoEnabled: vi.fn(),
setGizmoMode: vi.fn(),
setBackgroundRenderMode: vi.fn(),
getGizmoTransform: vi.fn().mockReturnValue({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
isPlyModel: vi.fn().mockReturnValue(false)
}
mockSourceLoad3d = {
@@ -171,6 +163,20 @@ describe('useLoad3dViewer', () => {
})
describe('initialization', () => {
it('should initialize with default values', () => {
const viewer = useLoad3dViewer(mockNode)
expect(viewer.backgroundColor.value).toBe('')
expect(viewer.showGrid.value).toBe(true)
expect(viewer.cameraType.value).toBe('perspective')
expect(viewer.fov.value).toBe(75)
expect(viewer.lightIntensity.value).toBe(1)
expect(viewer.backgroundImage.value).toBe('')
expect(viewer.hasBackgroundImage.value).toBe(false)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
})
it('should initialize viewer with source Load3d state', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -234,7 +240,104 @@ describe('useLoad3dViewer', () => {
})
})
describe('error handling', () => {
describe('state watchers', () => {
it('should update background color when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundColor.value = '#ff0000'
await nextTick()
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ff0000')
})
it('should update grid visibility when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.showGrid.value = false
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
})
it('should update camera type when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.cameraType.value = 'orthographic'
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
})
it('should update FOV when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.fov.value = 90
await nextTick()
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
})
it('should update light intensity when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.lightIntensity.value = 2
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(2)
})
it('should update background image when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundImage.value = 'new-bg.jpg'
await nextTick()
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('new-bg.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should update up direction when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.upDirection.value = '+y'
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
})
it('should update material mode when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.materialMode.value = 'wireframe'
await nextTick()
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
})
it('should handle watcher errors gracefully', async () => {
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
function () {
@@ -646,118 +749,4 @@ describe('useLoad3dViewer', () => {
expect(newViewer.backgroundColor.value).toBe('#0000ff')
})
})
describe('gizmo controls', () => {
it('should initialize gizmo state from node model config', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: true,
mode: 'rotate'
}
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(viewer.gizmoEnabled.value).toBe(true)
expect(viewer.gizmoMode.value).toBe('rotate')
})
it('should default gizmo to disabled translate when no config', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(viewer.gizmoEnabled.value).toBe(false)
expect(viewer.gizmoMode.value).toBe('translate')
})
it('should persist gizmo state in applyChanges', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
await viewer.applyChanges()
const modelConfig = mockNode.properties!['Model Config'] as Record<
string,
unknown
>
const gizmo = modelConfig.gizmo as Record<string, unknown>
expect(gizmo.enabled).toBe(true)
expect(gizmo.mode).toBe('rotate')
})
it('should save gizmo transform from load3d in applyChanges', async () => {
vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue({
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
})
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
await viewer.applyChanges()
const modelConfig = mockNode.properties!['Model Config'] as Record<
string,
unknown
>
const gizmo = modelConfig.gizmo as {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
}
expect(gizmo.position).toEqual({ x: 1, y: 2, z: 3 })
expect(gizmo.rotation).toEqual({ x: 0.1, y: 0.2, z: 0.3 })
expect(gizmo.scale).toEqual({ x: 2, y: 2, z: 2 })
})
it('should restore gizmo state in restoreInitialState', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
viewer.restoreInitialState()
const modelConfig = mockNode.properties!['Model Config'] as Record<
string,
unknown
>
const gizmo = modelConfig.gizmo as Record<string, unknown>
expect(gizmo.enabled).toBe(false)
expect(gizmo.mode).toBe('translate')
})
it('should restore gizmo state from standalone config cache', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
const model1 = 'gizmo_model1.glb'
await viewer.initializeStandaloneViewer(containerRef, model1)
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
await nextTick()
viewer.cleanup()
const restoredViewer = useLoad3dViewer()
await restoredViewer.initializeStandaloneViewer(containerRef, model1)
expect(restoredViewer.gizmoEnabled.value).toBe(true)
expect(restoredViewer.gizmoMode.value).toBe('rotate')
})
})
})

View File

@@ -9,7 +9,6 @@ import type {
CameraConfig,
CameraState,
CameraType,
GizmoMode,
LightConfig,
MaterialMode,
ModelConfig,
@@ -33,8 +32,6 @@ interface Load3dViewerState {
backgroundRenderMode: BackgroundRenderModeType
upDirection: UpDirection
materialMode: MaterialMode
gizmoEnabled: boolean
gizmoMode: GizmoMode
}
const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
@@ -47,9 +44,7 @@ const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
backgroundImage: '',
backgroundRenderMode: 'tiled',
upDirection: 'original',
materialMode: 'original',
gizmoEnabled: false,
gizmoMode: 'translate'
materialMode: 'original'
}
const standaloneConfigCache = new QuickLRU<string, Load3dViewerState>({
@@ -74,8 +69,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const backgroundRenderMode = ref<BackgroundRenderModeType>('tiled')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const gizmoEnabled = ref(false)
const gizmoMode = ref<GizmoMode>('translate')
const needApplyChanges = ref(true)
const isPreview = ref(false)
const isStandaloneMode = ref(false)
@@ -105,9 +98,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundImage: '',
backgroundRenderMode: 'tiled',
upDirection: 'original',
materialMode: 'original',
gizmoEnabled: false,
gizmoMode: 'translate'
materialMode: 'original'
})
watch(backgroundColor, (newColor) => {
@@ -282,18 +273,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
watch(gizmoEnabled, (newValue) => {
if (load3d) {
load3d.setGizmoEnabled(newValue)
}
})
watch(gizmoMode, (newValue) => {
if (load3d) {
load3d.setGizmoMode(newValue)
}
})
/**
* Initializes the viewer in node mode using a source Load3d instance.
*
@@ -388,10 +367,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
modelConfig.upDirection || source.modelManager.currentUpDirection
materialMode.value =
modelConfig.materialMode || source.modelManager.materialMode
if (modelConfig.gizmo) {
gizmoEnabled.value = modelConfig.gizmo.enabled
gizmoMode.value = modelConfig.gizmo.mode
}
}
isSplatModel.value = source.isSplatModel()
@@ -407,9 +382,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundImage: backgroundImage.value,
backgroundRenderMode: backgroundRenderMode.value,
upDirection: upDirection.value,
materialMode: materialMode.value,
gizmoEnabled: gizmoEnabled.value,
gizmoMode: gizmoMode.value
materialMode: materialMode.value
}
setupAnimationEvents()
@@ -502,9 +475,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundImage: backgroundImage.value,
backgroundRenderMode: backgroundRenderMode.value,
upDirection: upDirection.value,
materialMode: materialMode.value,
gizmoEnabled: gizmoEnabled.value,
gizmoMode: gizmoMode.value
materialMode: materialMode.value
})
}
@@ -526,8 +497,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundRenderMode.value = config.backgroundRenderMode
upDirection.value = config.upDirection
materialMode.value = config.materialMode
gizmoEnabled.value = config.gizmoEnabled
gizmoMode.value = config.gizmoMode
if (cached?.cameraState && load3d) {
load3d.setCameraState(cached.cameraState)
}
@@ -603,14 +572,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
nodeValue.properties['Model Config'] = {
upDirection: initialState.value.upDirection,
materialMode: initialState.value.materialMode,
gizmo: {
enabled: initialState.value.gizmoEnabled,
mode: initialState.value.gizmoMode,
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
materialMode: initialState.value.materialMode
}
const currentCameraConfig = nodeValue.properties['Camera Config'] as
@@ -652,18 +614,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
intensity: lightIntensity.value
}
const gizmoTransform = load3d.getGizmoTransform()
nodeValue.properties['Model Config'] = {
upDirection: upDirection.value,
materialMode: materialMode.value,
showSkeleton: false,
gizmo: {
enabled: gizmoEnabled.value,
mode: gizmoMode.value,
position: gizmoTransform.position,
rotation: gizmoTransform.rotation,
scale: gizmoTransform.scale
}
materialMode: materialMode.value
}
}
@@ -804,8 +757,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundRenderMode,
upDirection,
materialMode,
gizmoEnabled,
gizmoMode,
needApplyChanges,
isPreview,
isStandaloneMode,
@@ -833,9 +784,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
handleBackgroundImageUpdate,
handleModelDrop,
handleSeek,
resetGizmoTransform: () => {
load3d?.resetGizmoTransform()
},
cleanup,
hasSkeleton: false,

View File

@@ -323,10 +323,6 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (!(outputGroups?.[idx] == matchKey)) return
changeOutputType(this, output, outputType)
})
// Force Vue reactivity update for output slot types.
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
// so mutating output.type alone doesn't trigger re-render.
this.outputs = [...this.outputs]
app.canvas?.setDirty(true, true)
}
)

View File

@@ -190,40 +190,28 @@ export class CameraManager implements CameraManagerInterface {
}
}
setupForModel(
size: THREE.Vector3,
center: THREE.Vector3 = new THREE.Vector3(0, size.y / 2, 0)
): void {
const maxDim = Math.max(size.x, size.y, size.z)
setupForModel(size: THREE.Vector3): void {
const distance = Math.max(size.x, size.z) * 2
const height = center.y + maxDim
const height = size.y * 2
this.perspectiveCamera.position.set(
center.x + distance,
height,
center.z + distance
)
this.orthographicCamera.position.set(
center.x + distance,
height,
center.z + distance
)
this.perspectiveCamera.position.set(distance, height, distance)
this.orthographicCamera.position.set(distance, height, distance)
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.lookAt(center)
this.perspectiveCamera.lookAt(0, size.y / 2, 0)
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = maxDim * 2
const frustumSize = Math.max(size.x, size.y, size.z) * 2
const aspect = this.perspectiveCamera.aspect
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.lookAt(center)
this.orthographicCamera.lookAt(0, size.y / 2, 0)
this.orthographicCamera.updateProjectionMatrix()
}
this.controls?.target.copy(center)
this.controls?.target.set(0, size.y / 2, 0)
this.controls?.update()
}

View File

@@ -1,368 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GizmoManager } from './GizmoManager'
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn()
}))
vi.mock('three/examples/jsm/controls/TransformControls', () => {
class TransformControls {
enabled = true
camera: THREE.Camera
private listeners = new Map<string, ((e: unknown) => void)[]>()
constructor(camera: THREE.Camera) {
this.camera = camera
}
addEventListener(event: string, cb: (e: unknown) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, [])
this.listeners.get(event)!.push(cb)
}
setMode = mockSetMode
attach = mockAttach
detach = mockDetach
getHelper = mockGetHelper
dispose = mockDispose
emit(event: string, data: unknown) {
for (const cb of this.listeners.get(event) ?? []) cb(data)
}
}
return { TransformControls }
})
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
class OrbitControls {
enabled = true
}
return { OrbitControls }
})
function makeMockOrbitControls() {
return { enabled: true } as unknown as InstanceType<
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
>
}
describe('GizmoManager', () => {
let scene: THREE.Scene
let renderer: THREE.WebGLRenderer
let camera: THREE.PerspectiveCamera
let orbitControls: ReturnType<typeof makeMockOrbitControls>
let manager: GizmoManager
let onTransformChange: () => void
let mockHelper: THREE.Object3D
beforeEach(() => {
vi.clearAllMocks()
scene = new THREE.Scene()
renderer = {
domElement: document.createElement('canvas')
} as unknown as THREE.WebGLRenderer
camera = new THREE.PerspectiveCamera()
orbitControls = makeMockOrbitControls()
onTransformChange = vi.fn()
mockHelper = new THREE.Object3D()
mockHelper.name = ''
mockHelper.renderOrder = 0
mockGetHelper.mockReturnValue(mockHelper)
manager = new GizmoManager(
scene,
renderer,
orbitControls,
() => camera,
onTransformChange
)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('init', () => {
it('adds helper to scene with correct name and render order', () => {
manager.init()
expect(mockGetHelper).toHaveBeenCalled()
expect(mockHelper.name).toBe('GizmoTransformControls')
expect(mockHelper.renderOrder).toBe(999)
expect(scene.children).toContain(mockHelper)
})
})
describe('setupForModel', () => {
it('attaches to model and stores initial transform when enabled', () => {
manager.init()
manager.setEnabled(true)
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
manager.setupForModel(model)
expect(mockDetach).toHaveBeenCalled()
expect(mockAttach).toHaveBeenCalledWith(model)
expect(mockSetMode).toHaveBeenCalledWith('translate')
})
it('does not attach when disabled', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
expect(mockAttach).not.toHaveBeenCalled()
})
it('does nothing before init', () => {
const model = new THREE.Object3D()
manager.setupForModel(model)
expect(mockDetach).not.toHaveBeenCalled()
})
})
describe('setEnabled', () => {
it('attaches to target when enabled with a target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
vi.mocked(mockAttach).mockClear()
manager.setEnabled(true)
expect(mockAttach).toHaveBeenCalledWith(model)
expect(manager.isEnabled()).toBe(true)
})
it('detaches when disabled', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
vi.mocked(mockDetach).mockClear()
manager.setEnabled(false)
expect(mockDetach).toHaveBeenCalled()
expect(manager.isEnabled()).toBe(false)
})
it('does nothing before init', () => {
manager.setEnabled(true)
expect(mockAttach).not.toHaveBeenCalled()
})
})
describe('detach', () => {
it('detaches and clears target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
vi.mocked(mockDetach).mockClear()
manager.detach()
expect(mockDetach).toHaveBeenCalled()
expect(manager.isEnabled()).toBe(false)
})
})
describe('setMode / getMode', () => {
it('defaults to translate', () => {
expect(manager.getMode()).toBe('translate')
})
it('switches to rotate', () => {
manager.init()
manager.setMode('rotate')
expect(manager.getMode()).toBe('rotate')
expect(mockSetMode).toHaveBeenCalledWith('rotate')
})
it('stores mode before init', () => {
manager.setMode('rotate')
expect(manager.getMode()).toBe('rotate')
})
})
describe('reset', () => {
it('restores initial position, rotation, and scale', () => {
manager.init()
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(2, 2, 2)
manager.setupForModel(model)
model.position.set(10, 20, 30)
model.rotation.set(1, 2, 3)
model.scale.set(5, 5, 5)
manager.reset()
expect(model.position.x).toBeCloseTo(1)
expect(model.position.y).toBeCloseTo(2)
expect(model.position.z).toBeCloseTo(3)
expect(model.rotation.x).toBeCloseTo(0.1)
expect(model.rotation.y).toBeCloseTo(0.2)
expect(model.rotation.z).toBeCloseTo(0.3)
expect(model.scale.x).toBeCloseTo(2)
expect(model.scale.y).toBeCloseTo(2)
expect(model.scale.z).toBeCloseTo(2)
})
it('does nothing without a target', () => {
manager.init()
expect(() => manager.reset()).not.toThrow()
})
it('invokes onTransformChange after resetting', () => {
manager.init()
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
manager.setupForModel(model)
expect(onTransformChange).not.toHaveBeenCalled()
manager.reset()
expect(onTransformChange).toHaveBeenCalledOnce()
})
})
describe('applyTransform', () => {
it('sets position and rotation on target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.applyTransform({ x: 5, y: 6, z: 7 }, { x: 0.5, y: 0.6, z: 0.7 })
expect(model.position.x).toBeCloseTo(5)
expect(model.position.y).toBeCloseTo(6)
expect(model.position.z).toBeCloseTo(7)
expect(model.rotation.x).toBeCloseTo(0.5)
expect(model.rotation.y).toBeCloseTo(0.6)
expect(model.rotation.z).toBeCloseTo(0.7)
})
it('applies scale when provided', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.applyTransform(
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
{ x: 2, y: 3, z: 4 }
)
expect(model.scale.x).toBeCloseTo(2)
expect(model.scale.y).toBeCloseTo(3)
expect(model.scale.z).toBeCloseTo(4)
})
it('does nothing without a target', () => {
manager.init()
expect(() =>
manager.applyTransform({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0 })
).not.toThrow()
})
})
describe('getTransform', () => {
it('returns current target transform', () => {
manager.init()
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(4, 5, 6)
manager.setupForModel(model)
const transform = manager.getTransform()
expect(transform.position).toEqual({ x: 1, y: 2, z: 3 })
expect(transform.rotation.x).toBeCloseTo(0.1)
expect(transform.rotation.y).toBeCloseTo(0.2)
expect(transform.rotation.z).toBeCloseTo(0.3)
expect(transform.scale).toEqual({ x: 4, y: 5, z: 6 })
})
it('returns zero/identity when no target', () => {
const transform = manager.getTransform()
expect(transform.position).toEqual({ x: 0, y: 0, z: 0 })
expect(transform.rotation).toEqual({ x: 0, y: 0, z: 0 })
expect(transform.scale).toEqual({ x: 1, y: 1, z: 1 })
})
})
describe('removeFromScene / ensureHelperInScene', () => {
it('removes helper from scene', () => {
manager.init()
expect(scene.children).toContain(mockHelper)
manager.removeFromScene()
expect(scene.children).not.toContain(mockHelper)
})
it('restores helper to scene', () => {
manager.init()
manager.removeFromScene()
manager.ensureHelperInScene()
expect(scene.children).toContain(mockHelper)
})
})
describe('dispose', () => {
it('removes helper, detaches, and disposes controls', () => {
manager.init()
scene.add(mockHelper)
manager.dispose()
expect(mockDetach).toHaveBeenCalled()
expect(mockDispose).toHaveBeenCalled()
})
it('is safe to call before init', () => {
expect(() => manager.dispose()).not.toThrow()
})
})
describe('ensureHelperInScene', () => {
it('re-adds helper if it was removed from its parent', () => {
manager.init()
// Simulate helper being removed from scene
scene.remove(mockHelper)
expect(scene.children).not.toContain(mockHelper)
// setEnabled triggers ensureHelperInScene internally
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
expect(scene.children).toContain(mockHelper)
})
})
})

View File

@@ -1,229 +0,0 @@
import * as THREE from 'three'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode } from './interfaces'
export class GizmoManager {
private transformControls: TransformControls | null = null
private targetObject: THREE.Object3D | null = null
private initialPosition: THREE.Vector3 = new THREE.Vector3()
private initialRotation: THREE.Euler = new THREE.Euler()
private initialScale: THREE.Vector3 = new THREE.Vector3(1, 1, 1)
private enabled: boolean = false
private activeCamera: THREE.Camera
private mode: GizmoMode = 'translate'
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private orbitControls: OrbitControls
private onTransformChange?: () => void
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
orbitControls: OrbitControls,
getActiveCamera: () => THREE.Camera,
onTransformChange?: () => void
) {
this.scene = scene
this.renderer = renderer
this.orbitControls = orbitControls
this.activeCamera = getActiveCamera()
this.onTransformChange = onTransformChange
}
init(): void {
this.transformControls = new TransformControls(
this.activeCamera,
this.renderer.domElement
)
this.transformControls.addEventListener('dragging-changed', (event) => {
this.orbitControls.enabled = !event.value
if (!event.value && this.onTransformChange) {
this.onTransformChange()
}
})
const helper = this.transformControls.getHelper()
helper.name = 'GizmoTransformControls'
helper.renderOrder = 999
this.scene.add(helper)
}
setupForModel(model: THREE.Object3D): void {
if (!this.transformControls) return
this.ensureHelperInScene()
this.transformControls.detach()
this.transformControls.enabled = false
this.targetObject = model
this.initialPosition.copy(model.position)
this.initialRotation.copy(model.rotation)
this.initialScale.copy(model.scale)
if (this.enabled) {
this.transformControls.attach(model)
this.transformControls.setMode(this.mode)
this.transformControls.enabled = true
}
}
detach(): void {
this.enabled = false
if (this.transformControls) {
this.transformControls.detach()
this.transformControls.enabled = false
}
this.targetObject = null
}
setEnabled(enabled: boolean): void {
this.enabled = enabled
if (!this.transformControls) return
this.ensureHelperInScene()
if (enabled && this.targetObject) {
this.transformControls.attach(this.targetObject)
this.transformControls.setMode(this.mode)
this.transformControls.enabled = true
} else {
this.transformControls.detach()
this.transformControls.enabled = false
}
}
ensureHelperInScene(): void {
if (!this.transformControls) return
const helper = this.transformControls.getHelper()
if (!helper.parent) {
this.scene.add(helper)
}
}
removeFromScene(): void {
if (!this.transformControls) return
const helper = this.transformControls.getHelper()
if (helper.parent) {
helper.parent.remove(helper)
}
}
isEnabled(): boolean {
return this.enabled
}
updateCamera(camera: THREE.Camera): void {
this.activeCamera = camera
if (this.transformControls) {
this.transformControls.camera = camera
}
}
setMode(mode: GizmoMode): void {
this.mode = mode
if (this.transformControls) {
this.transformControls.setMode(mode)
}
}
getMode(): GizmoMode {
return this.mode
}
reset(): void {
if (!this.targetObject) return
this.targetObject.position.copy(this.initialPosition)
this.targetObject.rotation.copy(this.initialRotation)
this.targetObject.scale.copy(this.initialScale)
this.onTransformChange?.()
}
applyTransform(
position: { x: number; y: number; z: number },
rotation: { x: number; y: number; z: number },
scale?: { x: number; y: number; z: number }
): void {
if (!this.targetObject) return
this.targetObject.position.set(position.x, position.y, position.z)
this.targetObject.rotation.set(rotation.x, rotation.y, rotation.z)
if (scale) {
this.targetObject.scale.set(scale.x, scale.y, scale.z)
}
}
getInitialTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
} {
return {
position: {
x: this.initialPosition.x,
y: this.initialPosition.y,
z: this.initialPosition.z
},
rotation: {
x: this.initialRotation.x,
y: this.initialRotation.y,
z: this.initialRotation.z
},
scale: {
x: this.initialScale.x,
y: this.initialScale.y,
z: this.initialScale.z
}
}
}
getTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
} {
if (!this.targetObject) {
return {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
return {
position: {
x: this.targetObject.position.x,
y: this.targetObject.position.y,
z: this.targetObject.position.z
},
rotation: {
x: this.targetObject.rotation.x,
y: this.targetObject.rotation.y,
z: this.targetObject.rotation.z
},
scale: {
x: this.targetObject.scale.x,
y: this.targetObject.scale.y,
z: this.targetObject.scale.z
}
}
}
dispose(): void {
if (this.transformControls) {
const helper = this.transformControls.getHelper()
this.scene.remove(helper)
this.transformControls.detach()
this.transformControls.dispose()
this.transformControls = null
}
this.targetObject = null
}
}

View File

@@ -1,164 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type {
GizmoConfig,
ModelConfig
} from '@/extensions/core/load3d/interfaces'
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (p: string) => p,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchCustomEvent: vi.fn(),
fetchApi: vi.fn(),
getSystemStats: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: { extra: {} } }
}))
vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: class {} }))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn()
}
}))
type WithPrivate = { loadModelConfig(): ModelConfig }
function createConfig(properties?: Dictionary<NodeProperty | undefined>) {
const load3d = {} as Load3d
return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate
}
const defaultGizmo: GizmoConfig = {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
describe('Load3DConfiguration.loadModelConfig', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns full defaults including gizmo when no properties are provided', () => {
const result = createConfig().loadModelConfig()
expect(result).toEqual({
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: defaultGizmo
})
})
it('returns full defaults when properties do not contain Model Config', () => {
const result = createConfig({ 'Other Key': 'x' }).loadModelConfig()
expect(result.gizmo).toEqual(defaultGizmo)
})
it('adds default gizmo when Model Config exists but has no gizmo field', () => {
const stored: ModelConfig = {
upDirection: '+y',
materialMode: 'wireframe',
showSkeleton: true
}
const properties = { 'Model Config': stored } as Dictionary<
NodeProperty | undefined
>
const result = createConfig(properties).loadModelConfig()
expect(result.upDirection).toBe('+y')
expect(result.materialMode).toBe('wireframe')
expect(result.showSkeleton).toBe(true)
expect(result.gizmo).toEqual(defaultGizmo)
})
it('mutates the original Model Config property to persist gizmo defaults', () => {
const stored: ModelConfig = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
}
const properties = { 'Model Config': stored } as Dictionary<
NodeProperty | undefined
>
createConfig(properties).loadModelConfig()
expect((properties['Model Config'] as ModelConfig).gizmo).toEqual(
defaultGizmo
)
})
it('backfills scale on legacy gizmo config missing the scale field', () => {
const legacyGizmo = {
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 }
} as unknown as GizmoConfig
const stored: ModelConfig = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: legacyGizmo
}
const properties = { 'Model Config': stored } as Dictionary<
NodeProperty | undefined
>
const result = createConfig(properties).loadModelConfig()
expect(result.gizmo).toEqual({
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 1, y: 1, z: 1 }
})
})
it('preserves a fully populated gizmo config unchanged', () => {
const fullGizmo: GizmoConfig = {
enabled: true,
mode: 'scale',
position: { x: 5, y: 6, z: 7 },
rotation: { x: 1, y: 2, z: 3 },
scale: { x: 2, y: 2, z: 2 }
}
const stored: ModelConfig = {
upDirection: '-z',
materialMode: 'normal',
showSkeleton: false,
gizmo: fullGizmo
}
const properties = { 'Model Config': stored } as Dictionary<
NodeProperty | undefined
>
const result = createConfig(properties).loadModelConfig()
expect(result.gizmo).toEqual(fullGizmo)
})
})

View File

@@ -167,32 +167,13 @@ class Load3DConfiguration {
private loadModelConfig(): ModelConfig {
if (this.properties && 'Model Config' in this.properties) {
const config = this.properties['Model Config'] as ModelConfig
if (!config.gizmo) {
config.gizmo = {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
} else if (!config.gizmo.scale) {
config.gizmo.scale = { x: 1, y: 1, z: 1 }
}
return config
return this.properties['Model Config'] as ModelConfig
}
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
showSkeleton: false
}
}

View File

@@ -1,269 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Load3d from '@/extensions/core/load3d/Load3d'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
type GizmoStub = {
setEnabled: ReturnType<typeof vi.fn>
setMode: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
applyTransform: ReturnType<typeof vi.fn>
getTransform: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
updateCamera: ReturnType<typeof vi.fn>
detach: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
removeFromScene: ReturnType<typeof vi.fn>
ensureHelperInScene: ReturnType<typeof vi.fn>
isEnabled: ReturnType<typeof vi.fn>
getMode: ReturnType<typeof vi.fn>
}
type ModelManagerStub = {
fitToViewer: ReturnType<typeof vi.fn>
clearModel: ReturnType<typeof vi.fn>
}
type CameraManagerStub = {
toggleCamera: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
type SceneManagerStub = {
captureScene: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
}
type Load3dPrivate = {
setGizmo(model: THREE.Object3D): void
setupCamera(size: THREE.Vector3, center: THREE.Vector3): void
}
function makeGizmoStub(): GizmoStub {
return {
setEnabled: vi.fn(),
setMode: vi.fn(),
reset: vi.fn(),
applyTransform: vi.fn(),
getTransform: vi.fn(() => ({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})),
setupForModel: vi.fn(),
updateCamera: vi.fn(),
detach: vi.fn(),
dispose: vi.fn(),
removeFromScene: vi.fn(),
ensureHelperInScene: vi.fn(),
isEnabled: vi.fn(() => false),
getMode: vi.fn(() => 'translate')
}
}
function makeInstance() {
const gizmo = makeGizmoStub()
const modelManager: ModelManagerStub = {
fitToViewer: vi.fn(),
clearModel: vi.fn()
}
const cameraManager: CameraManagerStub = {
toggleCamera: vi.fn(),
setupForModel: vi.fn(),
reset: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneManagerStub = {
captureScene: vi.fn(),
dispose: vi.fn()
}
const controlsManager = { updateCamera: vi.fn() }
const viewHelperManager = { recreateViewHelper: vi.fn() }
const animationManager = { dispose: vi.fn() }
// Load3d's constructor instantiates THREE.WebGLRenderer, ResizeObserver
// and ViewHelper, none of which are available in happy-dom. Skip it and
// inject stubs directly onto the prototype instance so delegation methods
// can be exercised in isolation.
const load3d = Object.create(Load3d.prototype) as Load3d
Object.assign(load3d, {
gizmoManager: gizmo,
modelManager,
cameraManager,
sceneManager,
controlsManager,
viewHelperManager,
animationManager,
forceRender: vi.fn(),
handleResize: vi.fn()
})
return {
load3d,
gizmo,
modelManager,
cameraManager,
sceneManager,
controlsManager,
viewHelperManager,
animationManager,
forceRender: load3d.forceRender as ReturnType<typeof vi.fn>
}
}
describe('Load3d', () => {
let ctx: ReturnType<typeof makeInstance>
beforeEach(() => {
ctx = makeInstance()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('gizmo delegation', () => {
it('getGizmoManager returns the underlying manager', () => {
expect(ctx.load3d.getGizmoManager()).toBe(ctx.gizmo)
})
it('setGizmoEnabled delegates to gizmoManager.setEnabled and forces a render', () => {
ctx.load3d.setGizmoEnabled(true)
expect(ctx.gizmo.setEnabled).toHaveBeenCalledWith(true)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it.each(['translate', 'rotate', 'scale'] as const)(
'setGizmoMode delegates "%s" and forces a render',
(mode: GizmoMode) => {
ctx.load3d.setGizmoMode(mode)
expect(ctx.gizmo.setMode).toHaveBeenCalledWith(mode)
expect(ctx.forceRender).toHaveBeenCalledOnce()
}
)
it('resetGizmoTransform delegates to gizmoManager.reset and forces a render', () => {
ctx.load3d.resetGizmoTransform()
expect(ctx.gizmo.reset).toHaveBeenCalledOnce()
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('applyGizmoTransform forwards position, rotation and scale', () => {
const pos = { x: 1, y: 2, z: 3 }
const rot = { x: 0.1, y: 0.2, z: 0.3 }
const scale = { x: 2, y: 2, z: 2 }
ctx.load3d.applyGizmoTransform(pos, rot, scale)
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, scale)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('applyGizmoTransform forwards undefined scale when not provided', () => {
const pos = { x: 0, y: 0, z: 0 }
const rot = { x: 0, y: 0, z: 0 }
ctx.load3d.applyGizmoTransform(pos, rot)
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
})
it('getGizmoTransform returns the gizmoManager transform', () => {
const transform = {
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
ctx.gizmo.getTransform.mockReturnValue(transform)
expect(ctx.load3d.getGizmoTransform()).toEqual(transform)
})
it('fitToViewer delegates to modelManager and forces a render', () => {
ctx.load3d.fitToViewer()
expect(ctx.modelManager.fitToViewer).toHaveBeenCalledOnce()
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
})
describe('lifecycle interactions', () => {
it('clearModel detaches the gizmo before clearing the model', () => {
const order: string[] = []
ctx.animationManager.dispose.mockImplementation(() =>
order.push('animation')
)
ctx.gizmo.detach.mockImplementation(() => order.push('detach'))
ctx.modelManager.clearModel.mockImplementation(() => order.push('clear'))
ctx.load3d.clearModel()
expect(order).toEqual(['animation', 'detach', 'clear'])
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('toggleCamera updates both controls and gizmo with the active camera', () => {
ctx.load3d.toggleCamera('orthographic')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.gizmo.updateCamera).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
})
it('setGizmo (private) forwards the model to gizmoManager.setupForModel', () => {
const model = new THREE.Object3D()
;(ctx.load3d as unknown as Load3dPrivate).setGizmo(model)
expect(ctx.gizmo.setupForModel).toHaveBeenCalledWith(model)
})
it('setupCamera (private) forwards size and center to cameraManager', () => {
const size = new THREE.Vector3(1, 2, 3)
const center = new THREE.Vector3(4, 5, 6)
;(ctx.load3d as unknown as Load3dPrivate).setupCamera(size, center)
expect(ctx.cameraManager.setupForModel).toHaveBeenCalledWith(size, center)
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
ctx.sceneManager.captureScene.mockResolvedValue(captureResult)
const result = await ctx.load3d.captureScene(100, 200)
expect(ctx.gizmo.removeFromScene).toHaveBeenCalledBefore(
ctx.sceneManager.captureScene
)
expect(ctx.sceneManager.captureScene).toHaveBeenCalledWith(100, 200)
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
expect(result).toBe(captureResult)
})
it('restores the gizmo helper even when capture fails', async () => {
const err = new Error('capture failed')
ctx.sceneManager.captureScene.mockRejectedValue(err)
await expect(ctx.load3d.captureScene(100, 200)).rejects.toBe(err)
expect(ctx.gizmo.removeFromScene).toHaveBeenCalledOnce()
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
})
})
})

View File

@@ -7,7 +7,6 @@ import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { HDRIManager } from './HDRIManager'
import { GizmoManager } from './GizmoManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
@@ -15,14 +14,13 @@ import { RecordingManager } from './RecordingManager'
import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import type {
CameraState,
CaptureResult,
EventCallback,
GizmoMode,
Load3DOptions,
MaterialMode,
UpDirection
import {
type CameraState,
type CaptureResult,
type EventCallback,
type Load3DOptions,
type MaterialMode,
type UpDirection
} from './interfaces'
function positionThumbnailCamera(
@@ -63,7 +61,6 @@ class Load3d {
modelManager: SceneModelManager
recordingManager: RecordingManager
animationManager: AnimationManager
gizmoManager: GizmoManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
@@ -149,8 +146,7 @@ class Load3d {
this.renderer,
this.eventManager,
this.getActiveCamera.bind(this),
this.setupCamera.bind(this),
this.setGizmo.bind(this)
this.setupCamera.bind(this)
)
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
@@ -162,29 +158,12 @@ class Load3d {
)
this.animationManager = new AnimationManager(this.eventManager)
this.gizmoManager = new GizmoManager(
this.sceneManager.scene,
this.renderer,
this.controlsManager.controls,
this.getActiveCamera.bind(this),
() => {
const transform = this.gizmoManager.getTransform()
this.eventManager.emitEvent('gizmoTransformChange', {
...transform,
enabled: this.gizmoManager.isEnabled(),
mode: this.gizmoManager.getMode()
})
}
)
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
@@ -308,10 +287,6 @@ class Load3d {
return this.recordingManager
}
getGizmoManager(): GizmoManager {
return this.gizmoManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
@@ -413,12 +388,8 @@ class Load3d {
return this.controlsManager.controls
}
private setGizmo(model: THREE.Object3D): void {
this.gizmoManager.setupForModel(model)
}
private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void {
this.cameraManager.setupForModel(size, center)
private setupCamera(size: THREE.Vector3): void {
this.cameraManager.setupForModel(size)
}
private startAnimation(): void {
@@ -580,7 +551,6 @@ class Load3d {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
this.viewHelperManager.recreateViewHelper()
this.handleResize()
@@ -631,7 +601,6 @@ class Load3d {
): Promise<void> {
this.cameraManager.reset()
this.controlsManager.reset()
this.gizmoManager.detach()
this.modelManager.clearModel()
this.animationManager.dispose()
@@ -660,7 +629,6 @@ class Load3d {
clearModel(): void {
this.animationManager.dispose()
this.gizmoManager.detach()
this.modelManager.clearModel()
this.forceRender()
}
@@ -768,11 +736,7 @@ class Load3d {
}
captureScene(width: number, height: number): Promise<CaptureResult> {
this.gizmoManager.removeFromScene()
return this.sceneManager.captureScene(width, height).finally(() => {
this.gizmoManager.ensureHelperInScene()
})
return this.sceneManager.captureScene(width, height)
}
public async startRecording(): Promise<void> {
@@ -889,7 +853,7 @@ class Load3d {
this.controlsManager.controls.update()
}
const result = await this.captureScene(width, height)
const result = await this.sceneManager.captureScene(width, height)
return result.scene
} finally {
this.sceneManager.gridHelper.visible = savedGridVisible
@@ -902,43 +866,6 @@ class Load3d {
}
}
public setGizmoEnabled(enabled: boolean): void {
this.gizmoManager.setEnabled(enabled)
this.forceRender()
}
public setGizmoMode(mode: GizmoMode): void {
this.gizmoManager.setMode(mode)
this.forceRender()
}
public resetGizmoTransform(): void {
this.gizmoManager.reset()
this.forceRender()
}
public applyGizmoTransform(
position: { x: number; y: number; z: number },
rotation: { x: number; y: number; z: number },
scale?: { x: number; y: number; z: number }
): void {
this.gizmoManager.applyTransform(position, rotation, scale)
this.forceRender()
}
public getGizmoTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
} {
return this.gizmoManager.getTransform()
}
public fitToViewer(): void {
this.modelManager.fitToViewer()
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
@@ -972,7 +899,6 @@ class Load3d {
this.modelManager.dispose()
this.recordingManager.dispose()
this.animationManager.dispose()
this.gizmoManager.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()

View File

@@ -9,10 +9,10 @@ import {
} from './interfaces'
export class SceneManager implements SceneManagerInterface {
scene!: THREE.Scene
scene: THREE.Scene
gridHelper: THREE.GridHelper
backgroundScene!: THREE.Scene
backgroundScene: THREE.Scene
backgroundCamera: THREE.OrthographicCamera
backgroundMesh: THREE.Mesh | null = null
backgroundTexture: THREE.Texture | null = null
@@ -38,8 +38,6 @@ export class SceneManager implements SceneManagerInterface {
this.eventManager = eventManager
this.scene = new THREE.Scene()
this.scene.name = 'MainScene'
this.getActiveCamera = getActiveCamera
this.gridHelper = new THREE.GridHelper(20, 20)
@@ -47,7 +45,6 @@ export class SceneManager implements SceneManagerInterface {
this.scene.add(this.gridHelper)
this.backgroundScene = new THREE.Scene()
this.backgroundScene.name = 'BackgroundScene'
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
this.initBackgroundScene()
@@ -96,8 +93,6 @@ export class SceneManager implements SceneManagerInterface {
this.scene.background = null
}
this.backgroundScene.clear()
this.scene.clear()
}

View File

@@ -1,699 +0,0 @@
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import type { EventManagerInterface } from './interfaces'
import { SceneModelManager } from './SceneModelManager'
function createMockRenderer(): THREE.WebGLRenderer {
return {
outputColorSpace: THREE.SRGBColorSpace,
dispose: vi.fn()
} as unknown as THREE.WebGLRenderer
}
function createMockEventManager(): EventManagerInterface {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
function createManager(
overrides: {
scene?: THREE.Scene
eventManager?: EventManagerInterface
} = {}
) {
const scene = overrides.scene ?? new THREE.Scene()
const renderer = createMockRenderer()
const eventManager = overrides.eventManager ?? createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const getActiveCamera = () => camera
const setupCamera = vi.fn()
const setupGizmo = vi.fn()
const manager = new SceneModelManager(
scene,
renderer,
eventManager,
getActiveCamera,
setupCamera,
setupGizmo
)
return {
manager,
scene,
renderer,
eventManager,
camera,
setupCamera,
setupGizmo
}
}
function createMeshModel(name = 'TestModel'): THREE.Group {
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
const mesh = new THREE.Mesh(geometry, material)
const group = new THREE.Group()
group.name = name
group.add(mesh)
return group
}
describe('SceneModelManager', () => {
describe('constructor', () => {
it('initializes default state', () => {
const { manager } = createManager()
expect(manager.currentModel).toBeNull()
expect(manager.originalModel).toBeNull()
expect(manager.originalRotation).toBeNull()
expect(manager.currentUpDirection).toBe('original')
expect(manager.materialMode).toBe('original')
expect(manager.originalFileName).toBeNull()
expect(manager.originalURL).toBeNull()
expect(manager.appliedTexture).toBeNull()
expect(manager.skeletonHelper).toBeNull()
expect(manager.showSkeleton).toBe(false)
})
it('creates material instances', () => {
const { manager } = createManager()
expect(manager.normalMaterial).toBeInstanceOf(THREE.MeshNormalMaterial)
expect(manager.wireframeMaterial).toBeInstanceOf(THREE.MeshBasicMaterial)
expect(manager.wireframeMaterial.wireframe).toBe(true)
expect(manager.depthMaterial).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(manager.standardMaterial).toBeInstanceOf(
THREE.MeshStandardMaterial
)
})
})
describe('dispose', () => {
it('disposes all materials', () => {
const { manager } = createManager()
const normalDispose = vi.spyOn(manager.normalMaterial, 'dispose')
const standardDispose = vi.spyOn(manager.standardMaterial, 'dispose')
const wireframeDispose = vi.spyOn(manager.wireframeMaterial, 'dispose')
const depthDispose = vi.spyOn(manager.depthMaterial, 'dispose')
manager.dispose()
expect(normalDispose).toHaveBeenCalled()
expect(standardDispose).toHaveBeenCalled()
expect(wireframeDispose).toHaveBeenCalled()
expect(depthDispose).toHaveBeenCalled()
})
it('disposes applied texture', () => {
const { manager } = createManager()
const texture = new THREE.Texture()
const textureDispose = vi.spyOn(texture, 'dispose')
manager.appliedTexture = texture
manager.dispose()
expect(textureDispose).toHaveBeenCalled()
expect(manager.appliedTexture).toBeNull()
})
})
describe('createSTLMaterial', () => {
it('returns a MeshStandardMaterial with expected properties', () => {
const { manager } = createManager()
const mat = manager.createSTLMaterial()
expect(mat).toBeInstanceOf(THREE.MeshStandardMaterial)
expect(mat.color.getHex()).toBe(0x808080)
expect(mat.metalness).toBe(0.1)
expect(mat.roughness).toBe(0.8)
expect(mat.side).toBe(THREE.DoubleSide)
})
})
describe('addModelToScene', () => {
it('adds the model to the scene and sets currentModel', () => {
const { manager, scene } = createManager()
const model = createMeshModel()
manager.addModelToScene(model)
expect(manager.currentModel).toBe(model)
expect(model.name).toBe('MainModel')
expect(scene.children).toContain(model)
})
})
describe('setupModel', () => {
it('scales and positions the model, then adds to scene', async () => {
const { manager, scene, setupCamera } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.currentModel).toBe(model)
expect(model.name).toBe('MainModel')
expect(scene.children).toContain(model)
expect(setupCamera).toHaveBeenCalled()
})
it('does not skip materialMode when it differs from original', async () => {
const { manager } = createManager()
const model = createMeshModel()
// setupModel checks materialMode !== 'original' and calls
// setMaterialMode, but the guard `mode === this.materialMode`
// causes it to no-op. Then setupModelMaterials resets to 'original'.
manager.materialMode = 'wireframe'
const spy = vi.spyOn(manager, 'setMaterialMode')
await manager.setupModel(model)
// setMaterialMode is called with the stored mode and then 'original'
expect(spy).toHaveBeenCalledWith('wireframe')
expect(spy).toHaveBeenCalledWith('original')
})
it('applies current up direction if not original', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
manager.currentUpDirection = '+z'
await manager.setupModel(model)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'+z'
)
})
})
describe('setOriginalModel', () => {
it('stores the original model reference', () => {
const { manager } = createManager()
const model = new THREE.Group()
manager.setOriginalModel(model)
expect(manager.originalModel).toBe(model)
})
})
describe('clearModel', () => {
it('removes non-environment objects from scene', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const light = new THREE.DirectionalLight()
scene.add(light)
manager.clearModel()
expect(manager.currentModel).toBeNull()
expect(scene.children).toContain(light)
})
it('disposes mesh geometry and materials', async () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const geoDispose = vi.spyOn(mesh.geometry, 'dispose')
const matDispose = vi.spyOn(mesh.material as THREE.Material, 'dispose')
await manager.setupModel(model)
manager.clearModel()
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
})
describe('reset', () => {
it('resets all state to defaults', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.originalFileName = 'test.glb'
manager.originalURL = 'http://example.com/test.glb'
manager.originalModel = model
manager.reset()
expect(manager.currentModel).toBeNull()
expect(manager.originalModel).toBeNull()
expect(manager.originalRotation).toBeNull()
expect(manager.currentUpDirection).toBe('original')
expect(manager.originalFileName).toBeNull()
expect(manager.originalURL).toBeNull()
})
it('disposes applied texture', () => {
const { manager } = createManager()
const texture = new THREE.Texture()
const textureDispose = vi.spyOn(texture, 'dispose')
manager.appliedTexture = texture
manager.reset()
expect(textureDispose).toHaveBeenCalled()
expect(manager.appliedTexture).toBeNull()
})
it('removes and disposes skeleton helper', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const mockHelper = new THREE.SkeletonHelper(model)
const helperDispose = vi.spyOn(mockHelper, 'dispose')
manager.skeletonHelper = mockHelper
scene.add(mockHelper)
manager.reset()
expect(helperDispose).toHaveBeenCalled()
expect(manager.skeletonHelper).toBeNull()
expect(manager.showSkeleton).toBe(false)
})
})
describe('setMaterialMode', () => {
it('does nothing when no current model', () => {
const { manager, eventManager } = createManager()
manager.setMaterialMode('normal')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('does nothing when mode is unchanged', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
vi.mocked(eventManager.emitEvent).mockClear()
manager.setMaterialMode('original')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('switches to normal material', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('normal')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshNormalMaterial)
expect(manager.materialMode).toBe('normal')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'normal'
)
})
it('switches to wireframe material', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('wireframe')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial)
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'wireframe'
)
})
it('switches to depth material', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
})
it('restores original material when switching back', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
manager.setMaterialMode('normal')
manager.setMaterialMode('original')
expect(mesh.material).toBe(originalMat)
})
it('uses appliedTexture when no original material stored', async () => {
const { manager } = createManager()
const model = createMeshModel()
const texture = new THREE.Texture()
manager.appliedTexture = texture
manager.addModelToScene(model)
manager.materialMode = 'normal'
manager.setMaterialMode('original')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshStandardMaterial)
expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture)
})
it('sets renderer color space to SRGB for non-depth modes', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
manager.setMaterialMode('normal')
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
})
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.originalModel = new THREE.BufferGeometry()
;(manager.originalModel as THREE.BufferGeometry).setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 2, 2], 3)
)
manager.setMaterialMode('wireframe')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'wireframe'
)
})
})
describe('setupModelMaterials', () => {
it('stores original materials in the WeakMap', () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
manager.currentModel = model
manager.setupModelMaterials(model)
expect(manager.originalMaterials.get(mesh)).toBe(originalMat)
})
})
describe('setUpDirection', () => {
it('does nothing when no current model', () => {
const { manager, eventManager } = createManager()
manager.setUpDirection('+x')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('stores the original rotation on first call', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+x')
expect(manager.originalRotation).not.toBeNull()
})
it('applies correct rotation for each direction', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const directions: Array<{
dir: '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
axis: 'x' | 'z'
value: number
}> = [
{ dir: '-x', axis: 'z', value: Math.PI / 2 },
{ dir: '+x', axis: 'z', value: -Math.PI / 2 },
{ dir: '-y', axis: 'x', value: Math.PI },
{ dir: '-z', axis: 'x', value: Math.PI / 2 },
{ dir: '+z', axis: 'x', value: -Math.PI / 2 }
]
for (const { dir, axis, value } of directions) {
manager.setUpDirection(dir)
expect(model.rotation[axis]).toBeCloseTo(value)
expect(manager.currentUpDirection).toBe(dir)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
dir
)
}
})
it('restores original rotation before applying new direction', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+x')
const zAfterX = model.rotation.z
manager.setUpDirection('-z')
expect(model.rotation.x).toBeCloseTo(Math.PI / 2)
expect(model.rotation.z).not.toBeCloseTo(zAfterX)
})
it('emits upDirectionChange event', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('original')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'original'
)
})
})
describe('hasSkeleton', () => {
it('returns false when no current model', () => {
const { manager } = createManager()
expect(manager.hasSkeleton()).toBe(false)
})
it('returns false for model without skeleton', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.hasSkeleton()).toBe(false)
})
it('returns true for model with SkinnedMesh', () => {
const { manager } = createManager()
const group = new THREE.Group()
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial()
const bones = [new THREE.Bone(), new THREE.Bone()]
bones[0].add(bones[1])
const skeleton = new THREE.Skeleton(bones)
const skinnedMesh = new THREE.SkinnedMesh(geometry, material)
skinnedMesh.add(bones[0])
skinnedMesh.bind(skeleton)
group.add(skinnedMesh)
manager.currentModel = group
expect(manager.hasSkeleton()).toBe(true)
})
})
describe('setShowSkeleton', () => {
it('sets showSkeleton flag', () => {
const { manager } = createManager()
manager.setShowSkeleton(true)
expect(manager.showSkeleton).toBe(true)
})
it('emits skeletonVisibilityChange event', () => {
const { manager, eventManager } = createManager()
manager.setShowSkeleton(true)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'skeletonVisibilityChange',
true
)
})
it('hides existing skeleton helper when set to false', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const helper = new THREE.SkeletonHelper(model)
manager.skeletonHelper = helper
scene.add(helper)
manager.setShowSkeleton(false)
expect(helper.visible).toBe(false)
})
it('shows existing skeleton helper when set to true', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const helper = new THREE.SkeletonHelper(model)
helper.visible = false
manager.skeletonHelper = helper
scene.add(helper)
manager.setShowSkeleton(true)
expect(helper.visible).toBe(true)
})
})
describe('containsSplatMesh', () => {
it('returns false when no model', () => {
const { manager } = createManager()
expect(manager.containsSplatMesh()).toBe(false)
})
it('returns false for regular model', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.containsSplatMesh()).toBe(false)
})
it('returns false for explicit null argument', () => {
const { manager } = createManager()
expect(manager.containsSplatMesh(null)).toBe(false)
})
})
describe('PLY mode switching', () => {
function createPLYManager() {
const ctx = createManager()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
)
const mesh = new THREE.Mesh(
geometry.clone(),
ctx.manager.standardMaterial.clone()
)
const group = new THREE.Group()
group.name = 'MainModel'
group.add(mesh)
ctx.scene.add(group)
ctx.manager.currentModel = group
ctx.manager.originalModel = geometry
return ctx
}
it('recreates model as point cloud', () => {
const { manager, scene, eventManager } = createPLYManager()
manager.setMaterialMode('pointCloud')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
expect(mainModel).toBeDefined()
const points = mainModel!.children.find((c) => c instanceof THREE.Points)
expect(points).toBeInstanceOf(THREE.Points)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'pointCloud'
)
})
it('recreates model as wireframe mesh', () => {
const { manager, scene } = createPLYManager()
manager.setMaterialMode('wireframe')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
expect(mainModel).toBeDefined()
let foundWireframe = false
mainModel!.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material instanceof THREE.MeshBasicMaterial
) {
foundWireframe = child.material.wireframe
}
})
expect(foundWireframe).toBe(true)
})
it('uses vertex colors when available', () => {
const { manager, scene } = createManager()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
)
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
const mesh = new THREE.Mesh(
geometry.clone(),
new THREE.MeshBasicMaterial()
)
const group = new THREE.Group()
group.name = 'MainModel'
group.add(mesh)
scene.add(group)
manager.currentModel = group
manager.originalModel = geometry
manager.setMaterialMode('pointCloud')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
const points = mainModel!.children.find(
(c) => c instanceof THREE.Points
) as THREE.Points
expect((points.material as THREE.PointsMaterial).vertexColors).toBe(true)
})
it('removes old MainModel objects before adding new one', () => {
const { manager, scene } = createPLYManager()
manager.setMaterialMode('wireframe')
const mainModels = scene.children.filter((c) => c.name === 'MainModel')
expect(mainModels).toHaveLength(1)
})
})
})

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