mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 22:21:51 +00:00
Compare commits
1 Commits
pysssss/ta
...
glary/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a065087e2f |
@@ -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));
|
||||
15
.github/workflows/ci-perf-report.yaml
vendored
15
.github/workflows/ci-perf-report.yaml
vendored
@@ -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
|
||||
|
||||
7
.github/workflows/ci-size-data.yaml
vendored
7
.github/workflows/ci-size-data.yaml
vendored
@@ -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:
|
||||
|
||||
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -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
|
||||
|
||||
35
.github/workflows/ci-tests-e2e-forks.yaml
vendored
35
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -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"
|
||||
|
||||
35
.github/workflows/ci-tests-storybook-forks.yaml
vendored
35
.github/workflows/ci-tests-storybook-forks.yaml
vendored
@@ -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"
|
||||
|
||||
88
.github/workflows/ci-vercel-website-preview.yaml
vendored
88
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -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
|
||||
|
||||
37
.github/workflows/pr-report.yaml
vendored
37
.github/workflows/pr-report.yaml
vendored
@@ -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'
|
||||
|
||||
46
.github/workflows/pr-vercel-website-preview.yaml
vendored
46
.github/workflows/pr-vercel-website-preview.yaml
vendored
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,10 +23,6 @@
|
||||
"destination": "https://blog.comfy.org/",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
}
|
||||
{ "source": "/press", "destination": "/about", "permanent": true }
|
||||
]
|
||||
}
|
||||
|
||||
63
browser_tests/assets/nodes/note_with_ksampler.json
Normal file
63
browser_tests/assets/nodes/note_with_ksampler.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 |
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
82
browser_tests/tests/noteNodeApiExport.spec.ts
Normal file
82
browser_tests/tests/noteNodeApiExport.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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 |
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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'
|
||||
|
||||
884
packages/ingest-types/src/types.gen.ts
generated
884
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
353
packages/ingest-types/src/zod.gen.ts
generated
353
packages/ingest-types/src/zod.gen.ts
generated
@@ -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()
|
||||
})
|
||||
|
||||
71
packages/registry-types/src/comfyRegistryTypes.ts
generated
71
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
20
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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="
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
@@ -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
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 ? '•' : ''"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user