mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 05:32:02 +00:00
Compare commits
51 Commits
glary/test
...
pysssss/sa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7715bac8 | ||
|
|
5624bab342 | ||
|
|
91ed6a37e2 | ||
|
|
15c5a298a6 | ||
|
|
65e27b5cdf | ||
|
|
dd16e7a9ea | ||
|
|
63d0e3ae5d | ||
|
|
71ca582325 | ||
|
|
9ed7a7bd87 | ||
|
|
3e62033f09 | ||
|
|
78630f5485 | ||
|
|
55c5fce522 | ||
|
|
4b5c15fc7d | ||
|
|
b36242475c | ||
|
|
2f4116fa81 | ||
|
|
d83c84aa85 | ||
|
|
c1c3fba1ac | ||
|
|
35bfe509b3 | ||
|
|
5d98e11ba1 | ||
|
|
cac66cdc39 | ||
|
|
60c7471818 | ||
|
|
0ac4c3d6c5 | ||
|
|
feafdc0b4a | ||
|
|
2fea0aa538 | ||
|
|
a1ba567dbc | ||
|
|
d2e30645fe | ||
|
|
fc61b19cb9 | ||
|
|
8a5a8f0a6e | ||
|
|
0638e8e993 | ||
|
|
07ce7123c8 | ||
|
|
799ffcf4b6 | ||
|
|
1020e8cf32 | ||
|
|
b157182a20 | ||
|
|
2bfe3443ab | ||
|
|
4c35add5bc | ||
|
|
a3893a593d | ||
|
|
deba72e7a0 | ||
|
|
3db0eac353 | ||
|
|
4c7729ee0b | ||
|
|
40083d593b | ||
|
|
7089a7d1a0 | ||
|
|
3b4811b00d | ||
|
|
b756545f59 | ||
|
|
da91bdc957 | ||
|
|
cf3006f82c | ||
|
|
be2d757c47 | ||
|
|
54f3127658 | ||
|
|
25a5cb4868 | ||
|
|
181b1ac245 | ||
|
|
55e21ac7e8 | ||
|
|
8011f0cd1e |
88
.github/actions/resolve-pr-from-workflow-run/action.yaml
vendored
Normal file
88
.github/actions/resolve-pr-from-workflow-run/action.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
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,21 +58,6 @@ 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,13 +32,6 @@ 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,3 +98,50 @@ 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,6 +6,10 @@ 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
|
||||
@@ -30,40 +34,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get PR Number
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr
|
||||
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;
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Test Start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
if: steps.pr.outputs.skip != 'true' && 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.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,7 +59,7 @@ jobs:
|
||||
path: reports
|
||||
|
||||
- name: Handle Test Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -85,6 +72,6 @@ jobs:
|
||||
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ 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,6 +6,10 @@ 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
|
||||
@@ -30,40 +34,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get PR Number
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr
|
||||
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;
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Storybook Start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
if: steps.pr.outputs.skip != 'true' && 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.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,7 +59,7 @@ jobs:
|
||||
path: storybook-static
|
||||
|
||||
- name: Handle Storybook Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -82,6 +69,6 @@ jobs:
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
|
||||
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -18,6 +18,12 @@ 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:
|
||||
@@ -25,6 +31,8 @@ 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
|
||||
@@ -32,28 +40,83 @@ 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 --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel pull --yes --environment=preview
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
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)
|
||||
|
||||
- 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 --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
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")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save PR metadata
|
||||
- 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: Publish preview outputs
|
||||
env:
|
||||
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
|
||||
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
|
||||
run: |
|
||||
if [[ "$ALIAS_OK" == "true" ]]; then
|
||||
STABLE_URL="https://$ALIAS_HOST"
|
||||
else
|
||||
STABLE_URL="$DEPLOY_URL"
|
||||
fi
|
||||
mkdir -p temp/vercel-preview
|
||||
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
|
||||
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"
|
||||
|
||||
- name: Upload preview metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -71,19 +134,24 @@ 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 --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
URL=$(vercel deploy --prebuilt --prod)
|
||||
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,42 +30,7 @@ jobs:
|
||||
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the workflow_run head SHA matches the current PR head
|
||||
const { data: livePr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
|
||||
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', livePr.base.ref);
|
||||
core.setOutput('head-sha', livePr.head.sha);
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- 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,6 +12,10 @@ 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
|
||||
@@ -31,38 +35,24 @@ jobs:
|
||||
|
||||
- name: Resolve PR number from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
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"
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- 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: |
|
||||
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
|
||||
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
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
|
||||
@@ -44,6 +44,7 @@ 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,6 +23,10 @@
|
||||
"destination": "https://blog.comfy.org/",
|
||||
"permanent": true
|
||||
},
|
||||
{ "source": "/press", "destination": "/about", "permanent": true }
|
||||
{
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"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,8 +30,6 @@ 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'
|
||||
@@ -179,7 +177,6 @@ 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
|
||||
@@ -233,7 +230,6 @@ 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)
|
||||
}
|
||||
@@ -499,7 +495,6 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
|
||||
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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,5 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class ShortcutsTab {
|
||||
readonly essentialsTab: Locator
|
||||
readonly viewControlsTab: Locator
|
||||
@@ -16,6 +18,26 @@ 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
|
||||
@@ -23,6 +45,7 @@ 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')
|
||||
@@ -38,6 +61,15 @@ 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', '--listen', '0.0.0.0'],
|
||||
argv: ['main.py'],
|
||||
ram_total: 67108864000,
|
||||
ram_free: 52428800000
|
||||
},
|
||||
|
||||
184
browser_tests/fixtures/helpers/HelpCenterHelper.ts
Normal file
184
browser_tests/fixtures/helpers/HelpCenterHelper.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
75
browser_tests/fixtures/helpers/LogsTerminalHelper.ts
Normal file
75
browser_tests/fixtures/helpers/LogsTerminalHelper.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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,7 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
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'
|
||||
@@ -120,6 +123,27 @@ 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,
|
||||
@@ -202,3 +226,13 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
95
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
95
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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,6 +115,13 @@ 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',
|
||||
@@ -199,6 +206,13 @@ 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',
|
||||
@@ -211,34 +225,11 @@ export const TestIds = {
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 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]
|
||||
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]
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -331,6 +332,22 @@ 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,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createAssetHelper,
|
||||
withModels,
|
||||
@@ -17,6 +18,8 @@ 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 ({
|
||||
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
|
||||
})
|
||||
|
||||
test.describe('mock API routes', () => {
|
||||
test('GET /assets returns all assets', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
@@ -87,12 +89,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets respects pagination params', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withModels(5),
|
||||
withPagination({ total: 10, hasMore: true })
|
||||
@@ -110,12 +112,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets filters by include_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
@@ -129,14 +131,12 @@ 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
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('PUT /assets/:id updates asset in store', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -175,14 +175,12 @@ 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
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -193,11 +191,12 @@ 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 }) => {
|
||||
test('POST /assets returns upload response', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const customUpload = {
|
||||
id: 'custom-upload-001',
|
||||
name: 'custom.safetensors',
|
||||
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
created_new: true
|
||||
}
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withUploadResponse(customUpload))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -217,14 +215,12 @@ 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
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -235,14 +231,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('tracks POST, PUT, DELETE mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -265,12 +261,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET requests are not tracked as mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -280,14 +276,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('returns error status for all asset routes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mockError(503, 'Service Unavailable')
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -296,16 +292,14 @@ 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
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
|
||||
@@ -1,98 +1,151 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} 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('should open bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
test.describe('panel', () => {
|
||||
test.beforeEach(async ({ logsTerminal }) => {
|
||||
await logsTerminal.mockSubscribeLogs()
|
||||
await logsTerminal.mockRawLogs([])
|
||||
})
|
||||
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
test('should show Logs tab when terminal panel opens', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
test.describe('terminal', () => {
|
||||
test.beforeEach(async ({ logsTerminal }) => {
|
||||
await logsTerminal.mockSubscribeLogs()
|
||||
await logsTerminal.mockRawLogs([])
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('shows loading spinner while logs are loading', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
const resolveRaw = await logsTerminal.mockRawLogsPending()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
})
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeVisible()
|
||||
|
||||
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
resolveRaw()
|
||||
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeHidden()
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
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).toBeHidden()
|
||||
})
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
|
||||
test('should switch between shortcuts and terminal panels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
await expect(comfyPage.bottomPanel.logs.xtermScreen).toBeVisible()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
logLine
|
||||
)
|
||||
})
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeVisible()
|
||||
test('appends log entries received via WebSocket', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
const ws = await getWebSocket()
|
||||
const firstLine = 'First live log line'
|
||||
const secondLine = 'Second live log line'
|
||||
|
||||
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([firstLine]))
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
firstLine
|
||||
)
|
||||
|
||||
test('should persist Logs tab content in bottom panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
ws.send(LogsTerminalHelper.buildWsLogFrame([secondLine]))
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
firstLine
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
secondLine
|
||||
)
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('copy button copies terminal contents to clipboard', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
const logLine = 'Copy me to the clipboard'
|
||||
await logsTerminal.mockRawLogs([logLine])
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
logLine
|
||||
)
|
||||
|
||||
const isAlreadyActive =
|
||||
(await logsTab.getAttribute('aria-selected')) === 'true'
|
||||
if (!isAlreadyActive) {
|
||||
await logsTab.click()
|
||||
}
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const xtermContainer = bottomPanel.root.locator('.xterm')
|
||||
await expect(xtermContainer).toBeVisible()
|
||||
})
|
||||
await comfyPage.bottomPanel.logs.terminalRoot.hover()
|
||||
await expect(comfyPage.bottomPanel.logs.copyButton).toBeVisible()
|
||||
await comfyPage.bottomPanel.logs.copyButton.click()
|
||||
|
||||
test('should render xterm container in terminal panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
await expect
|
||||
.poll(() => getClipboardText(comfyPage.page))
|
||||
.toContain(logLine)
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('shows error message when raw-logs API fails', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
await logsTerminal.mockRawLogsError()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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', '--listen', '0.0.0.0', '--enable-manager']
|
||||
argv: ['main.py', '--enable-manager']
|
||||
}
|
||||
}
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
|
||||
@@ -131,6 +131,38 @@ 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
|
||||
}) => {
|
||||
|
||||
54
browser_tests/tests/fileInputReselection.spec.ts
Normal file
54
browser_tests/tests/fileInputReselection.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('File input same-file reselection', () => {
|
||||
test('should allow uploading the same file twice via LoadImage node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
const loadImageNode = loadImageNodes[0]
|
||||
const uploadWidget = await loadImageNode.getWidget(1)
|
||||
const fileWidget = await loadImageNode.getWidget(0)
|
||||
|
||||
// First upload
|
||||
const firstUpload = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
const firstChooser = comfyPage.page.waitForEvent('filechooser')
|
||||
await uploadWidget.click()
|
||||
await (
|
||||
await firstChooser
|
||||
).setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
await firstUpload
|
||||
|
||||
await expect
|
||||
.poll(() => fileWidget.getValue(), {
|
||||
message: 'First upload should set widget value'
|
||||
})
|
||||
.toContain('test_upload_image')
|
||||
|
||||
// Second upload of the SAME file — before the fix, the hidden input
|
||||
// retained the previous value and onchange did not fire.
|
||||
const secondUpload = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
const secondChooser = comfyPage.page.waitForEvent('filechooser')
|
||||
await uploadWidget.click()
|
||||
await (
|
||||
await secondChooser
|
||||
).setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
await secondUpload
|
||||
|
||||
await expect
|
||||
.poll(() => fileWidget.getValue(), {
|
||||
message: 'Second upload of the same file should still set widget value'
|
||||
})
|
||||
.toContain('test_upload_image')
|
||||
})
|
||||
})
|
||||
174
browser_tests/tests/helpCenter.spec.ts
Normal file
174
browser_tests/tests/helpCenter.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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,10 +34,35 @@ 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
|
||||
|
||||
87
browser_tests/tests/load3d/gizmoControls.spec.ts
Normal file
87
browser_tests/tests/load3d/gizmoControls.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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')
|
||||
}
|
||||
)
|
||||
})
|
||||
235
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
235
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
81
browser_tests/tests/topbarMenuCommands.spec.ts
Normal file
81
browser_tests/tests/topbarMenuCommands.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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: 88 KiB After Width: | Height: | Size: 94 KiB |
@@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
|
||||
test(
|
||||
'Empty state matches screenshot baseline',
|
||||
'Empty state matches the 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,6 +26,8 @@ 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.
|
||||
@@ -172,7 +174,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.
|
||||
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).
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
@@ -182,6 +184,8 @@ System design is deferred to a future ADR.
|
||||
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:
|
||||
@@ -231,6 +235,23 @@ 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.4",
|
||||
"version": "1.44.7",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -102,7 +102,6 @@
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "catalog:",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "catalog:",
|
||||
|
||||
@@ -28,6 +28,7 @@ export type {
|
||||
BillingPlansResponse,
|
||||
BillingStatus,
|
||||
BillingStatusResponse,
|
||||
BindingErrorResponse,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -44,11 +45,6 @@ export type {
|
||||
CheckHubUsernameErrors,
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClaimInviteCodeData,
|
||||
ClaimInviteCodeError,
|
||||
ClaimInviteCodeErrors,
|
||||
ClaimInviteCodeResponse,
|
||||
ClaimInviteCodeResponses,
|
||||
ClientOptions,
|
||||
CreateAssetDownloadData,
|
||||
CreateAssetDownloadError,
|
||||
@@ -113,6 +109,13 @@ export type {
|
||||
CreateWorkflowVersionRequest,
|
||||
CreateWorkflowVersionResponse,
|
||||
CreateWorkflowVersionResponses,
|
||||
CreateWorkspaceApiKeyData,
|
||||
CreateWorkspaceApiKeyError,
|
||||
CreateWorkspaceApiKeyErrors,
|
||||
CreateWorkspaceApiKeyRequest,
|
||||
CreateWorkspaceApiKeyResponse,
|
||||
CreateWorkspaceApiKeyResponse2,
|
||||
CreateWorkspaceApiKeyResponses,
|
||||
CreateWorkspaceData,
|
||||
CreateWorkspaceError,
|
||||
CreateWorkspaceErrors,
|
||||
@@ -237,12 +240,16 @@ export type {
|
||||
GetBillingStatusErrors,
|
||||
GetBillingStatusResponse,
|
||||
GetBillingStatusResponses,
|
||||
GetCustomNodeProxyData,
|
||||
GetCustomNodeProxyErrors,
|
||||
GetCustomNodeProxyResponses,
|
||||
GetDeletionRequestData,
|
||||
GetDeletionRequestError,
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetExtensionsData,
|
||||
GetExtensionsResponse,
|
||||
GetExtensionsResponses,
|
||||
GetFeaturesData,
|
||||
GetFeaturesResponse,
|
||||
@@ -263,7 +270,9 @@ export type {
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHealthData,
|
||||
GetHealthError,
|
||||
GetHealthErrors,
|
||||
GetHealthResponse,
|
||||
GetHealthResponses,
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
@@ -285,11 +294,6 @@ export type {
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetInviteCodeStatusData,
|
||||
GetInviteCodeStatusError,
|
||||
GetInviteCodeStatusErrors,
|
||||
GetInviteCodeStatusResponse,
|
||||
GetInviteCodeStatusResponses,
|
||||
GetJobDetailData,
|
||||
GetJobDetailError,
|
||||
GetJobDetailErrors,
|
||||
@@ -339,9 +343,19 @@ export type {
|
||||
GetMyHubProfileErrors,
|
||||
GetMyHubProfileResponse,
|
||||
GetMyHubProfileResponses,
|
||||
GetNodeByIdData,
|
||||
GetNodeByIdErrors,
|
||||
GetNodeByIdResponses,
|
||||
GetNodeInfoData,
|
||||
GetNodeInfoResponse,
|
||||
GetNodeInfoResponses,
|
||||
GetNodeInfoSchemaData,
|
||||
GetNodeInfoSchemaResponses,
|
||||
GetNodeReplacementsData,
|
||||
GetNodeReplacementsError,
|
||||
GetNodeReplacementsErrors,
|
||||
GetNodeReplacementsResponse,
|
||||
GetNodeReplacementsResponses,
|
||||
GetOpenapiSpecData,
|
||||
GetOpenapiSpecResponses,
|
||||
GetPaymentPortalData,
|
||||
@@ -422,11 +436,15 @@ export type {
|
||||
GetUserErrors,
|
||||
GetUserResponse,
|
||||
GetUserResponses,
|
||||
GetUsersRawData,
|
||||
GetUsersRawErrors,
|
||||
GetUsersRawResponses,
|
||||
GetUsersInfoData,
|
||||
GetUsersInfoError,
|
||||
GetUsersInfoErrors,
|
||||
GetUsersInfoResponse,
|
||||
GetUsersInfoResponses,
|
||||
GetVhsQueryVideoData,
|
||||
GetVhsQueryVideoError,
|
||||
GetVhsQueryVideoErrors,
|
||||
GetVhsQueryVideoResponse,
|
||||
GetVhsQueryVideoResponses,
|
||||
GetVhsViewAudioData,
|
||||
GetVhsViewAudioErrors,
|
||||
@@ -487,8 +505,6 @@ export type {
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
InterruptJobResponses,
|
||||
InviteCodeClaimResponse,
|
||||
InviteCodeStatusResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse,
|
||||
@@ -551,6 +567,12 @@ export type {
|
||||
ListWorkflowsErrors,
|
||||
ListWorkflowsResponse,
|
||||
ListWorkflowsResponses,
|
||||
ListWorkspaceApiKeysData,
|
||||
ListWorkspaceApiKeysError,
|
||||
ListWorkspaceApiKeysErrors,
|
||||
ListWorkspaceApiKeysResponse,
|
||||
ListWorkspaceApiKeysResponse2,
|
||||
ListWorkspaceApiKeysResponses,
|
||||
ListWorkspaceInvitesData,
|
||||
ListWorkspaceInvitesError,
|
||||
ListWorkspaceInvitesErrors,
|
||||
@@ -601,6 +623,9 @@ export type {
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PostCustomNodeProxyData,
|
||||
PostCustomNodeProxyErrors,
|
||||
PostCustomNodeProxyResponses,
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
@@ -661,6 +686,11 @@ export type {
|
||||
ResubscribeResponse,
|
||||
ResubscribeResponse2,
|
||||
ResubscribeResponses,
|
||||
RevokeWorkspaceApiKeyData,
|
||||
RevokeWorkspaceApiKeyError,
|
||||
RevokeWorkspaceApiKeyErrors,
|
||||
RevokeWorkspaceApiKeyResponse,
|
||||
RevokeWorkspaceApiKeyResponses,
|
||||
RevokeWorkspaceInviteData,
|
||||
RevokeWorkspaceInviteError,
|
||||
RevokeWorkspaceInviteErrors,
|
||||
@@ -668,13 +698,6 @@ export type {
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretResponse,
|
||||
SendUserInviteEmailData,
|
||||
SendUserInviteEmailError,
|
||||
SendUserInviteEmailErrors,
|
||||
SendUserInviteEmailRequest,
|
||||
SendUserInviteEmailResponse,
|
||||
SendUserInviteEmailResponse2,
|
||||
SendUserInviteEmailResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
@@ -718,6 +741,12 @@ export type {
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateHubWorkflowData,
|
||||
UpdateHubWorkflowError,
|
||||
UpdateHubWorkflowErrors,
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -734,6 +763,11 @@ export type {
|
||||
UpdateSettingByKeyErrors,
|
||||
UpdateSettingByKeyResponse,
|
||||
UpdateSettingByKeyResponses,
|
||||
UpdateSubscriptionCacheData,
|
||||
UpdateSubscriptionCacheError,
|
||||
UpdateSubscriptionCacheErrors,
|
||||
UpdateSubscriptionCacheResponse,
|
||||
UpdateSubscriptionCacheResponses,
|
||||
UpdateWorkflowData,
|
||||
UpdateWorkflowError,
|
||||
UpdateWorkflowErrors,
|
||||
@@ -765,6 +799,13 @@ export type {
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
VerifyApiKeyRequest,
|
||||
VerifyApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyData,
|
||||
VerifyWorkspaceApiKeyError,
|
||||
VerifyWorkspaceApiKeyErrors,
|
||||
VerifyWorkspaceApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyResponses,
|
||||
ViewFileData,
|
||||
ViewFileError,
|
||||
ViewFileErrors,
|
||||
@@ -779,6 +820,7 @@ 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,6 +20,32 @@ 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(),
|
||||
@@ -134,8 +160,43 @@ export const zHubWorkflowTemplateEntry = z.object({
|
||||
thumbnailVariant: z.string().optional(),
|
||||
mediaType: z.string().optional(),
|
||||
mediaSubtype: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
vram: z.number().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(),
|
||||
openSource: z.boolean().optional(),
|
||||
profile: zHubProfileSummary.optional(),
|
||||
tutorialUrl: z.string().optional(),
|
||||
@@ -641,6 +702,53 @@ 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()
|
||||
@@ -979,22 +1087,6 @@ 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'])
|
||||
@@ -1005,22 +1097,6 @@ 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
|
||||
*/
|
||||
@@ -1040,6 +1116,7 @@ export const zCreateSessionResponse = z.object({
|
||||
* User information response
|
||||
*/
|
||||
export const zUserResponse = z.object({
|
||||
id: z.string(),
|
||||
status: z.string()
|
||||
})
|
||||
|
||||
@@ -1194,8 +1271,16 @@ export const zQueueManageRequest = z.object({
|
||||
* Queue information with pending and running jobs
|
||||
*/
|
||||
export const zQueueInfo = z.object({
|
||||
queue_running: z.array(z.array(z.unknown())).optional(),
|
||||
queue_pending: z.array(z.array(z.unknown())).optional()
|
||||
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()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1315,6 +1400,10 @@ 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()
|
||||
@@ -1427,6 +1516,17 @@ 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(),
|
||||
@@ -1588,7 +1688,7 @@ export const zViewFileData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - File content returned (used when channel or res parameter is present)
|
||||
* Processed PNG image with extracted channel
|
||||
*/
|
||||
export const zViewFileResponse = z.string()
|
||||
|
||||
@@ -2429,6 +2529,56 @@ 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(),
|
||||
@@ -2440,43 +2590,6 @@ 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(),
|
||||
@@ -2488,6 +2601,19 @@ 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(),
|
||||
@@ -2527,6 +2653,23 @@ 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({
|
||||
@@ -2991,6 +3134,25 @@ 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(),
|
||||
@@ -3019,12 +3181,32 @@ export const zGetVhsQueryVideoData = z.object({
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetUsersRawData = 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({
|
||||
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(),
|
||||
@@ -3065,6 +3247,11 @@ 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(),
|
||||
@@ -3134,3 +3321,19 @@ 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,40 +15951,35 @@ export interface components {
|
||||
QuiverTextToSVGRequest: {
|
||||
/**
|
||||
* @description Model identifier for SVG generation
|
||||
* @default arrow-preview
|
||||
* @example arrow-1.1
|
||||
*/
|
||||
model: string;
|
||||
/** @description Text description of the desired SVG output */
|
||||
prompt: string;
|
||||
/** @description Additional style or formatting guidance */
|
||||
instructions?: string;
|
||||
/** @description Up to 4 reference images (URL or base64) */
|
||||
references?: components["schemas"]["QuiverImageObject"][];
|
||||
/** @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 Number of SVGs to generate
|
||||
* @default 1
|
||||
*/
|
||||
n: number;
|
||||
/**
|
||||
* @description Enable Server-Sent Events streaming
|
||||
* @default false
|
||||
*/
|
||||
stream: boolean;
|
||||
/**
|
||||
* @description Randomness control
|
||||
* @description Sampling temperature
|
||||
* @default 1
|
||||
*/
|
||||
temperature: number;
|
||||
/**
|
||||
* @description Nucleus sampling parameter
|
||||
* @description Nucleus sampling probability
|
||||
* @default 1
|
||||
*/
|
||||
top_p: number;
|
||||
/**
|
||||
* @description Token presence penalty
|
||||
* @description Penalty for tokens already present in prior output
|
||||
* @default 0
|
||||
*/
|
||||
presence_penalty: number;
|
||||
presence_penalty: number | null;
|
||||
/** @description Maximum number of output tokens */
|
||||
max_output_tokens?: number;
|
||||
};
|
||||
@@ -15992,42 +15987,37 @@ export interface components {
|
||||
QuiverImageToSVGRequest: {
|
||||
/**
|
||||
* @description Model identifier for SVG vectorization
|
||||
* @default arrow-preview
|
||||
* @example arrow-1.1
|
||||
*/
|
||||
model: string;
|
||||
image: components["schemas"]["QuiverImageObject"];
|
||||
/**
|
||||
* @description Automatically crop to dominant subject
|
||||
* @description Auto-crop image to the dominant subject before vectorization
|
||||
* @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 Randomness control
|
||||
* @description Sampling temperature
|
||||
* @default 1
|
||||
*/
|
||||
temperature: number;
|
||||
/**
|
||||
* @description Nucleus sampling parameter
|
||||
* @description Nucleus sampling probability
|
||||
* @default 1
|
||||
*/
|
||||
top_p: number;
|
||||
/**
|
||||
* @description Token presence penalty
|
||||
* @description Penalty for tokens already present in prior output
|
||||
* @default 0
|
||||
*/
|
||||
presence_penalty: number;
|
||||
presence_penalty: number | null;
|
||||
/** @description Maximum number of output tokens */
|
||||
max_output_tokens?: number;
|
||||
};
|
||||
@@ -16044,24 +16034,39 @@ 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 Generated SVG content */
|
||||
svg?: string;
|
||||
created: number;
|
||||
data: {
|
||||
/** @description Raw SVG markup */
|
||||
svg: string;
|
||||
/**
|
||||
* @description MIME type of the output
|
||||
* @default image/svg+xml
|
||||
* @enum {string}
|
||||
*/
|
||||
mime_type: string;
|
||||
mime_type: "image/svg+xml";
|
||||
}[];
|
||||
/** @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?: {
|
||||
/** @description Total tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
total_tokens?: number;
|
||||
/** @description Input tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
input_tokens?: number;
|
||||
/** @description Output tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
output_tokens?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
@@ -357,4 +358,12 @@ 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,9 +361,17 @@ 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,9 +267,6 @@ 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
|
||||
@@ -557,9 +554,6 @@ importers:
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: 'catalog:'
|
||||
version: 0.7.3
|
||||
loglevel:
|
||||
specifier: ^1.9.2
|
||||
version: 1.9.2
|
||||
@@ -1780,9 +1774,6 @@ 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==}
|
||||
|
||||
@@ -7269,11 +7260,6 @@ 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==}
|
||||
|
||||
@@ -11239,8 +11225,6 @@ 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':
|
||||
@@ -17140,10 +17124,6 @@ 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,7 +90,6 @@ 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,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const TARGET = 80
|
||||
const MILESTONE_STEP = 5
|
||||
const MIN_DELTA = 0.05
|
||||
const BAR_WIDTH = 20
|
||||
|
||||
interface CoverageData {
|
||||
@@ -71,8 +72,9 @@ function formatPct(value: number): string {
|
||||
}
|
||||
|
||||
function formatDelta(delta: number): string {
|
||||
const sign = delta >= 0 ? '+' : ''
|
||||
return sign + delta.toFixed(1) + '%'
|
||||
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
|
||||
const sign = rounded >= 0 ? '+' : ''
|
||||
return sign + rounded.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function crossedMilestone(prev: number, curr: number): number | null {
|
||||
@@ -150,15 +152,18 @@ function main() {
|
||||
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
|
||||
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
|
||||
|
||||
const unitImproved =
|
||||
unitCurrent !== null &&
|
||||
unitBaseline !== null &&
|
||||
unitCurrent.percentage > unitBaseline.percentage
|
||||
const unitDelta =
|
||||
unitCurrent !== null && unitBaseline !== null
|
||||
? unitCurrent.percentage - unitBaseline.percentage
|
||||
: 0
|
||||
|
||||
const e2eImproved =
|
||||
e2eCurrent !== null &&
|
||||
e2eBaseline !== null &&
|
||||
e2eCurrent.percentage > e2eBaseline.percentage
|
||||
const e2eDelta =
|
||||
e2eCurrent !== null && e2eBaseline !== null
|
||||
? e2eCurrent.percentage - e2eBaseline.percentage
|
||||
: 0
|
||||
|
||||
const unitImproved = unitDelta >= MIN_DELTA
|
||||
const e2eImproved = e2eDelta >= MIN_DELTA
|
||||
|
||||
if (!unitImproved && !e2eImproved) {
|
||||
process.exit(0)
|
||||
@@ -172,12 +177,12 @@ function main() {
|
||||
)
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitCurrent && unitBaseline) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
|
||||
if (unitImproved) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
|
||||
}
|
||||
|
||||
if (e2eCurrent && e2eBaseline) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
|
||||
if (e2eImproved) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
|
||||
}
|
||||
|
||||
summaryLines.push('')
|
||||
|
||||
177
scripts/generate-embedded-metadata-test-files.py
Normal file
177
scripts/generate-embedded-metadata-test-files.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate test fixture files for metadata parser tests.
|
||||
|
||||
Each fixture embeds the same workflow and prompt JSON, matching the
|
||||
format the ComfyUI backend uses to write metadata.
|
||||
|
||||
Prerequisites:
|
||||
source ~/ComfyUI/.venv/bin/activate
|
||||
python3 scripts/generate-embedded-metadata-test-files.py
|
||||
|
||||
Output: src/scripts/metadata/__fixtures__/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
|
||||
import av
|
||||
from PIL import Image
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||
|
||||
WORKFLOW = {
|
||||
'nodes': [
|
||||
{
|
||||
'id': 1,
|
||||
'type': 'KSampler',
|
||||
'pos': [100, 100],
|
||||
'size': [200, 200],
|
||||
}
|
||||
]
|
||||
}
|
||||
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
|
||||
|
||||
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
|
||||
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
|
||||
|
||||
|
||||
def out(name: str) -> str:
|
||||
return os.path.join(FIXTURES_DIR, name)
|
||||
|
||||
|
||||
def report(name: str):
|
||||
size = os.path.getsize(out(name))
|
||||
print(f' {name} ({size} bytes)')
|
||||
|
||||
|
||||
def make_1x1_image() -> Image.Image:
|
||||
return Image.new('RGB', (1, 1), (255, 0, 0))
|
||||
|
||||
|
||||
def build_exif_bytes() -> bytes:
|
||||
"""Build EXIF bytes matching the backend's tag assignments.
|
||||
|
||||
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
|
||||
"""
|
||||
img = make_1x1_image()
|
||||
exif = img.getexif()
|
||||
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
|
||||
exif[0x0110] = f'prompt:{PROMPT_JSON}'
|
||||
return exif.tobytes()
|
||||
|
||||
|
||||
def inject_exif_prefix_in_webp(path: str):
|
||||
"""Prepend Exif\\0\\0 to the EXIF chunk in a WEBP file.
|
||||
|
||||
PIL always strips this prefix, so we re-inject it to test that code path.
|
||||
"""
|
||||
data = bytearray(open(path, 'rb').read())
|
||||
off = 12
|
||||
while off < len(data):
|
||||
chunk_type = data[off:off + 4]
|
||||
chunk_len = struct.unpack_from('<I', data, off + 4)[0]
|
||||
if chunk_type == b'EXIF':
|
||||
prefix = b'Exif\x00\x00'
|
||||
data[off + 8:off + 8] = prefix
|
||||
struct.pack_into('<I', data, off + 4, chunk_len + len(prefix))
|
||||
riff_size = struct.unpack_from('<I', data, 4)[0]
|
||||
struct.pack_into('<I', data, 4, riff_size + len(prefix))
|
||||
break
|
||||
off += 8 + chunk_len + (chunk_len % 2)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def generate_av_fixture(
|
||||
name: str,
|
||||
fmt: str,
|
||||
codec: str,
|
||||
rate: int = 44100,
|
||||
options: dict | None = None,
|
||||
):
|
||||
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
|
||||
path = out(name)
|
||||
container = av.open(path, mode='w', format=fmt, options=options or {})
|
||||
stream = container.add_stream(codec, rate=rate)
|
||||
stream.layout = 'mono'
|
||||
|
||||
container.metadata['prompt'] = PROMPT_JSON
|
||||
container.metadata['workflow'] = WORKFLOW_JSON
|
||||
|
||||
sample_fmt = stream.codec_context.codec.audio_formats[0].name
|
||||
samples = stream.codec_context.frame_size or 1024
|
||||
frame = av.AudioFrame(format=sample_fmt, layout='mono', samples=samples)
|
||||
frame.rate = rate
|
||||
frame.pts = 0
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
container.close()
|
||||
report(name)
|
||||
|
||||
|
||||
def generate_webp():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
|
||||
img.save(out('with_metadata.webp'), 'WEBP', exif=exif)
|
||||
report('with_metadata.webp')
|
||||
|
||||
img.save(out('with_metadata_exif_prefix.webp'), 'WEBP', exif=exif)
|
||||
inject_exif_prefix_in_webp(out('with_metadata_exif_prefix.webp'))
|
||||
report('with_metadata_exif_prefix.webp')
|
||||
|
||||
|
||||
def generate_avif():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
img.save(out('with_metadata.avif'), 'AVIF', exif=exif)
|
||||
report('with_metadata.avif')
|
||||
|
||||
|
||||
def generate_flac():
|
||||
generate_av_fixture('with_metadata.flac', 'flac', 'flac')
|
||||
|
||||
|
||||
def generate_opus():
|
||||
generate_av_fixture('with_metadata.opus', 'opus', 'libopus', rate=48000)
|
||||
|
||||
|
||||
def generate_mp3():
|
||||
generate_av_fixture('with_metadata.mp3', 'mp3', 'libmp3lame')
|
||||
|
||||
|
||||
def generate_mp4():
|
||||
"""Generate MP4 via ffmpeg CLI with QuickTime keys/ilst metadata."""
|
||||
path = out('with_metadata.mp4')
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-loglevel', 'error',
|
||||
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
|
||||
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
|
||||
'-movflags', 'use_metadata_tags',
|
||||
'-metadata', f'prompt={PROMPT_JSON}',
|
||||
'-metadata', f'workflow={WORKFLOW_JSON}',
|
||||
path,
|
||||
], check=True)
|
||||
report('with_metadata.mp4')
|
||||
|
||||
|
||||
def generate_webm():
|
||||
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_webp()
|
||||
generate_avif()
|
||||
generate_flac()
|
||||
generate_opus()
|
||||
generate_mp3()
|
||||
generate_mp4()
|
||||
generate_webm()
|
||||
print('Done.')
|
||||
10
src/base/common/selectionBounds.ts
Normal file
10
src/base/common/selectionBounds.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** 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,13 +1,22 @@
|
||||
<template>
|
||||
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
|
||||
<div
|
||||
ref="rootEl"
|
||||
data-testid="terminal-root"
|
||||
class="relative size-full overflow-hidden bg-neutral-900"
|
||||
>
|
||||
<div class="p-terminal size-full rounded-none p-2">
|
||||
<div ref="terminalEl" class="terminal-host h-full" />
|
||||
<div
|
||||
ref="terminalEl"
|
||||
data-testid="terminal-host"
|
||||
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,10 +1,15 @@
|
||||
<template>
|
||||
<div class="size-full bg-transparent">
|
||||
<p v-if="errorMessage" class="p-4 text-center">
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
data-testid="terminal-error-message"
|
||||
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" />
|
||||
|
||||
133
src/components/boundingbox/WidgetBoundingBox.test.ts
Normal file
133
src/components/boundingbox/WidgetBoundingBox.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
import WidgetBoundingBox from './WidgetBoundingBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ScrubableNumberInputStub = defineComponent({
|
||||
name: 'ScrubableNumberInput',
|
||||
props: {
|
||||
modelValue: { type: Number, default: 0 },
|
||||
min: { type: Number, default: 0 },
|
||||
step: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="number"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:data-min="min"
|
||||
:data-step="step"
|
||||
@input="$emit('update:modelValue', Number(($event.target).value))"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
function renderBox(initial: Bounds, disabled = false) {
|
||||
const value = ref<Bounds>(initial)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetBoundingBox },
|
||||
setup: () => ({ value, disabled }),
|
||||
template: '<WidgetBoundingBox v-model="value" :disabled="disabled" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { ScrubableNumberInput: ScrubableNumberInputStub }
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetBoundingBox', () => {
|
||||
describe('Label rendering', () => {
|
||||
it('renders labels for x, y, width, and height', () => {
|
||||
renderBox({ x: 0, y: 0, width: 100, height: 100 })
|
||||
expect(screen.getByText('X')).toBeInTheDocument()
|
||||
expect(screen.getByText('Y')).toBeInTheDocument()
|
||||
expect(screen.getByText('Width')).toBeInTheDocument()
|
||||
expect(screen.getByText('Height')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Initial values', () => {
|
||||
it('displays the initial bounds across four inputs', () => {
|
||||
renderBox({ x: 10, y: 20, width: 300, height: 400 })
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
expect(inputs.map((i) => i.value)).toEqual(['10', '20', '300', '400'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Constraints', () => {
|
||||
it('sets min=0 for x/y and min=1 for width/height', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 })
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
expect(inputs[0].dataset.min).toBe('0') // x
|
||||
expect(inputs[1].dataset.min).toBe('0') // y
|
||||
expect(inputs[2].dataset.min).toBe('1') // width
|
||||
expect(inputs[3].dataset.min).toBe('1') // height
|
||||
})
|
||||
})
|
||||
|
||||
describe('v-model updates', () => {
|
||||
it('updates x immutably, preserving y/width/height', async () => {
|
||||
const { value } = renderBox({ x: 10, y: 20, width: 100, height: 200 })
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
const user = userEvent.setup()
|
||||
await user.clear(inputs[0])
|
||||
await user.type(inputs[0], '55')
|
||||
expect(value.value).toEqual({
|
||||
x: 55,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('updates height immutably without mutating the original bounds', async () => {
|
||||
const initial = { x: 10, y: 20, width: 100, height: 200 }
|
||||
const { value } = renderBox(initial)
|
||||
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
const user = userEvent.setup()
|
||||
await user.clear(inputs[3])
|
||||
await user.type(inputs[3], '500')
|
||||
expect(value.value.height).toBe(500)
|
||||
expect(initial).toEqual({ x: 10, y: 20, width: 100, height: 200 })
|
||||
expect(value.value).not.toBe(initial)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('disables all four inputs when disabled=true', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 }, true)
|
||||
for (const input of screen.getAllByRole('spinbutton')) {
|
||||
expect(input).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves all four inputs enabled when disabled=false', () => {
|
||||
renderBox({ x: 0, y: 0, width: 1, height: 1 }, false)
|
||||
for (const input of screen.getAllByRole('spinbutton')) {
|
||||
expect(input).not.toBeDisabled()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/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'
|
||||
|
||||
116
src/components/graph/widgets/MultiSelectWidget.test.ts
Normal file
116
src/components/graph/widgets/MultiSelectWidget.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
import MultiSelectWidget from './MultiSelectWidget.vue'
|
||||
|
||||
const MultiSelectStub = defineComponent({
|
||||
name: 'MultiSelect',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '' },
|
||||
display: { type: String, default: '' }
|
||||
},
|
||||
template: `<div data-testid="multiselect"
|
||||
:data-options="JSON.stringify(options)"
|
||||
:data-placeholder="placeholder"
|
||||
:data-display="display"
|
||||
:data-model-value="JSON.stringify(modelValue)" />`
|
||||
})
|
||||
|
||||
function makeWidget(
|
||||
inputSpec: Partial<ComboInputSpec>
|
||||
): ComponentWidget<string[]> {
|
||||
return {
|
||||
name: 'multi',
|
||||
inputSpec: {
|
||||
type: 'COMBO',
|
||||
name: 'multi',
|
||||
...inputSpec
|
||||
} as ComboInputSpec
|
||||
} as unknown as ComponentWidget<string[]>
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
inputSpec: Partial<ComboInputSpec>,
|
||||
initialValue: string[] = []
|
||||
) {
|
||||
const value = ref<string[]>(initialValue)
|
||||
const widget = makeWidget(inputSpec)
|
||||
const Harness = defineComponent({
|
||||
components: { MultiSelectWidget },
|
||||
setup: () => ({ value, widget }),
|
||||
template: '<MultiSelectWidget v-model="value" :widget="widget" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: { plugins: [PrimeVue], stubs: { MultiSelect: MultiSelectStub } }
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('MultiSelectWidget', () => {
|
||||
describe('Option list', () => {
|
||||
it('passes inputSpec.options through as MultiSelect options', () => {
|
||||
renderWidget({ options: ['a', 'b', 'c'] })
|
||||
const el = screen.getByTestId('multiselect')
|
||||
expect(JSON.parse(el.dataset.options!)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('falls back to an empty list when inputSpec.options is absent', () => {
|
||||
renderWidget({})
|
||||
const el = screen.getByTestId('multiselect')
|
||||
expect(JSON.parse(el.dataset.options!)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Placeholder', () => {
|
||||
it('reads placeholder from multi_select.placeholder', () => {
|
||||
renderWidget({
|
||||
options: ['a'],
|
||||
multi_select: { placeholder: 'Pick one or more' }
|
||||
})
|
||||
expect(screen.getByTestId('multiselect').dataset.placeholder).toBe(
|
||||
'Pick one or more'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults placeholder to "Select items" when not provided', () => {
|
||||
renderWidget({ options: ['a'] })
|
||||
expect(screen.getByTestId('multiselect').dataset.placeholder).toBe(
|
||||
'Select items'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Display mode', () => {
|
||||
it('uses "chip" display when multi_select.chip is true', () => {
|
||||
renderWidget({ options: ['a'], multi_select: { chip: true } })
|
||||
expect(screen.getByTestId('multiselect').dataset.display).toBe('chip')
|
||||
})
|
||||
|
||||
it('uses "comma" display when chip is false or missing', () => {
|
||||
renderWidget({ options: ['a'], multi_select: { chip: false } })
|
||||
expect(screen.getByTestId('multiselect').dataset.display).toBe('comma')
|
||||
})
|
||||
|
||||
it('uses "comma" display when multi_select is absent', () => {
|
||||
renderWidget({ options: ['a'] })
|
||||
expect(screen.getByTestId('multiselect').dataset.display).toBe('comma')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value binding', () => {
|
||||
it('forwards the initial selected items to MultiSelect', () => {
|
||||
renderWidget({ options: ['a', 'b'] }, ['a'])
|
||||
const el = screen.getByTestId('multiselect')
|
||||
expect(JSON.parse(el.dataset.modelValue!)).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
})
|
||||
206
src/components/graph/widgets/TextPreviewWidget.test.ts
Normal file
206
src/components/graph/widgets/TextPreviewWidget.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
const execHolder = vi.hoisted(() => ({
|
||||
state: null as {
|
||||
executingNodeIds: Array<string | number>
|
||||
isIdle: boolean
|
||||
} | null
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
execHolder.state = reactive({
|
||||
executingNodeIds: [] as Array<string | number>,
|
||||
isIdle: true
|
||||
})
|
||||
return {
|
||||
useExecutionStore: () => execHolder.state
|
||||
}
|
||||
})
|
||||
|
||||
const execState = (): {
|
||||
executingNodeIds: Array<string | number>
|
||||
isIdle: boolean
|
||||
} => execHolder.state!
|
||||
|
||||
import TextPreviewWidget from './TextPreviewWidget.vue'
|
||||
|
||||
const SkeletonStub = defineComponent({
|
||||
name: 'Skeleton',
|
||||
template: '<div data-testid="skeleton" />'
|
||||
})
|
||||
|
||||
function renderPreview(
|
||||
text: string,
|
||||
{ nodeId = 'node-1' }: { nodeId?: string | number } = {}
|
||||
) {
|
||||
const value = ref(text)
|
||||
const Harness = defineComponent({
|
||||
components: { TextPreviewWidget },
|
||||
setup: () => ({ value, nodeId }),
|
||||
template: '<TextPreviewWidget v-model="value" :node-id="nodeId" />'
|
||||
})
|
||||
return render(Harness, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
stubs: { Skeleton: SkeletonStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TextPreviewWidget', () => {
|
||||
beforeEach(() => {
|
||||
execState().executingNodeIds = []
|
||||
execState().isIdle = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Text formatting', () => {
|
||||
it('renders plain text content', () => {
|
||||
const { container } = renderPreview('hello world')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const span = container.querySelector('span')
|
||||
expect(span?.innerHTML).toContain('hello world')
|
||||
})
|
||||
|
||||
it('converts newlines to <br> tags', () => {
|
||||
const { container } = renderPreview('line1\nline2')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const span = container.querySelector('span')
|
||||
expect(span?.innerHTML).toContain('<br')
|
||||
})
|
||||
|
||||
it('auto-links bare http URLs', () => {
|
||||
const { container } = renderPreview('visit https://example.com for info')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor).not.toBeNull()
|
||||
expect(anchor?.getAttribute('href')).toBe('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bracketed link tokens [[label|url]]', () => {
|
||||
it('renders an http link with the supplied label', () => {
|
||||
const { container } = renderPreview(
|
||||
'see [[Docs|https://docs.example.com]]'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor).not.toBeNull()
|
||||
expect(anchor?.getAttribute('href')).toBe('https://docs.example.com')
|
||||
expect(anchor?.textContent).toBe('Docs')
|
||||
})
|
||||
|
||||
it('sets target=_blank and rel=noopener for safety', () => {
|
||||
const { container } = renderPreview('[[Docs|https://x.example.com]]')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor?.getAttribute('target')).toBe('_blank')
|
||||
expect(anchor?.getAttribute('rel')).toContain('noopener')
|
||||
})
|
||||
|
||||
it('renders label as plain text when url is not http(s)', () => {
|
||||
const { container } = renderPreview('[[Local|javascript:alert(1)]]')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('span')?.textContent).toContain('Local')
|
||||
})
|
||||
|
||||
it('escapes HTML in the label to prevent XSS', () => {
|
||||
const { container } = renderPreview(
|
||||
'[[<img src=x>|https://x.example.com]]'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const span = container.querySelector('span')
|
||||
expect(span?.innerHTML).toContain('<img')
|
||||
expect(span?.innerHTML).not.toContain('<img src')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Raw HTML sanitisation in modelValue', () => {
|
||||
it('drops img tags entirely (strict allowlist is <a> + <br> only)', () => {
|
||||
const { container } = renderPreview('<img src=x onerror="alert(1)">')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const img = container.querySelector('img')
|
||||
expect(img).toBeNull()
|
||||
})
|
||||
|
||||
it('drops script tags from raw HTML in modelValue', () => {
|
||||
const { container } = renderPreview(
|
||||
'hello<script>window.__xss = true</script>world'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops iframe tags', () => {
|
||||
const { container } = renderPreview(
|
||||
'<iframe src="https://evil.example.com"></iframe>'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
|
||||
it('strips inline javascript: hrefs on anchors', () => {
|
||||
const { container } = renderPreview(
|
||||
'<a href="javascript:alert(1)">click</a>'
|
||||
)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor).not.toBeNull()
|
||||
const href = anchor?.getAttribute('href')
|
||||
expect(href == null || !href.startsWith('javascript:')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the <br> tag produced by nl2br', () => {
|
||||
const { container } = renderPreview('line1\nline2')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('br')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Execution state', () => {
|
||||
it('hides the Skeleton on mount when execution is already idle', () => {
|
||||
execState().executingNodeIds = []
|
||||
execState().isIdle = true
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
expect(screen.queryByTestId('skeleton')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a Skeleton on mount when the parent node is executing', () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Skeleton when execution transitions to idle', async () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
|
||||
|
||||
execState().executingNodeIds = []
|
||||
execState().isIdle = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('skeleton')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the Skeleton when the parent node leaves executingNodeIds', async () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
|
||||
execState().executingNodeIds = ['other']
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('skeleton')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,8 +12,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -25,7 +26,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const isParentNodeExecuting = ref(true)
|
||||
const isParentNodeExecuting = computed(() => {
|
||||
if (executionStore.isIdle) return false
|
||||
if (!parentNodeId) return executionStore.executingNodeIds.length > 0
|
||||
return executionStore.executingNodeIds.includes(parentNodeId)
|
||||
})
|
||||
const formattedText = computed(() => {
|
||||
const src = modelValue.value
|
||||
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
|
||||
@@ -51,39 +56,27 @@ const formattedText = computed(() => {
|
||||
: safeLabel
|
||||
})
|
||||
|
||||
return html
|
||||
// Strict allowlist: this widget only needs anchors and line breaks. Raw
|
||||
// websocket progress text flows into modelValue, so we drop every other
|
||||
// tag (img, script, iframe, etc.) to keep the v-html trust boundary tight.
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['a', 'br'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel']
|
||||
})
|
||||
})
|
||||
|
||||
let parentNodeId: NodeId | null = null
|
||||
onMounted(() => {
|
||||
// Get the parent node ID from props if provided
|
||||
// For backward compatibility, fall back to the first executing node
|
||||
parentNodeId = props.nodeId
|
||||
parentNodeId = props.nodeId ?? parentNodeId
|
||||
})
|
||||
|
||||
// Watch for either a new node has starting execution or overall execution ending
|
||||
const stopWatching = watch(
|
||||
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
|
||||
() => {
|
||||
if (executionStore.isIdle) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parent node is no longer in the executing nodes list
|
||||
if (
|
||||
parentNodeId &&
|
||||
!executionStore.executingNodeIds.includes(parentNodeId)
|
||||
) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
}
|
||||
|
||||
// Set parent node ID if not set yet
|
||||
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
|
||||
parentNodeId = executionStore.executingNodeIds[0]
|
||||
}
|
||||
// Lazily adopt the first executing node as the parent when no nodeId is known.
|
||||
watch(
|
||||
() => executionStore.executingNodeIds,
|
||||
(ids) => {
|
||||
if (!parentNodeId && ids.length > 0) parentNodeId = ids[0]
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
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)"
|
||||
@@ -103,6 +104,7 @@
|
||||
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,6 +4,7 @@
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
data-testid="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
@@ -38,6 +39,7 @@
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
data-testid="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
@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"
|
||||
@@ -40,9 +43,27 @@
|
||||
@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-12 right-2 z-20"
|
||||
class="pointer-events-auto absolute top-24 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
@@ -51,8 +72,8 @@
|
||||
v-if="!isPreview"
|
||||
class="pointer-events-auto absolute right-2 z-20"
|
||||
:class="{
|
||||
'top-12': !enable3DViewer,
|
||||
'top-24': enable3DViewer
|
||||
'top-24': !enable3DViewer,
|
||||
'top-36': enable3DViewer
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
@@ -77,6 +98,7 @@ 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'
|
||||
@@ -143,6 +165,10 @@ const {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@
|
||||
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>
|
||||
@@ -102,6 +110,7 @@ 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'
|
||||
@@ -109,6 +118,7 @@ import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
@@ -148,6 +158,7 @@ const categoryLabels: Record<string, string> = {
|
||||
model: 'load3d.model',
|
||||
camera: 'load3d.camera',
|
||||
light: 'load3d.light',
|
||||
gizmo: 'load3d.gizmo.label',
|
||||
export: 'load3d.export'
|
||||
}
|
||||
|
||||
@@ -156,7 +167,7 @@ const availableCategories = computed(() => {
|
||||
return ['scene', 'model', 'camera']
|
||||
}
|
||||
|
||||
return ['scene', 'model', 'camera', 'light', 'export']
|
||||
return ['scene', 'model', 'camera', 'light', 'gizmo', 'export']
|
||||
})
|
||||
|
||||
const showSceneControls = computed(
|
||||
@@ -175,6 +186,9 @@ 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
|
||||
@@ -190,6 +204,7 @@ 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
|
||||
|
||||
@@ -205,6 +220,9 @@ 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) => {
|
||||
@@ -218,4 +236,16 @@ 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,6 +74,14 @@
|
||||
/>
|
||||
</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>
|
||||
@@ -99,6 +107,7 @@ 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'
|
||||
|
||||
155
src/components/load3d/controls/GizmoControls.test.ts
Normal file
155
src/components/load3d/controls/GizmoControls.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
122
src/components/load3d/controls/GizmoControls.vue
Normal file
122
src/components/load3d/controls/GizmoControls.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<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>
|
||||
@@ -0,0 +1,133 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
<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>
|
||||
185
src/components/range/WidgetRange.test.ts
Normal file
185
src/components/range/WidgetRange.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/* 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/comfyanonymous/ComfyUI/issues'
|
||||
githubIssues: 'https://github.com/Comfy-Org/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/comfyanonymous/ComfyUI/issues?q='),
|
||||
expect.stringContaining('github.com/Comfy-Org/ComfyUI/issues?q='),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
159
src/components/rightSidePanel/errors/useErrorActions.test.ts
Normal file
159
src/components/rightSidePanel/errors/useErrorActions.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
392
src/components/rightSidePanel/errors/useErrorReport.test.ts
Normal file
392
src/components/rightSidePanel/errors/useErrorReport.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
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,6 +2,7 @@
|
||||
<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 } from 'vue'
|
||||
import { h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
@@ -103,11 +103,13 @@ 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: { value: true },
|
||||
subscriptionTierName: { value: 'Creator' },
|
||||
subscriptionTier: { value: 'CREATOR' },
|
||||
isActiveSubscription: ref(true),
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
fetchStatus: mockFetchStatus
|
||||
}))
|
||||
}))
|
||||
@@ -188,6 +190,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
@@ -406,14 +409,43 @@ 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('hides credits section', () => {
|
||||
it('still shows credits balance', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
|
||||
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('upgrade-to-add-credits-button')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -424,11 +456,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides partner nodes menu item', () => {
|
||||
it('still shows partner nodes menu item', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('partner-nodes-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('partner-nodes-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides plans & pricing menu item', () => {
|
||||
@@ -438,11 +468,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides manage plan menu item', () => {
|
||||
it('still shows manage plan menu item', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('manage-plan-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows user settings menu item', () => {
|
||||
|
||||
@@ -29,11 +29,8 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credits Section (cloud only) -->
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
<!-- Credits Section -->
|
||||
<div v-if="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"
|
||||
@@ -49,7 +46,7 @@
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
data-testid="upgrade-to-add-credits-button"
|
||||
@@ -82,7 +79,7 @@
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
v-if="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"
|
||||
@@ -112,7 +109,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
v-if="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"
|
||||
|
||||
86
src/components/ui/button/Button.test.ts
Normal file
86
src/components/ui/button/Button.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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,8 +1,9 @@
|
||||
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,9 +155,6 @@ 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,
|
||||
@@ -165,8 +162,10 @@ import {
|
||||
selectItemVariants,
|
||||
selectTriggerVariants,
|
||||
stopEscapeToDocument
|
||||
} from './select.variants'
|
||||
import type { SelectOption } from './types'
|
||||
} from '@/components/ui/select/select.variants'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
const meta: Meta = {
|
||||
@@ -84,17 +84,16 @@ 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 './select.variants'
|
||||
import type { SelectOption } from './types'
|
||||
} from '@/components/ui/select/select.variants'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
141
src/components/ui/slider/Slider.test.ts
Normal file
141
src/components/ui/slider/Slider.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
71
src/components/ui/textarea/Textarea.test.ts
Normal file
71
src/components/ui/textarea/Textarea.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/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/input/MultiSelect.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
243
src/composables/node/useNodeDragAndDrop.test.ts
Normal file
243
src/composables/node/useNodeDragAndDrop.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeDragAndDrop } from './useNodeDragAndDrop'
|
||||
|
||||
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(name: string, type = 'image/png'): File {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
function createDragEvent(options: {
|
||||
items?: Array<{ kind: string }>
|
||||
files?: File[]
|
||||
types?: string[]
|
||||
uri?: string
|
||||
}): DragEvent {
|
||||
const { items = [], files = [], types = [], uri = '' } = options
|
||||
return fromAny<DragEvent, unknown>({
|
||||
dataTransfer: {
|
||||
items: fromAny<DataTransferItemList, unknown>(items),
|
||||
files: fromAny<FileList, unknown>(files),
|
||||
types,
|
||||
getData: vi.fn((format: string) =>
|
||||
format === 'text/uri-list' ? uri : ''
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('useNodeDragAndDrop', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('onDragOver detects file items by default', () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const isDragging = node.onDragOver?.(
|
||||
createDragEvent({ items: [{ kind: 'file' }] })
|
||||
)
|
||||
|
||||
expect(isDragging).toBe(true)
|
||||
})
|
||||
|
||||
it('onDragOver delegates to custom handler result', () => {
|
||||
const node = createNode()
|
||||
const onDragOver = vi.fn().mockReturnValue(false)
|
||||
|
||||
useNodeDragAndDrop(node, {
|
||||
onDrop: vi.fn().mockResolvedValue([]),
|
||||
onDragOver
|
||||
})
|
||||
|
||||
const isDragging = node.onDragOver?.(
|
||||
createDragEvent({ items: [{ kind: 'file' }] })
|
||||
)
|
||||
|
||||
expect(onDragOver).toHaveBeenCalledTimes(1)
|
||||
expect(isDragging).toBe(false)
|
||||
})
|
||||
|
||||
it('onDragOver returns true for uri list drops without file items', () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const isDragging = node.onDragOver?.(
|
||||
createDragEvent({ items: [{ kind: 'string' }], types: ['text/uri-list'] })
|
||||
)
|
||||
|
||||
expect(isDragging).toBe(true)
|
||||
})
|
||||
|
||||
it('onDragOver returns false when drag event has no items', () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const isDragging = node.onDragOver?.(fromAny<DragEvent, unknown>({}))
|
||||
|
||||
expect(isDragging).toBe(false)
|
||||
})
|
||||
|
||||
it('onDragDrop calls onDrop with filtered files', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const node = createNode()
|
||||
const keep = createFile('keep.png')
|
||||
const skip = createFile('skip.jpg', 'image/jpeg')
|
||||
|
||||
useNodeDragAndDrop(node, {
|
||||
onDrop,
|
||||
fileFilter: (file) => file.type === 'image/png'
|
||||
})
|
||||
|
||||
const result = await node.onDragDrop?.(
|
||||
createDragEvent({ files: [keep, skip], items: [{ kind: 'file' }] })
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onDrop).toHaveBeenCalledWith([keep])
|
||||
})
|
||||
|
||||
it('onDragDrop returns false for invalid drops', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(createDragEvent({}))
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onDragDrop handles same-origin uri drops', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
fromAny<Response, unknown>({
|
||||
ok: true,
|
||||
blob: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
|
||||
})
|
||||
)
|
||||
const uri = `${location.origin}/api/file?filename=uri.png`
|
||||
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(
|
||||
createDragEvent({ uri, types: ['text/uri-list'] })
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fetchSpy).toHaveBeenCalledWith(new URL(uri))
|
||||
expect(onDrop).toHaveBeenCalledTimes(1)
|
||||
expect(onDrop.mock.calls[0][0][0]).toBeInstanceOf(File)
|
||||
expect(onDrop.mock.calls[0][0][0].name).toBe('uri.png')
|
||||
})
|
||||
|
||||
it('onDragDrop returns false for cross-origin uri drops', async () => {
|
||||
const node = createNode()
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(
|
||||
createDragEvent({
|
||||
uri: 'https://example.com/api/file?filename=uri.png',
|
||||
types: ['text/uri-list']
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onDragDrop returns false when uri fetch throws', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network'))
|
||||
const uri = `${location.origin}/api/file?filename=uri.png`
|
||||
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(
|
||||
createDragEvent({ uri, types: ['text/uri-list'] })
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onDragDrop returns false when uri response is invalid or filtered out', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const uri = `${location.origin}/api/file?filename=uri.jpg`
|
||||
|
||||
const nodeA = createNode()
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
fromAny<Response, unknown>({ ok: false })
|
||||
)
|
||||
useNodeDragAndDrop(nodeA, { onDrop })
|
||||
const badResponseResult = await nodeA.onDragDrop?.(
|
||||
createDragEvent({ uri, types: ['text/uri-list'] })
|
||||
)
|
||||
expect(badResponseResult).toBe(false)
|
||||
|
||||
const nodeB = createNode()
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
fromAny<Response, unknown>({
|
||||
ok: true,
|
||||
blob: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['uri'], { type: 'image/jpeg' }))
|
||||
})
|
||||
)
|
||||
useNodeDragAndDrop(nodeB, {
|
||||
onDrop,
|
||||
fileFilter: (file) => file.type === 'image/png'
|
||||
})
|
||||
const filteredOutResult = await nodeB.onDragDrop?.(
|
||||
createDragEvent({ uri, types: ['text/uri-list'] })
|
||||
)
|
||||
|
||||
expect(filteredOutResult).toBe(false)
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onRemoved clears handlers and chains existing onRemoved', () => {
|
||||
const previousOnRemoved = vi.fn()
|
||||
const node = createNode({ onRemoved: previousOnRemoved })
|
||||
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
expect(node.onDragOver).toBeTypeOf('function')
|
||||
expect(node.onDragDrop).toBeTypeOf('function')
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
|
||||
expect(node.onDragOver).toBeUndefined()
|
||||
expect(node.onDragDrop).toBeUndefined()
|
||||
})
|
||||
|
||||
it('onRemoved preserves handlers replaced by another extension', () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const replacementDragOver = vi.fn()
|
||||
const replacementDragDrop = vi.fn()
|
||||
node.onDragOver = replacementDragOver
|
||||
node.onDragDrop = replacementDragDrop
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(node.onDragOver).toBe(replacementDragOver)
|
||||
expect(node.onDragDrop).toBe(replacementDragDrop)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type DragHandler = (e: DragEvent) => boolean
|
||||
@@ -43,9 +44,10 @@ export const useNodeDragAndDrop = <T>(
|
||||
return !!e?.dataTransfer?.getData('text/uri-list')
|
||||
}
|
||||
|
||||
node.onDragOver = isDraggingFiles
|
||||
const installedDragOver = isDraggingFiles
|
||||
node.onDragOver = installedDragOver
|
||||
|
||||
node.onDragDrop = async function (e: DragEvent) {
|
||||
const installedDragDrop = async function (e: DragEvent) {
|
||||
if (!isDraggingValidFiles(e)) return false
|
||||
|
||||
const files = filterFiles(e.dataTransfer!.files)
|
||||
@@ -73,4 +75,10 @@ export const useNodeDragAndDrop = <T>(
|
||||
}
|
||||
return true
|
||||
}
|
||||
node.onDragDrop = installedDragDrop
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
if (node.onDragOver === installedDragOver) node.onDragOver = undefined
|
||||
if (node.onDragDrop === installedDragDrop) node.onDragDrop = undefined
|
||||
})
|
||||
}
|
||||
|
||||
175
src/composables/node/useNodeFileInput.test.ts
Normal file
175
src/composables/node/useNodeFileInput.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeFileInput } from './useNodeFileInput'
|
||||
|
||||
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(name: string, type = 'image/png'): File {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
function setInputFiles(input: HTMLInputElement, files: File[]) {
|
||||
Object.defineProperty(input, 'files', {
|
||||
configurable: true,
|
||||
value: fromAny<FileList, unknown>(files)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
Object.defineProperty(input, 'value', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
describe('useNodeFileInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('creates a file input with configured attributes and defaults', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
const createElementSpy = vi
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(fileInput)
|
||||
|
||||
const node = createNode()
|
||||
useNodeFileInput(node, { onSelect: vi.fn() })
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith('input')
|
||||
expect(fileInput.type).toBe('file')
|
||||
expect(fileInput.accept).toBe('*')
|
||||
expect(fileInput.multiple).toBe(false)
|
||||
})
|
||||
|
||||
it('uses provided accept and allow_batch options', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const node = createNode()
|
||||
useNodeFileInput(node, {
|
||||
onSelect: vi.fn(),
|
||||
accept: 'image/*',
|
||||
allow_batch: true
|
||||
})
|
||||
|
||||
expect(fileInput.accept).toBe('image/*')
|
||||
expect(fileInput.multiple).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onSelect with filtered files and resets value on change', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const onSelect = vi.fn()
|
||||
const node = createNode()
|
||||
const keep = createFile('keep.png')
|
||||
const skip = createFile('skip.jpg', 'image/jpeg')
|
||||
|
||||
useNodeFileInput(node, {
|
||||
onSelect,
|
||||
fileFilter: (file) => file.type === 'image/png'
|
||||
})
|
||||
|
||||
setInputFiles(fileInput, [keep, skip])
|
||||
setInputValue(fileInput, 'C:\\fakepath\\keep.png')
|
||||
|
||||
fileInput.onchange?.(new Event('change'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith([keep])
|
||||
expect(fileInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('does not call onSelect for empty file list and still resets value', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const onSelect = vi.fn()
|
||||
const node = createNode()
|
||||
|
||||
useNodeFileInput(node, { onSelect })
|
||||
|
||||
setInputFiles(fileInput, [])
|
||||
setInputValue(fileInput, 'C:\\fakepath\\empty.png')
|
||||
fileInput.onchange?.(new Event('change'))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(fileInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('resets value before invoking onSelect so it is cleared even on throw', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const node = createNode()
|
||||
const onSelect = vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
useNodeFileInput(node, { onSelect })
|
||||
|
||||
setInputFiles(fileInput, [createFile('test.png')])
|
||||
setInputValue(fileInput, 'C:\\fakepath\\test.png')
|
||||
|
||||
expect(() => fileInput.onchange?.(new Event('change'))).toThrow('boom')
|
||||
expect(fileInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('does not call onSelect when all files are filtered out', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const onSelect = vi.fn()
|
||||
const node = createNode()
|
||||
|
||||
useNodeFileInput(node, {
|
||||
onSelect,
|
||||
fileFilter: () => false
|
||||
})
|
||||
|
||||
setInputFiles(fileInput, [createFile('ignored.png')])
|
||||
fileInput.onchange?.(new Event('change'))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('openFileSelection clicks the generated input', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const node = createNode()
|
||||
const { openFileSelection } = useNodeFileInput(node, { onSelect: vi.fn() })
|
||||
|
||||
openFileSelection()
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cleans up on removal, chains existing callback, and no-ops after removal', () => {
|
||||
const fileInput = document.createElement('input')
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
|
||||
|
||||
const previousOnRemoved = vi.fn()
|
||||
const node = createNode({ onRemoved: previousOnRemoved })
|
||||
const { openFileSelection } = useNodeFileInput(node, { onSelect: vi.fn() })
|
||||
|
||||
expect(fileInput.onchange).toBeTypeOf('function')
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
|
||||
expect(fileInput.onchange).toBeNull()
|
||||
|
||||
openFileSelection()
|
||||
expect(clickSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -25,10 +25,12 @@ export function useNodeFileInput(node: LGraphNode, options: FileInputOptions) {
|
||||
fileInput.multiple = allow_batch
|
||||
|
||||
fileInput.onchange = () => {
|
||||
if (fileInput?.files?.length) {
|
||||
const files = Array.from(fileInput.files).filter(fileFilter)
|
||||
if (files.length) onSelect(files)
|
||||
}
|
||||
const files = fileInput?.files?.length
|
||||
? Array.from(fileInput.files).filter(fileFilter)
|
||||
: []
|
||||
// Reset value so re-selecting the same file triggers onchange
|
||||
if (fileInput) fileInput.value = ''
|
||||
if (files.length) onSelect(files)
|
||||
}
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
|
||||
107
src/composables/node/useNodePaste.test.ts
Normal file
107
src/composables/node/useNodePaste.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodePaste } from './useNodePaste'
|
||||
|
||||
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(name: string, type = 'image/png'): File {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
describe('useNodePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('pasteFiles calls onPaste with filtered files', () => {
|
||||
const onPaste = vi.fn().mockResolvedValue('ok')
|
||||
const node = createNode()
|
||||
const keep = createFile('keep.png')
|
||||
const skip = createFile('skip.jpg', 'image/jpeg')
|
||||
|
||||
useNodePaste(node, {
|
||||
onPaste,
|
||||
fileFilter: (file) => file.type === 'image/png',
|
||||
allow_batch: true
|
||||
})
|
||||
|
||||
const result = node.pasteFiles?.([keep, skip])
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onPaste).toHaveBeenCalledWith([keep])
|
||||
})
|
||||
|
||||
it('pasteFiles returns false when no files match filter', () => {
|
||||
const onPaste = vi.fn().mockResolvedValue('ok')
|
||||
const node = createNode()
|
||||
|
||||
useNodePaste(node, {
|
||||
onPaste,
|
||||
fileFilter: () => false
|
||||
})
|
||||
|
||||
const result = node.pasteFiles?.([createFile('ignored.png')])
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(onPaste).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pasteFiles limits to first file when allow_batch is false', () => {
|
||||
const onPaste = vi.fn().mockResolvedValue('ok')
|
||||
const node = createNode()
|
||||
const first = createFile('first.png')
|
||||
const second = createFile('second.png')
|
||||
|
||||
useNodePaste(node, { onPaste, allow_batch: false })
|
||||
|
||||
const result = node.pasteFiles?.([first, second])
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onPaste).toHaveBeenCalledWith([first])
|
||||
})
|
||||
|
||||
it('pasteFiles passes all files when allow_batch is true', () => {
|
||||
const onPaste = vi.fn().mockResolvedValue('ok')
|
||||
const node = createNode()
|
||||
const first = createFile('first.png')
|
||||
const second = createFile('second.png')
|
||||
|
||||
useNodePaste(node, { onPaste, allow_batch: true })
|
||||
|
||||
const result = node.pasteFiles?.([first, second])
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onPaste).toHaveBeenCalledWith([first, second])
|
||||
})
|
||||
|
||||
it('onRemoved clears pasteFiles and chains existing onRemoved', () => {
|
||||
const previousOnRemoved = vi.fn()
|
||||
const node = createNode({ onRemoved: previousOnRemoved })
|
||||
|
||||
useNodePaste(node, { onPaste: vi.fn().mockResolvedValue('ok') })
|
||||
expect(node.pasteFiles).toBeTypeOf('function')
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
|
||||
expect(node.pasteFiles).toBeUndefined()
|
||||
})
|
||||
|
||||
it('onRemoved preserves pasteFiles replaced by another extension', () => {
|
||||
const node = createNode()
|
||||
useNodePaste(node, { onPaste: vi.fn().mockResolvedValue('ok') })
|
||||
|
||||
const replacementPasteFiles = vi.fn()
|
||||
node.pasteFiles = replacementPasteFiles
|
||||
|
||||
node.onRemoved?.call(node)
|
||||
|
||||
expect(node.pasteFiles).toBe(replacementPasteFiles)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type PasteHandler<T> = (files: File[]) => Promise<T>
|
||||
@@ -17,7 +18,7 @@ export const useNodePaste = <T>(
|
||||
) => {
|
||||
const { onPaste, fileFilter = () => true, allow_batch = false } = options
|
||||
|
||||
node.pasteFiles = function (files: File[]) {
|
||||
const installedPasteFiles = function (files: File[]) {
|
||||
const filteredFiles = Array.from(files).filter(fileFilter)
|
||||
if (!filteredFiles.length) return false
|
||||
|
||||
@@ -26,4 +27,9 @@ export const useNodePaste = <T>(
|
||||
void onPaste(paste)
|
||||
return true
|
||||
}
|
||||
node.pasteFiles = installedPasteFiles
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
if (node.pasteFiles === installedPasteFiles) node.pasteFiles = undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,11 +43,9 @@ describe('useExternalLink', () => {
|
||||
|
||||
// Static URLs
|
||||
expect(staticUrls.discord).toBe('https://www.comfy.org/discord')
|
||||
expect(staticUrls.github).toBe(
|
||||
'https://github.com/comfyanonymous/ComfyUI'
|
||||
)
|
||||
expect(staticUrls.github).toBe('https://github.com/Comfy-Org/ComfyUI')
|
||||
expect(staticUrls.githubIssues).toBe(
|
||||
'https://github.com/comfyanonymous/ComfyUI/issues'
|
||||
'https://github.com/Comfy-Org/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/comfyanonymous/ComfyUI',
|
||||
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues',
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
|
||||
@@ -146,6 +146,12 @@ 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']
|
||||
@@ -169,38 +175,6 @@ 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')
|
||||
@@ -229,8 +203,6 @@ 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)
|
||||
@@ -271,53 +243,29 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('should load model if model_file widget exists', async () => {
|
||||
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', 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).toHaveBeenCalledWith(
|
||||
'http://localhost/api/view/test.glb'
|
||||
)
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
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 = {
|
||||
it('should restore camera config from node properties', async () => {
|
||||
;(
|
||||
mockNode.properties!['Camera Config'] as Record<string, 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')
|
||||
@@ -325,7 +273,7 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({
|
||||
expect(composable.cameraConfig.value.state).toEqual({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
})
|
||||
@@ -373,6 +321,39 @@ 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)
|
||||
@@ -460,11 +441,13 @@ describe('useLoad3d', () => {
|
||||
|
||||
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(mockNode.properties['Model Config']).toEqual({
|
||||
upDirection: '+y',
|
||||
materialMode: 'wireframe',
|
||||
showSkeleton: false
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
it('should update camera config when values change', async () => {
|
||||
@@ -862,79 +845,72 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
|
||||
'http://example.com/model.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('should construct URL for local files', async () => {
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'models/test.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
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([
|
||||
'models',
|
||||
'test.glb'
|
||||
'uploaded',
|
||||
'model.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/models/test.glb'
|
||||
'/api/view/uploaded/model.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/models/test.glb'
|
||||
'http://localhost/api/view/uploaded/model.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb')
|
||||
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
|
||||
'models',
|
||||
'test.glb',
|
||||
'input'
|
||||
)
|
||||
expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb')
|
||||
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/models/test.glb'
|
||||
'http://localhost/api/view/uploaded/model.glb'
|
||||
)
|
||||
})
|
||||
|
||||
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'])
|
||||
it('should use resource folder for upload subfolder', async () => {
|
||||
mockNode.properties['Resource Folder'] = 'subfolder'
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'uploaded',
|
||||
'model.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/test.glb'
|
||||
'/api/view/uploaded/model.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/test.glb'
|
||||
'http://localhost/api/view/uploaded/model.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
|
||||
'',
|
||||
'test.glb',
|
||||
'output'
|
||||
const file = new File([''], 'model.glb', {
|
||||
type: 'model/gltf-binary'
|
||||
})
|
||||
await composable.handleModelDrop(file)
|
||||
|
||||
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
|
||||
})
|
||||
|
||||
it('should not load model when load3d is not initialized', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
const file = new File([''], 'model.glb', {
|
||||
type: 'model/gltf-binary'
|
||||
})
|
||||
await composable.handleModelDrop(file)
|
||||
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.no3dScene'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1071,4 +1047,241 @@ 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,8 +2,9 @@ import type { MaybeRef } from 'vue'
|
||||
|
||||
import { toRef } from '@vueuse/core'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
import { 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 {
|
||||
@@ -16,6 +17,8 @@ import type {
|
||||
CameraState,
|
||||
CameraType,
|
||||
EventCallback,
|
||||
GizmoConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
@@ -38,6 +41,7 @@ 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,
|
||||
@@ -49,7 +53,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const modelConfig = ref<ModelConfig>({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
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 }
|
||||
}
|
||||
})
|
||||
|
||||
const hasSkeleton = ref(false)
|
||||
@@ -123,30 +134,32 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
await restoreConfigurationsFromNode(node)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
|
||||
load3d?.refreshViewport()
|
||||
|
||||
load3d?.updateStatusMouseOnNode(true)
|
||||
}
|
||||
})
|
||||
|
||||
node.onMouseLeave = function () {
|
||||
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
|
||||
load3d?.updateStatusMouseOnNode(false)
|
||||
}
|
||||
})
|
||||
|
||||
node.onResize = function () {
|
||||
node.onResize = useChainCallback(node.onResize, () => {
|
||||
load3d?.handleResize()
|
||||
}
|
||||
})
|
||||
|
||||
node.onDrawBackground = function () {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
node.onDrawBackground = useChainCallback(
|
||||
node.onDrawBackground,
|
||||
function (this: LGraphNode) {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
node.onRemoved = function () {
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
pendingCallbacks.delete(node)
|
||||
}
|
||||
})
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
@@ -183,11 +196,24 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const savedModelConfig = node.properties['Model Config'] as ModelConfig
|
||||
if (savedModelConfig) {
|
||||
modelConfig.value = 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const savedCameraConfig = node.properties['Camera Config'] as CameraConfig
|
||||
const cameraStateToRestore = savedCameraConfig?.state
|
||||
|
||||
if (savedCameraConfig) {
|
||||
cameraConfig.value = savedCameraConfig
|
||||
@@ -235,31 +261,6 @@ 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()
|
||||
}
|
||||
@@ -276,6 +277,31 @@ 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
|
||||
@@ -294,29 +320,6 @@ 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
|
||||
@@ -380,16 +383,34 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
watch(
|
||||
modelConfig,
|
||||
(newValue) => {
|
||||
if (load3d && nodeRef.value) {
|
||||
if (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) => {
|
||||
@@ -741,6 +762,20 @@ 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 = ''
|
||||
@@ -748,8 +783,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
applyGizmoConfigToLoad3d()
|
||||
isFirstModelLoad = false
|
||||
|
||||
if (load3d && isAssetPreviewSupported()) {
|
||||
const node = nodeRef.value
|
||||
@@ -816,9 +851,44 @@ 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
|
||||
@@ -878,6 +948,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,15 @@ describe('useLoad3dViewer', () => {
|
||||
addEventListener: vi.fn(),
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: 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 }
|
||||
})
|
||||
}
|
||||
|
||||
mockSourceLoad3d = {
|
||||
@@ -163,20 +171,6 @@ 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')
|
||||
@@ -240,104 +234,7 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle watcher errors gracefully', async () => {
|
||||
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
|
||||
function () {
|
||||
@@ -749,4 +646,118 @@ 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,6 +9,7 @@ import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
CameraType,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
@@ -32,6 +33,8 @@ interface Load3dViewerState {
|
||||
backgroundRenderMode: BackgroundRenderModeType
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
gizmoEnabled: boolean
|
||||
gizmoMode: GizmoMode
|
||||
}
|
||||
|
||||
const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
|
||||
@@ -44,7 +47,9 @@ const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled',
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
gizmoEnabled: false,
|
||||
gizmoMode: 'translate'
|
||||
}
|
||||
|
||||
const standaloneConfigCache = new QuickLRU<string, Load3dViewerState>({
|
||||
@@ -69,6 +74,8 @@ 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)
|
||||
@@ -98,7 +105,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled',
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
gizmoEnabled: false,
|
||||
gizmoMode: 'translate'
|
||||
})
|
||||
|
||||
watch(backgroundColor, (newColor) => {
|
||||
@@ -273,6 +282,18 @@ 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.
|
||||
*
|
||||
@@ -367,6 +388,10 @@ 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()
|
||||
@@ -382,7 +407,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundImage: backgroundImage.value,
|
||||
backgroundRenderMode: backgroundRenderMode.value,
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
materialMode: materialMode.value,
|
||||
gizmoEnabled: gizmoEnabled.value,
|
||||
gizmoMode: gizmoMode.value
|
||||
}
|
||||
|
||||
setupAnimationEvents()
|
||||
@@ -475,7 +502,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundImage: backgroundImage.value,
|
||||
backgroundRenderMode: backgroundRenderMode.value,
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
materialMode: materialMode.value,
|
||||
gizmoEnabled: gizmoEnabled.value,
|
||||
gizmoMode: gizmoMode.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -497,6 +526,8 @@ 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)
|
||||
}
|
||||
@@ -572,7 +603,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
|
||||
nodeValue.properties['Model Config'] = {
|
||||
upDirection: initialState.value.upDirection,
|
||||
materialMode: initialState.value.materialMode
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
const currentCameraConfig = nodeValue.properties['Camera Config'] as
|
||||
@@ -614,9 +652,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
intensity: lightIntensity.value
|
||||
}
|
||||
|
||||
const gizmoTransform = load3d.getGizmoTransform()
|
||||
nodeValue.properties['Model Config'] = {
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
materialMode: materialMode.value,
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: gizmoEnabled.value,
|
||||
mode: gizmoMode.value,
|
||||
position: gizmoTransform.position,
|
||||
rotation: gizmoTransform.rotation,
|
||||
scale: gizmoTransform.scale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,6 +804,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundRenderMode,
|
||||
upDirection,
|
||||
materialMode,
|
||||
gizmoEnabled,
|
||||
gizmoMode,
|
||||
needApplyChanges,
|
||||
isPreview,
|
||||
isStandaloneMode,
|
||||
@@ -784,6 +833,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
handleBackgroundImageUpdate,
|
||||
handleModelDrop,
|
||||
handleSeek,
|
||||
resetGizmoTransform: () => {
|
||||
load3d?.resetGizmoTransform()
|
||||
},
|
||||
cleanup,
|
||||
|
||||
hasSkeleton: false,
|
||||
|
||||
@@ -323,6 +323,10 @@ 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,28 +190,40 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
setupForModel(size: THREE.Vector3): void {
|
||||
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)
|
||||
const distance = Math.max(size.x, size.z) * 2
|
||||
const height = size.y * 2
|
||||
const height = center.y + maxDim
|
||||
|
||||
this.perspectiveCamera.position.set(distance, height, distance)
|
||||
this.orthographicCamera.position.set(distance, height, distance)
|
||||
this.perspectiveCamera.position.set(
|
||||
center.x + distance,
|
||||
height,
|
||||
center.z + distance
|
||||
)
|
||||
this.orthographicCamera.position.set(
|
||||
center.x + distance,
|
||||
height,
|
||||
center.z + distance
|
||||
)
|
||||
|
||||
if (this.activeCamera === this.perspectiveCamera) {
|
||||
this.perspectiveCamera.lookAt(0, size.y / 2, 0)
|
||||
this.perspectiveCamera.lookAt(center)
|
||||
this.perspectiveCamera.updateProjectionMatrix()
|
||||
} else {
|
||||
const frustumSize = Math.max(size.x, size.y, size.z) * 2
|
||||
const frustumSize = maxDim * 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(0, size.y / 2, 0)
|
||||
this.orthographicCamera.lookAt(center)
|
||||
this.orthographicCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.controls?.target.set(0, size.y / 2, 0)
|
||||
this.controls?.target.copy(center)
|
||||
this.controls?.update()
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user