Compare commits
67 Commits
glary/test
...
rizumu/per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c499cf1de0 | ||
|
|
23b54494cb | ||
|
|
bea72410fd | ||
|
|
cbc479d8b4 | ||
|
|
a6b3aa1667 | ||
|
|
d682b3c7da | ||
|
|
65b8a5652c | ||
|
|
5a598ef2e1 | ||
|
|
2c772077e0 | ||
|
|
00c294297e | ||
|
|
983789753e | ||
|
|
91ed6a37e2 | ||
|
|
15c5a298a6 | ||
|
|
65e27b5cdf | ||
|
|
dd16e7a9ea | ||
|
|
63d0e3ae5d | ||
|
|
71ca582325 | ||
|
|
9ed7a7bd87 | ||
|
|
3e62033f09 | ||
|
|
78630f5485 | ||
|
|
55c5fce522 | ||
|
|
4b5c15fc7d | ||
|
|
b36242475c | ||
|
|
2f4116fa81 | ||
|
|
d83c84aa85 | ||
|
|
c1c3fba1ac | ||
|
|
35bfe509b3 | ||
|
|
5d98e11ba1 | ||
|
|
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 | ||
|
|
e4d9b1c214 | ||
|
|
7e5143d2f1 | ||
|
|
bf606a209e | ||
|
|
3159921280 | ||
|
|
20198f1465 | ||
|
|
b0e6942e92 | ||
|
|
bf906c26c6 | ||
|
|
d6aabe7d20 | ||
|
|
56b0f6b822 | ||
|
|
5aa18edeb0 | ||
|
|
c9bf31e13b | ||
|
|
e067c4434d |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
135
browser_tests/assets/nodes/glsl_shader_in_subgraph.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"id": "ee111111-2222-4333-8444-000000000001",
|
||||
"revision": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "aa999999-8888-4777-a666-555555555555",
|
||||
"pos": [400, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
|
||||
"title": "GLSL Subgraph",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "aa999999-8888-4777-a666-555555555555",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "GLSL Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [50, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 200, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "bb888888-7777-4666-a555-444444444444",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 920, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [250, 180],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
69
browser_tests/assets/nodes/glsl_shader_standalone.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [200, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(v_texCoord.x, v_texCoord.y, 0.5, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
179
browser_tests/assets/nodes/glsl_shader_subgraph_with_float.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"id": "ee111111-2222-4333-8444-000000000002",
|
||||
"revision": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "aa999999-8888-4777-a666-555555555556",
|
||||
"pos": [400, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
|
||||
"title": "GLSL Subgraph With Float",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "aa999999-8888-4777-a666-555555555556",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "GLSL Subgraph With Float",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [50, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 200, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "bb888888-7777-4666-a555-444444444445",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 920, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 180],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_float0",
|
||||
"localized_name": "floats.u_float0",
|
||||
"name": "floats.u_float0",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform float u_float0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(u_float0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "FLOAT",
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveFloat" },
|
||||
"widgets_values": [1.0]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "FLOAT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_bool.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_bool0",
|
||||
"localized_name": "bools.u_bool0",
|
||||
"name": "bools.u_bool0",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform bool u_bool0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = u_bool0 ? vec4(1.0, 0.0, 0.0, 1.0) : vec4(0.0, 0.0, 1.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveBoolean",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "BOOLEAN",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "BOOLEAN",
|
||||
"name": "BOOLEAN",
|
||||
"type": "BOOLEAN",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveBoolean" },
|
||||
"widgets_values": [false]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "BOOLEAN"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_float.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_float0",
|
||||
"localized_name": "floats.u_float0",
|
||||
"name": "floats.u_float0",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform float u_float0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(u_float0, v_texCoord.y, 0.5, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "FLOAT",
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveFloat" },
|
||||
"widgets_values": [0.25]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "FLOAT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_int.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_int0",
|
||||
"localized_name": "ints.u_int0",
|
||||
"name": "ints.u_int0",
|
||||
"shape": 7,
|
||||
"type": "INT",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform int u_int0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(float(u_int0) / 100.0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 82],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveInt" },
|
||||
"widgets_values": [25, "randomize"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
92
browser_tests/assets/nodes/glsl_shader_with_loadimage.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [1] },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "image0",
|
||||
"localized_name": "images.image0",
|
||||
"name": "images.image0",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform sampler2D u_image0;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = texture(u_image0, v_texCoord);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [400, 50],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Note",
|
||||
"pos": [50, 50],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["This is a reference note"],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [50, 250],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["# Markdown heading"],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -316,11 +312,20 @@ export class ComfyPage {
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
await this.waitForAppReady()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the app to finish initializing after navigation/reload:
|
||||
* `window.app.extensionManager` is present, the PrimeVue block-UI mask is
|
||||
* hidden, and one animation frame has elapsed. Shared by `setup()` and
|
||||
* `WorkflowHelper.reloadAndWaitForApp()`.
|
||||
*/
|
||||
async waitForAppReady() {
|
||||
await this.page.waitForFunction(
|
||||
() =>
|
||||
// window.app => GraphCanvas ready
|
||||
// window.app.extensionManager => GraphView ready
|
||||
window.app && window.app.extensionManager
|
||||
// window.app => GraphCanvas ready
|
||||
// window.app.extensionManager => GraphView ready
|
||||
() => window.app?.extensionManager
|
||||
)
|
||||
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
|
||||
await this.nextFrame()
|
||||
@@ -499,7 +504,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
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class CancelSubscriptionDialog extends BaseDialog {
|
||||
readonly heading: Locator
|
||||
readonly keepSubscriptionButton: Locator
|
||||
readonly confirmCancelButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.heading = this.root.getByRole('heading', {
|
||||
name: 'Cancel subscription'
|
||||
})
|
||||
this.keepSubscriptionButton = this.root.getByRole('button', {
|
||||
name: 'Keep subscription'
|
||||
})
|
||||
this.confirmCancelButton = this.root.getByRole('button', {
|
||||
name: 'Cancel subscription'
|
||||
})
|
||||
}
|
||||
|
||||
async open(cancelAt?: string) {
|
||||
await this.page.evaluate((date) => {
|
||||
void (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).dialog.showCancelSubscriptionDialog(date)
|
||||
}, cancelAt)
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
54
browser_tests/fixtures/components/TopUpCreditsDialog.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class TopUpCreditsDialog extends BaseDialog {
|
||||
readonly heading: Locator
|
||||
readonly insufficientHeading: Locator
|
||||
readonly preset10: Locator
|
||||
readonly preset25: Locator
|
||||
readonly preset50: Locator
|
||||
readonly preset100: Locator
|
||||
readonly payAmountInput: Locator
|
||||
readonly pricingLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.heading = this.root.getByRole('heading', { name: 'Add more credits' })
|
||||
this.insufficientHeading = this.root.getByRole('heading', {
|
||||
name: 'Add more credits to run'
|
||||
})
|
||||
this.preset10 = this.root.getByRole('button', {
|
||||
name: '$10',
|
||||
exact: true
|
||||
})
|
||||
this.preset25 = this.root.getByRole('button', {
|
||||
name: '$25',
|
||||
exact: true
|
||||
})
|
||||
this.preset50 = this.root.getByRole('button', {
|
||||
name: '$50',
|
||||
exact: true
|
||||
})
|
||||
this.preset100 = this.root.getByRole('button', {
|
||||
name: '$100',
|
||||
exact: true
|
||||
})
|
||||
this.payAmountInput = this.root
|
||||
.getByTestId('top-up-pay-amount')
|
||||
.locator('input')
|
||||
this.pricingLink = this.root.getByRole('link', {
|
||||
name: 'View pricing details'
|
||||
})
|
||||
}
|
||||
|
||||
async open(options?: { isInsufficientCredits?: boolean }) {
|
||||
await this.page.evaluate((opts) => {
|
||||
void (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).dialog.showTopUpCreditsDialog(opts)
|
||||
}, options)
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,16 +60,25 @@ export class WorkflowHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async waitForDraftPersisted({ timeout = 5000 } = {}) {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
),
|
||||
{ timeout }
|
||||
async waitForDraftPersisted() {
|
||||
await this.comfyPage.page.waitForFunction(() =>
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the current page and waits for the app to initialize.
|
||||
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and
|
||||
* the URL hash (subgraph navigation state), so the app restores
|
||||
* exactly where the user left off.
|
||||
*/
|
||||
async reloadAndWaitForApp() {
|
||||
await this.comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await this.comfyPage.waitForAppReady()
|
||||
}
|
||||
|
||||
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.comfyPage.page.evaluate(
|
||||
(wf) => window.app!.loadGraphData(wf),
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
44
browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { CancelSubscriptionDialog } from '@e2e/fixtures/components/CancelSubscriptionDialog'
|
||||
|
||||
test.describe('CancelSubscription dialog', { tag: '@ui' }, () => {
|
||||
let dialog: CancelSubscriptionDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
dialog = new CancelSubscriptionDialog(comfyPage.page)
|
||||
})
|
||||
|
||||
test('displays dialog with title and formatted date', async () => {
|
||||
await dialog.open('2025-12-31T12:00:00Z')
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
await expect(dialog.root).toContainText('December 31, 2025')
|
||||
})
|
||||
|
||||
test('"Keep subscription" button closes dialog', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await dialog.keepSubscriptionButton.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dialog', async ({ comfyPage }) => {
|
||||
await dialog.open()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('"Cancel subscription" button initiates cancellation flow', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.confirmCancelButton).toBeEnabled()
|
||||
|
||||
await dialog.confirmCancelButton.click()
|
||||
|
||||
// Next state: dialog closes once the cancellation flow completes
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
58
browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
|
||||
|
||||
test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
|
||||
let dialog: TopUpCreditsDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
dialog = new TopUpCreditsDialog(comfyPage.page)
|
||||
})
|
||||
|
||||
test('displays dialog with heading and preset amounts', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
await expect(dialog.preset10).toBeVisible()
|
||||
await expect(dialog.preset25).toBeVisible()
|
||||
await expect(dialog.preset50).toBeVisible()
|
||||
await expect(dialog.preset100).toBeVisible()
|
||||
})
|
||||
|
||||
test('displays insufficient credits message when opened with flag', async () => {
|
||||
await dialog.open({ isInsufficientCredits: true })
|
||||
|
||||
await expect(dialog.insufficientHeading).toBeVisible()
|
||||
await expect(dialog.root).toContainText(
|
||||
"You don't have enough credits to run this workflow"
|
||||
)
|
||||
})
|
||||
|
||||
test('selecting a preset amount updates the pay amount', async () => {
|
||||
await dialog.open()
|
||||
|
||||
// Default preset is $50, click $10 instead
|
||||
await dialog.preset10.click()
|
||||
|
||||
await expect(dialog.payAmountInput).toHaveValue('10')
|
||||
})
|
||||
|
||||
test('close button dismisses dialog', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await dialog.closeButton.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('pricing details link points to docs pricing page', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.pricingLink).toBeVisible()
|
||||
await expect(dialog.pricingLink).toHaveAttribute(
|
||||
'href',
|
||||
/partner-nodes\/pricing/
|
||||
)
|
||||
await expect(dialog.pricingLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
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
@@ -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
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Note Node API Export', { tag: '@node' }, () => {
|
||||
test('excludes Note and MarkdownNote from API format export', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const classTypes = Object.values(apiWorkflow).map((n) => n.class_type)
|
||||
expect(classTypes, 'API output should not contain Note').not.toContain(
|
||||
'Note'
|
||||
)
|
||||
expect(
|
||||
classTypes,
|
||||
'API output should not contain MarkdownNote'
|
||||
).not.toContain('MarkdownNote')
|
||||
expect(
|
||||
Object.keys(apiWorkflow),
|
||||
'All-virtual workflow should produce empty API output'
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('preserves real nodes while filtering virtual ones', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const entries = Object.values(apiWorkflow)
|
||||
expect(entries, 'Exactly one real node in API output').toHaveLength(1)
|
||||
expect(entries[0].class_type).toBe('KSampler')
|
||||
})
|
||||
|
||||
test('standard workflow export still includes Note nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
const noteNodes = workflow.nodes.filter(
|
||||
(n) => n.type === 'Note' || n.type === 'MarkdownNote'
|
||||
)
|
||||
expect(
|
||||
noteNodes,
|
||||
'Standard export must preserve both Note and MarkdownNote'
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('no virtual node types leak through graphToPrompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
|
||||
|
||||
const virtualNodeCheck = await comfyPage.page.evaluate(async () => {
|
||||
const { output } = await window.app!.graphToPrompt()
|
||||
const virtualTypes = ['Note', 'MarkdownNote', 'Reroute', 'PrimitiveNode']
|
||||
const leaked: string[] = []
|
||||
for (const node of Object.values(output)) {
|
||||
if (virtualTypes.includes(node.class_type)) {
|
||||
leaked.push(node.class_type)
|
||||
}
|
||||
}
|
||||
return { leaked, totalNodes: Object.keys(output).length }
|
||||
})
|
||||
|
||||
expect(
|
||||
virtualNodeCheck.leaked,
|
||||
'No virtual node types should leak into API output'
|
||||
).toHaveLength(0)
|
||||
expect(virtualNodeCheck.totalNodes).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
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
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,83 +1,65 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
interface SubgraphNodePosition {
|
||||
id: NodeId
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function getSubgraphNodePositions(): SubgraphNodePosition[] {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph node positions after draft reload',
|
||||
{ tag: ['@subgraph'] },
|
||||
{ tag: ['@subgraph', '@vue-nodes'] },
|
||||
() => {
|
||||
test('Node positions are preserved after draft reload with subgraph auto-entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30000)
|
||||
|
||||
// Enable workflow persistence explicitly
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
|
||||
|
||||
// Load a workflow containing a subgraph
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// Enter the subgraph programmatically (fixture node is too small for UI click)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
if (sg) window.app!.canvas.setGraph(sg)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
const positionsBefore = await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
})
|
||||
|
||||
let positionsBefore: SubgraphNodePosition[] = []
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const positions = await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
})
|
||||
return positions.length
|
||||
positionsBefore = await comfyPage.page.evaluate(
|
||||
getSubgraphNodePositions
|
||||
)
|
||||
return positionsBefore.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Wait for the debounced draft persistence to flush to localStorage
|
||||
expect(
|
||||
positionsBefore.length,
|
||||
'Expected nodes before reload'
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.workflow.waitForDraftPersisted()
|
||||
await comfyPage.workflow.reloadAndWaitForApp()
|
||||
|
||||
// Reload the page (draft auto-loads with hash preserved)
|
||||
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
await comfyPage.page.locator('.p-blockui-mask').waitFor({
|
||||
state: 'hidden'
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for subgraph auto-entry via hash navigation
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), { timeout: 10000 })
|
||||
.toBe(true)
|
||||
|
||||
// Verify all internal node positions are preserved
|
||||
for (const before of positionsBefore) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const positionsNow = await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
})
|
||||
const positionsNow = await comfyPage.page.evaluate(
|
||||
getSubgraphNodePositions
|
||||
)
|
||||
const after = positionsNow.find((n) => n.id === before.id)
|
||||
if (!after) return null
|
||||
return { x: after.x, y: after.y }
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
512
browser_tests/tests/vueNodes/glslPreview.spec.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import type { Locator, WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const GLSL_NODE_ID = '1'
|
||||
const GLSL_NODE_TITLE = 'GLSL Shader'
|
||||
const PRIMITIVE_FLOAT_NODE_TITLE = 'Float'
|
||||
const PRIMITIVE_INT_NODE_TITLE = 'Int'
|
||||
const PRIMITIVE_BOOLEAN_NODE_TITLE = 'Boolean'
|
||||
|
||||
const RED_SHADER = [
|
||||
'#version 300 es',
|
||||
'precision highp float;',
|
||||
'uniform vec2 u_resolution;',
|
||||
'in vec2 v_texCoord;',
|
||||
'layout(location = 0) out vec4 fragColor0;',
|
||||
'void main() {',
|
||||
' fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);',
|
||||
'}'
|
||||
].join('\n')
|
||||
|
||||
/** Shader that drives every pixel's red channel from `u_float0` alone. */
|
||||
const FLOAT_RED_SHADER = [
|
||||
'#version 300 es',
|
||||
'precision highp float;',
|
||||
'uniform float u_float0;',
|
||||
'layout(location = 0) out vec4 fragColor0;',
|
||||
'void main() {',
|
||||
' fragColor0 = vec4(u_float0, 0.0, 0.0, 1.0);',
|
||||
'}'
|
||||
].join('\n')
|
||||
|
||||
/** Wait until an `<img>` locator has finished decoding. */
|
||||
async function waitForImageDecoded(image: Locator): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
image.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
/** Page-object helper bound to the GLSLShader node under test. */
|
||||
class GLSLShaderNode {
|
||||
readonly node: Locator
|
||||
/**
|
||||
* Any `<img>` inside the node whose src is a `blob:` URL. Covers both
|
||||
* the standalone-node `LivePreview` path and the subgraph-wrapped
|
||||
* promoted-preview path (where the blob surfaces via `ImagePreview`).
|
||||
*/
|
||||
readonly previewImage: Locator
|
||||
readonly shaderTextbox: Locator
|
||||
readonly widthInput: Locator
|
||||
readonly heightInput: Locator
|
||||
|
||||
constructor(
|
||||
private readonly comfyPage: ComfyPage,
|
||||
readonly nodeId: string,
|
||||
readonly title: string
|
||||
) {
|
||||
this.node = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
this.previewImage = this.node.locator('img[src^="blob:"]')
|
||||
this.shaderTextbox = this.node.getByRole('textbox', {
|
||||
name: 'fragment_shader'
|
||||
})
|
||||
this.widthInput = this.node
|
||||
.getByLabel('size_mode.width', { exact: true })
|
||||
.locator('input')
|
||||
this.heightInput = this.node
|
||||
.getByLabel('size_mode.height', { exact: true })
|
||||
.locator('input')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `execution_start` + `executed` with an image output for this node,
|
||||
* which satisfies the `hasExecutionOutput` gate in `useGLSLPreview`.
|
||||
*/
|
||||
async simulateExecutionOutput(ws: WebSocketRoute) {
|
||||
const exec = new ExecutionHelper(this.comfyPage, ws)
|
||||
const jobId = await exec.run()
|
||||
await this.comfyPage.nextFrame()
|
||||
exec.executionStart(jobId)
|
||||
exec.executed(jobId, this.nodeId, {
|
||||
images: [{ filename: 'glsl_test.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
exec.executionSuccess(jobId)
|
||||
}
|
||||
|
||||
async getPreviewSrc(): Promise<string | null> {
|
||||
return this.previewImage.getAttribute('src')
|
||||
}
|
||||
|
||||
async getPreviewNaturalSize(): Promise<{ width: number; height: number }> {
|
||||
return this.previewImage.evaluate((el: HTMLImageElement) => ({
|
||||
width: el.naturalWidth,
|
||||
height: el.naturalHeight
|
||||
}))
|
||||
}
|
||||
|
||||
async selectSizeMode(option: 'from_input' | 'custom'): Promise<void> {
|
||||
await this.comfyPage.vueNodes.selectComboOption(
|
||||
this.title,
|
||||
'size_mode',
|
||||
option
|
||||
)
|
||||
}
|
||||
|
||||
/** Wait until the preview image has a blob: URL and return it. */
|
||||
async waitForBlobSrc(): Promise<string> {
|
||||
await expect.poll(() => this.getPreviewSrc()).toMatch(/^blob:/)
|
||||
return (await this.getPreviewSrc())!
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the preview blob to a 2D canvas and verify every pixel matches.
|
||||
*/
|
||||
async expectEveryPixelToBe(
|
||||
expected: [number, number, number, number],
|
||||
tolerance = 1
|
||||
): Promise<void> {
|
||||
await waitForImageDecoded(this.previewImage)
|
||||
const mismatch = await this.previewImage.evaluate(
|
||||
(
|
||||
img: HTMLImageElement,
|
||||
args: { exp: [number, number, number, number]; tol: number }
|
||||
) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
for (let c = 0; c < 4; c++) {
|
||||
if (Math.abs(data[i + c] - args.exp[c]) > args.tol) {
|
||||
return {
|
||||
index: i / 4,
|
||||
actual: [data[i], data[i + 1], data[i + 2], data[i + 3]]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
{ exp: expected, tol: tolerance }
|
||||
)
|
||||
const message = mismatch
|
||||
? `expected every pixel ≈ [${expected.join(',')}] ±${tolerance}; pixel ${mismatch.index} was [${mismatch.actual.join(',')}]`
|
||||
: undefined
|
||||
expect(mismatch, message).toBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an image file onto a LoadImage node and wait for its preview to render.
|
||||
*/
|
||||
async function dropImageOntoLoadImage(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
const box = await node.boundingBox()
|
||||
expect(
|
||||
box,
|
||||
`LoadImage node ${nodeId} must have a bounding box`
|
||||
).not.toBeNull()
|
||||
await comfyPage.dragDrop.dragAndDropFile(filename, {
|
||||
dropPosition: { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
|
||||
})
|
||||
const preview = node.locator('.image-preview img')
|
||||
await expect(preview).toBeVisible()
|
||||
await waitForImageDecoded(preview)
|
||||
}
|
||||
|
||||
test.describe('GLSL Shader Preview', { tag: ['@vue-nodes', '@node'] }, () => {
|
||||
test.describe('standalone node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_standalone')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('renders a blob preview into the node after execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await test.step('no preview is present before execution', async () => {
|
||||
await expect(glsl.previewImage).toHaveCount(0)
|
||||
})
|
||||
|
||||
await test.step('execution populates preview with a blob URL', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
|
||||
await expect(glsl.previewImage).toBeVisible()
|
||||
await glsl.waitForBlobSrc()
|
||||
})
|
||||
})
|
||||
|
||||
test('refreshes the preview when the fragment shader is edited', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await test.step('editing the shader replaces the blob URL', async () => {
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test('custom size_mode controls rendered resolution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await test.step('switch size_mode to custom and set width/height', async () => {
|
||||
await glsl.selectSizeMode('custom')
|
||||
|
||||
await expect(glsl.widthInput).toBeVisible()
|
||||
await expect(glsl.heightInput).toBeVisible()
|
||||
|
||||
await glsl.widthInput.fill('16')
|
||||
await glsl.widthInput.blur()
|
||||
await glsl.heightInput.fill('32')
|
||||
await glsl.heightInput.blur()
|
||||
})
|
||||
|
||||
await test.step('executed preview uses the custom resolution', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
|
||||
await expect(glsl.previewImage).toBeVisible()
|
||||
await glsl.waitForBlobSrc()
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 16, height: 32 })
|
||||
})
|
||||
})
|
||||
|
||||
test('logs a compile failure then recovers when shader becomes valid again', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
// Captures every `[GLSL] shader compilation failed` warning emitted
|
||||
// by `useGLSLPreview.ts` during this test.
|
||||
const compileFailure = comfyPage.page.waitForEvent('console', {
|
||||
predicate: (msg) =>
|
||||
msg.type() === 'warning' &&
|
||||
msg.text().includes('[GLSL] shader compilation failed')
|
||||
})
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const goodSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await glsl.shaderTextbox.fill('not valid glsl at all')
|
||||
await compileFailure // ensures the invalid shader actually hit the compiler
|
||||
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(goodSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive float source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_float')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('refreshes preview when upstream PrimitiveFloat value changes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const floatValueWidget = comfyPage.vueNodes.getWidgetByName(
|
||||
PRIMITIVE_FLOAT_NODE_TITLE,
|
||||
'value'
|
||||
)
|
||||
const { input: floatValueInput } =
|
||||
comfyPage.vueNodes.getInputNumberControls(floatValueWidget)
|
||||
|
||||
// Drive every pixel's red channel directly from u_float0 so the
|
||||
// before/after refresh is visually obvious (dim red → pure red).
|
||||
await glsl.shaderTextbox.fill(FLOAT_RED_SHADER)
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
// Workflow default is 0.25 → ~64; RGBA16F → PNG round-trip can drift.
|
||||
await glsl.expectEveryPixelToBe([64, 0, 0, 255], 2)
|
||||
|
||||
await test.step('changing the upstream float value re-renders the preview', async () => {
|
||||
await expect(floatValueInput).toBeVisible()
|
||||
await floatValueInput.fill('1.0')
|
||||
await floatValueInput.blur()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with upstream LoadImage', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_loadimage')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
const LOAD_IMAGE_NODE_ID = '2'
|
||||
|
||||
test('uses upstream image dimensions', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await dropImageOntoLoadImage(
|
||||
comfyPage,
|
||||
LOAD_IMAGE_NODE_ID,
|
||||
'image64x64.webp'
|
||||
)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 64, height: 64 })
|
||||
})
|
||||
|
||||
test('ensures shaders are correctly executed', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await dropImageOntoLoadImage(
|
||||
comfyPage,
|
||||
LOAD_IMAGE_NODE_ID,
|
||||
'image64x64.webp'
|
||||
)
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 64, height: 64 })
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive int source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_int')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('refreshes preview when upstream PrimitiveInt value changes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const intValueWidget = comfyPage.vueNodes.getWidgetByName(
|
||||
PRIMITIVE_INT_NODE_TITLE,
|
||||
'value'
|
||||
)
|
||||
const { input: intValueInput } =
|
||||
comfyPage.vueNodes.getInputNumberControls(intValueWidget)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await test.step('changing the upstream int value re-renders the preview', async () => {
|
||||
await expect(intValueInput).toBeVisible()
|
||||
await intValueInput.fill('100')
|
||||
await intValueInput.blur()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
await test.step('upstream int value flows through as the u_int0 uniform', async () => {
|
||||
// Shader writes vec4(float(u_int0) / 100.0, 0, 0, 1); value 100 → red.
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive boolean source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_bool')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('upstream PrimitiveBoolean value flows through as the u_bool0 uniform', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const booleanToggle = comfyPage.vueNodes
|
||||
.getNodeByTitle(PRIMITIVE_BOOLEAN_NODE_TITLE)
|
||||
.getByRole('switch', { name: 'value' })
|
||||
|
||||
await test.step('boolean=false renders blue', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
// Blue (non-max channel) through RGBA16F → PNG round-trip can drift by 2.
|
||||
await glsl.expectEveryPixelToBe([0, 0, 255, 255], 2)
|
||||
})
|
||||
|
||||
await test.step('toggling boolean=true re-renders red', async () => {
|
||||
const blueSrc = (await glsl.getPreviewSrc())!
|
||||
await expect(booleanToggle).toBeVisible()
|
||||
await booleanToggle.click()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(blueSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('GLSL inside a subgraph', () => {
|
||||
const SUBGRAPH_NODE_ID = '1'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_in_subgraph')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('renders a GLSL blob preview on the outer subgraph node', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
// Inside a subgraph, the GLSL renderer writes the blob preview to the
|
||||
// INNER GLSLShader's locator; the outer subgraph node surfaces it via
|
||||
// the promoted-preview path (ImagePreview component), not LivePreview.
|
||||
// Either way, the observable signal is an <img> with a blob: src.
|
||||
const subgraph = new GLSLShaderNode(
|
||||
comfyPage,
|
||||
SUBGRAPH_NODE_ID,
|
||||
'GLSL Subgraph'
|
||||
)
|
||||
|
||||
await subgraph.simulateExecutionOutput(ws)
|
||||
await expect(subgraph.previewImage).toBeVisible()
|
||||
await subgraph.waitForBlobSrc()
|
||||
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('GLSL inside a subgraph with uniform source', () => {
|
||||
const SUBGRAPH_NODE_ID = '1'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'nodes/glsl_shader_subgraph_with_float'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('extracts uniform sources from inner upstream widgets', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
// Inner PrimitiveFloat is wired to the inner GLSLShader's floats.u_float0
|
||||
// input. useGLSLUniforms.extractUniformSources should pick it up and feed
|
||||
// 1.0 as u_float0 — shader outputs vec4(u_float0, 0, 0, 1) → red.
|
||||
const subgraph = new GLSLShaderNode(
|
||||
comfyPage,
|
||||
SUBGRAPH_NODE_ID,
|
||||
'GLSL Subgraph With Float'
|
||||
)
|
||||
|
||||
await subgraph.simulateExecutionOutput(ws)
|
||||
await expect(subgraph.previewImage).toBeVisible()
|
||||
await subgraph.waitForBlobSrc()
|
||||
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
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')
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -209,7 +209,8 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'image_preview_drag_and_dropped.png'
|
||||
'image_preview_drag_and_dropped.png',
|
||||
{ maxDiffPixelRatio: 0.02 }
|
||||
)
|
||||
|
||||
// Expect the filename combo value to be updated
|
||||
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 43 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.
|
||||
|
||||
@@ -284,7 +284,10 @@ export default defineConfig([
|
||||
message:
|
||||
'Use vi.mock() with vi.hoisted() instead of vi.doMock(). See docs/testing/vitest-patterns.md'
|
||||
}
|
||||
]
|
||||
],
|
||||
// Tests routinely define stub and harness components side-by-side with the
|
||||
// system under test, which is a distinct use case from production SFCs.
|
||||
'vue/one-component-per-file': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
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()
|
||||
})
|
||||
|
||||
252
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -2907,7 +2907,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/generate instead. */
|
||||
/** Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/poll instead. */
|
||||
post: operations["veoPoll"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -10482,77 +10482,196 @@ export interface components {
|
||||
};
|
||||
VeoGenVidRequest: {
|
||||
instances?: {
|
||||
/** @description Text description of the video */
|
||||
/** @description Text description of the video to generate */
|
||||
prompt: string;
|
||||
/** @description Optional image to guide video generation */
|
||||
/** @description Optional first frame image to guide video generation */
|
||||
image?: {
|
||||
/** Format: byte */
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
mimeType?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/** @description Optional last frame image to guide video generation */
|
||||
/** @description Optional last frame image. Used with image to generate video between first and last frames. Supported by Veo 3.0+ models. */
|
||||
lastFrame?: {
|
||||
/** Format: byte */
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/** @description Optional reference images to guide video generation. Supports up to 3 asset images or 1 style image. Supported by Veo 3.1 models (preview). */
|
||||
referenceImages?: {
|
||||
image: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/**
|
||||
* @description Type of reference image
|
||||
* @enum {string}
|
||||
*/
|
||||
referenceType: "asset" | "style";
|
||||
/** @description Optional identifier for the reference image */
|
||||
referenceId?: string;
|
||||
}[];
|
||||
/** @description Optional input video for video extension or editing. Incompatible with image and referenceImages. */
|
||||
video?: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded video bytes
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the input video */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the video
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "video/mov" | "video/mpeg" | "video/mp4" | "video/mpg" | "video/avi" | "video/wmv" | "video/mpegps" | "video/x-flv";
|
||||
} & (unknown | unknown);
|
||||
/**
|
||||
* @description Camera motion type. Requires image to be provided.
|
||||
* @enum {string}
|
||||
*/
|
||||
cameraControl?: "fixed" | "pan_left" | "pan_right" | "tilt_up" | "tilt_down" | "truck_left" | "truck_right" | "pedestal_up" | "pedestal_down" | "push_in" | "pull_out";
|
||||
/** @description Optional mask for video editing. Applies to input video. */
|
||||
mask?: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded mask bytes
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI to mask file */
|
||||
gcsUri?: string;
|
||||
/** @description MIME type of the mask (image/png, image/jpeg, image/webp, or video formats) */
|
||||
mimeType?: string;
|
||||
/**
|
||||
* @description How the mask is applied
|
||||
* @enum {string}
|
||||
*/
|
||||
maskMode?: "insert" | "remove" | "remove_static" | "outpaint";
|
||||
} & (unknown | unknown);
|
||||
}[];
|
||||
parameters?: {
|
||||
/** @example 16:9 */
|
||||
aspectRatio?: string;
|
||||
/**
|
||||
* @description Aspect ratio of the generated video. Default: 16:9
|
||||
* @example 16:9
|
||||
* @enum {string}
|
||||
*/
|
||||
aspectRatio?: "16:9" | "9:16";
|
||||
/** @description Text describing what to avoid in the generated video */
|
||||
negativePrompt?: string;
|
||||
/** @enum {string} */
|
||||
personGeneration?: "ALLOW" | "BLOCK";
|
||||
/**
|
||||
* @description Controls people in generated videos. Default: allow_adult
|
||||
* @enum {string}
|
||||
*/
|
||||
personGeneration?: "dont_allow" | "allow_adult" | "allowAll";
|
||||
/** @description Number of videos to generate. If not specified, 1 video is generated. */
|
||||
sampleCount?: number;
|
||||
/** Format: uint32 */
|
||||
/**
|
||||
* Format: uint32
|
||||
* @description Random seed for deterministic output. Different seeds used per video if sampleCount > 1.
|
||||
*/
|
||||
seed?: number;
|
||||
/** @description Optional Cloud Storage URI to upload the video */
|
||||
/** @description Cloud Storage URI (gs://) for saving generated videos */
|
||||
storageUri?: string;
|
||||
/** @description Target duration of the generated video in seconds. Veo 2: 5-8. Veo 3/3.1: 4, 6, or 8. Default: 8 */
|
||||
durationSeconds?: number;
|
||||
/** @description Frame rate of generated videos in frames per second */
|
||||
fps?: number;
|
||||
/** @description Automatically improve prompt for higher quality. Defaults to true. */
|
||||
enhancePrompt?: boolean;
|
||||
/** @description Generate audio for the video. Only supported by veo 3 models. */
|
||||
/** @description Whether to generate audio along with the video. Defaults to true. Supported by Veo 3.0+ models. */
|
||||
generateAudio?: boolean;
|
||||
/**
|
||||
* @description Output video resolution. Supported by Veo 3.0+ models. Default: 720p
|
||||
* @enum {string}
|
||||
*/
|
||||
resolution?: "720p" | "1080p" | "4k";
|
||||
/**
|
||||
* @description Resize approach for input image. Default: pad
|
||||
* @enum {string}
|
||||
*/
|
||||
resizeMode?: "pad" | "crop";
|
||||
/**
|
||||
* @description Video compression quality. Default: optimized
|
||||
* @enum {string}
|
||||
*/
|
||||
compressionQuality?: "optimized" | "lossless";
|
||||
/**
|
||||
* @description Operation type for the video generation request
|
||||
* @enum {string}
|
||||
*/
|
||||
task?: "textToVideo" | "imageToVideo" | "referenceToVideo" | "edit" | "extend" | "upscale";
|
||||
/** @description Cloud Pub/Sub topic for progress updates (projects/{project}/topics/{topic}) */
|
||||
pubsubTopic?: string;
|
||||
};
|
||||
};
|
||||
/** @description Response from a Veo video generation request. Contains the operation name for polling. */
|
||||
VeoGenVidResponse: {
|
||||
/**
|
||||
* @description Operation resource name
|
||||
* @description Operation resource name used to poll for results via fetchPredictOperation
|
||||
* @example projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/a1b07c8e-7b5a-4aba-bb34-3e1ccb8afcc8
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
VeoGenVidPollRequest: {
|
||||
/**
|
||||
* @description Full operation name (from predict response)
|
||||
* @description Full operation name returned from the generate response
|
||||
* @example projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/OPERATION_ID
|
||||
*/
|
||||
operationName: string;
|
||||
};
|
||||
/** @description Response from polling a Veo video generation operation */
|
||||
VeoGenVidPollResponse: {
|
||||
/** @description Operation resource name */
|
||||
name?: string;
|
||||
/** @description Whether the operation has completed */
|
||||
done?: boolean;
|
||||
/** @description The actual prediction response if done is true */
|
||||
/** @description The prediction response, present when done is true */
|
||||
response?: {
|
||||
/** @example type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse */
|
||||
"@type"?: string;
|
||||
/** @description Count of media filtered by responsible AI policies */
|
||||
/** @description Number of videos filtered by responsible AI policies */
|
||||
raiMediaFilteredCount?: number;
|
||||
/** @description Reasons why media was filtered by responsible AI policies */
|
||||
/** @description Reasons why videos were filtered by responsible AI policies */
|
||||
raiMediaFilteredReasons?: string[];
|
||||
videos?: {
|
||||
/** @description Cloud Storage URI of the video */
|
||||
/** @description Cloud Storage URI of the generated video */
|
||||
gcsUri?: string;
|
||||
/** @description Base64-encoded video content */
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Video MIME type */
|
||||
/** @description Video MIME type (video/mp4) */
|
||||
mimeType?: string;
|
||||
}[];
|
||||
};
|
||||
/** @description Error details if operation failed */
|
||||
/** @description Error details, present if the operation failed */
|
||||
error?: {
|
||||
/** @description Error code */
|
||||
/** @description gRPC error code */
|
||||
code?: number;
|
||||
/** @description Error message */
|
||||
message?: string;
|
||||
@@ -10653,8 +10772,8 @@ export interface components {
|
||||
};
|
||||
OpenAIImageGenerationRequest: {
|
||||
/**
|
||||
* @description The model to use for image generation
|
||||
* @example dall-e-3
|
||||
* @description The model to use for image generation (e.g., dall-e-2, dall-e-3, gpt-image-1, gpt-image-1.5, gpt-image-2)
|
||||
* @example gpt-image-2
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
@@ -10721,8 +10840,8 @@ export interface components {
|
||||
};
|
||||
OpenAIImageEditRequest: {
|
||||
/**
|
||||
* @description The model to use for image editing
|
||||
* @example gpt-image-1
|
||||
* @description The model to use for image editing (e.g., dall-e-2, gpt-image-1, gpt-image-1.5, gpt-image-2)
|
||||
* @example gpt-image-2
|
||||
*/
|
||||
model: string;
|
||||
/**
|
||||
@@ -15951,40 +16070,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 +16106,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 +16153,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;
|
||||
};
|
||||
};
|
||||
@@ -27164,8 +27288,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The ID of the model to use for generation */
|
||||
modelId: string;
|
||||
/** @description The Veo model ID to use for generation */
|
||||
modelId: "veo-2.0-generate-001" | "veo-3.0-generate-001" | "veo-3.0-fast-generate-001" | "veo-3.1-generate-001" | "veo-3.1-fast-generate-001" | "veo-3.1-lite-generate-001";
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -27219,8 +27343,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The ID of the model to use for generation */
|
||||
modelId: string;
|
||||
/** @description The Veo model ID */
|
||||
modelId: "veo-2.0-generate-001" | "veo-3.0-generate-001" | "veo-3.0-fast-generate-001" | "veo-3.1-generate-001" | "veo-3.1-fast-generate-001" | "veo-3.1-lite-generate-001";
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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('')
|
||||
|
||||
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
@@ -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'
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
@@ -52,7 +53,7 @@
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="flex flex-1 flex-col gap-3" data-testid="top-up-pay-amount">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
|
||||
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
@@ -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>
|
||||
|
||||
246
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
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 { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const cropHolder = vi.hoisted(() => ({
|
||||
state: null as Record<string, unknown> | null
|
||||
}))
|
||||
|
||||
function createDefaultCropState() {
|
||||
return {
|
||||
imageUrl: ref<string | null>(null),
|
||||
isLoading: ref(false),
|
||||
selectedRatio: ref('1:1'),
|
||||
isLockEnabled: ref(false),
|
||||
cropBoxStyle: ref({}),
|
||||
resizeHandles: ref([]),
|
||||
handleImageLoad: () => {},
|
||||
handleImageError: () => {},
|
||||
handleDragStart: () => {},
|
||||
handleDragMove: () => {},
|
||||
handleDragEnd: () => {},
|
||||
handleResizeStart: () => {},
|
||||
handleResizeMove: () => {},
|
||||
handleResizeEnd: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useImageCrop', async () => {
|
||||
return {
|
||||
ASPECT_RATIOS: {
|
||||
'1:1': 1,
|
||||
'4:3': 4 / 3,
|
||||
custom: null
|
||||
},
|
||||
useImageCrop: () => {
|
||||
if (!cropHolder.state) {
|
||||
cropHolder.state = createDefaultCropState()
|
||||
}
|
||||
return cropHolder.state
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const upstreamHolder = vi.hoisted(() => ({
|
||||
ref: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useUpstreamValue', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useUpstreamValue: () => {
|
||||
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
|
||||
return upstreamHolder.ref
|
||||
},
|
||||
boundsExtractor: () => () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
import WidgetImageCrop from './WidgetImageCrop.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
imageCrop: {
|
||||
loading: 'Loading...',
|
||||
noInputImage: 'No input image connected',
|
||||
cropPreviewAlt: 'Crop preview',
|
||||
ratio: 'Ratio',
|
||||
lockRatio: 'Lock aspect ratio',
|
||||
unlockRatio: 'Unlock aspect ratio',
|
||||
custom: 'Custom'
|
||||
},
|
||||
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'Button',
|
||||
inheritAttrs: false,
|
||||
template: '<button v-bind="$attrs" type="button"><slot /></button>'
|
||||
})
|
||||
|
||||
const Passthrough = defineComponent({
|
||||
template: '<div><slot /></div>'
|
||||
})
|
||||
|
||||
const WidgetBoundingBoxStub = defineComponent({
|
||||
name: 'WidgetBoundingBox',
|
||||
props: {
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `<div data-testid="bbox-child"
|
||||
:data-disabled="String(disabled)"
|
||||
:data-model="JSON.stringify(modelValue)"
|
||||
@click="$emit('update:modelValue', { x: 1, y: 2, width: 3, height: 4 })"
|
||||
/>`
|
||||
})
|
||||
|
||||
function primeCropState(overrides: Record<string, unknown> = {}) {
|
||||
cropHolder.state = {
|
||||
...createDefaultCropState(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWidget(
|
||||
overrides: Partial<SimplifiedWidget<Bounds>> = {}
|
||||
): SimplifiedWidget<Bounds> {
|
||||
return {
|
||||
name: 'crop',
|
||||
type: 'imagecrop',
|
||||
value: { x: 0, y: 0, width: 512, height: 512 },
|
||||
options: {},
|
||||
...overrides
|
||||
} as SimplifiedWidget<Bounds>
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
widget: SimplifiedWidget<Bounds> = makeWidget(),
|
||||
initialModel: Bounds = { x: 0, y: 0, width: 512, height: 512 }
|
||||
) {
|
||||
const value = ref<Bounds>(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetImageCrop },
|
||||
setup: () => ({ value, widget }),
|
||||
template:
|
||||
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="1" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
Select: Passthrough,
|
||||
SelectContent: Passthrough,
|
||||
SelectTrigger: Passthrough,
|
||||
SelectValue: Passthrough,
|
||||
SelectItem: Passthrough,
|
||||
WidgetBoundingBox: WidgetBoundingBoxStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
}
|
||||
|
||||
describe('WidgetImageCrop', () => {
|
||||
beforeEach(() => {
|
||||
cropHolder.state = null
|
||||
upstreamHolder.ref = null
|
||||
})
|
||||
|
||||
describe('Image states', () => {
|
||||
it('shows the empty-state placeholder when imageUrl is null', () => {
|
||||
primeCropState()
|
||||
renderWidget()
|
||||
expect(screen.getByTestId('crop-empty-state')).toBeInTheDocument()
|
||||
expect(screen.getByText('No input image connected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the loading message when isLoading is true', () => {
|
||||
primeCropState({ isLoading: ref(true), imageUrl: ref('/img.png') })
|
||||
renderWidget()
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('crop-empty-state')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders an img when imageUrl is set and not loading', () => {
|
||||
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
|
||||
renderWidget()
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'Crop preview' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('Loading...')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the crop overlay when an image is loaded', () => {
|
||||
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
|
||||
renderWidget()
|
||||
expect(screen.getByTestId('crop-overlay')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('hides the ratio controls when widget is disabled', () => {
|
||||
renderWidget(makeWidget({ options: { disabled: true } }))
|
||||
expect(screen.queryByText('Ratio')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the ratio controls when widget is enabled', () => {
|
||||
renderWidget()
|
||||
expect(screen.getByText('Ratio')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes disabled=true to the bounding box child when disabled', () => {
|
||||
renderWidget(makeWidget({ options: { disabled: true } }))
|
||||
expect(screen.getByTestId('bbox-child').dataset.disabled).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bounds delegation', () => {
|
||||
it('forwards v-model to the bounding box child', () => {
|
||||
renderWidget(undefined, { x: 5, y: 10, width: 100, height: 200 })
|
||||
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
|
||||
expect(parsed).toEqual({ x: 5, y: 10, width: 100, height: 200 })
|
||||
})
|
||||
|
||||
it('updates v-model when the bounding box emits a change', async () => {
|
||||
const { value } = renderWidget()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('bbox-child'))
|
||||
expect(value.value).toEqual({ x: 1, y: 2, width: 3, height: 4 })
|
||||
})
|
||||
|
||||
it('uses upstream bounds when disabled and upstream is available', () => {
|
||||
upstreamHolder.ref = ref<unknown>({
|
||||
x: 7,
|
||||
y: 8,
|
||||
width: 20,
|
||||
height: 30
|
||||
})
|
||||
renderWidget(
|
||||
makeWidget({
|
||||
options: { disabled: true },
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
}),
|
||||
{ x: 0, y: 0, width: 512, height: 512 }
|
||||
)
|
||||
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
|
||||
expect(parsed).toEqual({ x: 7, y: 8, width: 20, height: 30 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,4 +1,3 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render, fireEvent } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -90,7 +90,6 @@ vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
|
||||
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
default: defineComponent({
|
||||
name: 'CurrentUserPopoverLegacyMock',
|
||||
emits: ['close'],
|
||||
@@ -133,7 +132,6 @@ describe('CurrentUserButton', () => {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
Popover: defineComponent({
|
||||
setup(_, { slots, expose }) {
|
||||
const shown = ref(false)
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||