mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-09 05:49:59 +00:00
Compare commits
2 Commits
coverage-s
...
range-edit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2019704fc3 | ||
|
|
b95636fc81 |
65
.github/actions/find-workflow-run/action.yaml
vendored
65
.github/actions/find-workflow-run/action.yaml
vendored
@@ -1,65 +0,0 @@
|
||||
name: Find Workflow Run
|
||||
description: Finds a workflow run for a given commit SHA and outputs its status and run ID.
|
||||
|
||||
inputs:
|
||||
workflow-id:
|
||||
description: The workflow filename (e.g., 'ci-size-data.yaml')
|
||||
required: true
|
||||
head-sha:
|
||||
description: The commit SHA to find runs for
|
||||
required: true
|
||||
not-found-status:
|
||||
description: Status to output when no run exists
|
||||
required: false
|
||||
default: pending
|
||||
token:
|
||||
description: GitHub token for API access
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
status:
|
||||
description: One of 'ready', 'pending', 'failed', or the not-found-status value
|
||||
value: ${{ steps.find.outputs.status }}
|
||||
run-id:
|
||||
description: The workflow run ID (only set when status is 'ready')
|
||||
value: ${{ steps.find.outputs.run-id }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Find workflow run
|
||||
id: find
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
WORKFLOW_ID: ${{ inputs.workflow-id }}
|
||||
HEAD_SHA: ${{ inputs.head-sha }}
|
||||
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
script: |
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: process.env.WORKFLOW_ID,
|
||||
head_sha: process.env.HEAD_SHA,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', process.env.NOT_FOUND_STATUS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
64
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
64
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -1,64 +0,0 @@
|
||||
name: 'CI: E2E Coverage'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Tests E2E']
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: e2e-coverage-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download all shard coverage data
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: e2e-coverage-shard-.*
|
||||
name_is_regexp: true
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
# Concatenate all per-shard LCOV files into one
|
||||
find temp/coverage-shards -name 'coverage.lcov' -exec cat {} + > coverage/playwright/coverage.lcov
|
||||
echo "Merged coverage from $(find temp/coverage-shards -name 'coverage.lcov' | wc -l) shards"
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
path: coverage/playwright/
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
flags: e2e
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
10
.github/workflows/ci-tests-e2e.yaml
vendored
10
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -86,7 +86,6 @@ jobs:
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
COLLECT_COVERAGE: 'true'
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -96,15 +95,6 @@ jobs:
|
||||
path: blob-report/
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload shard coverage data
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-shard-${{ matrix.shardIndex }}
|
||||
path: coverage/playwright/
|
||||
retention-days: 1
|
||||
if-no-files-found: warn
|
||||
|
||||
playwright-tests:
|
||||
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
|
||||
timeout-minutes: 15
|
||||
|
||||
11
.github/workflows/ci-tests-unit.yaml
vendored
11
.github/workflows/ci-tests-unit.yaml
vendored
@@ -26,20 +26,9 @@ jobs:
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
if: always() && github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unit-coverage
|
||||
path: coverage/lcov.info
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
149
.github/workflows/coverage-slack-notify.yaml
vendored
149
.github/workflows/coverage-slack-notify.yaml
vendored
@@ -1,149 +0,0 @@
|
||||
name: 'Coverage: Slack Notification'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Tests Unit']
|
||||
branches: [main]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download current unit coverage
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: unit-coverage
|
||||
path: coverage
|
||||
|
||||
- name: Download previous unit coverage baseline
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: main
|
||||
workflow: coverage-slack-notify.yaml
|
||||
name: unit-coverage-baseline
|
||||
path: temp/coverage-baseline
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download latest E2E coverage
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: main
|
||||
workflow: ci-tests-e2e-coverage.yaml
|
||||
name: e2e-coverage
|
||||
path: temp/e2e-coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download previous E2E coverage baseline
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: main
|
||||
workflow: coverage-slack-notify.yaml
|
||||
name: e2e-coverage-baseline
|
||||
path: temp/e2e-coverage-baseline
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Resolve merged PR metadata
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const sha = context.payload.workflow_run.head_sha;
|
||||
const { data: commit } = await github.rest.repos.getCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: sha,
|
||||
});
|
||||
const message = commit.commit.message ?? '';
|
||||
const firstLine = message.split('\n')[0];
|
||||
const match = firstLine.match(/\(#(\d+)\)\s*$/);
|
||||
if (!match) {
|
||||
core.setOutput('skip', 'true');
|
||||
core.info('No PR number found in commit message — skipping.');
|
||||
return;
|
||||
}
|
||||
const prNumber = match[1];
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', prNumber);
|
||||
core.setOutput('url', pr.html_url);
|
||||
core.setOutput('author', pr.user.login);
|
||||
|
||||
- name: Generate Slack notification
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: slack-payload
|
||||
env:
|
||||
PR_URL: ${{ steps.pr-meta.outputs.url }}
|
||||
PR_NUMBER: ${{ steps.pr-meta.outputs.number }}
|
||||
PR_AUTHOR: ${{ steps.pr-meta.outputs.author }}
|
||||
run: |
|
||||
PAYLOAD=$(pnpm exec tsx scripts/coverage-slack-notify.ts \
|
||||
--pr-url="$PR_URL" \
|
||||
--pr-number="$PR_NUMBER" \
|
||||
--author="$PR_AUTHOR")
|
||||
if [ -n "$PAYLOAD" ]; then
|
||||
echo "has_payload=true" >> "$GITHUB_OUTPUT"
|
||||
DELIM="SLACK_PAYLOAD_$(date +%s)"
|
||||
echo "payload<<$DELIM" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$PAYLOAD" >> "$GITHUB_OUTPUT"
|
||||
echo "$DELIM" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_payload=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post to Slack
|
||||
if: steps.slack-payload.outputs.has_payload == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
SLACK_PAYLOAD: ${{ steps.slack-payload.outputs.payload }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
run: |
|
||||
# Channel: #p-frontend-automated-testing
|
||||
BODY=$(echo "$SLACK_PAYLOAD" | jq --arg ch "C0AP09LKRDZ" '. + {channel: $ch}')
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
-o /dev/null \
|
||||
https://slack.com/api/chat.postMessage
|
||||
|
||||
- name: Save unit coverage baseline
|
||||
if: always() && hashFiles('coverage/lcov.info') != ''
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unit-coverage-baseline
|
||||
path: coverage/lcov.info
|
||||
retention-days: 90
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Save E2E coverage baseline
|
||||
if: always() && hashFiles('temp/e2e-coverage/coverage.lcov') != ''
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-baseline
|
||||
path: temp/e2e-coverage/coverage.lcov
|
||||
retention-days: 90
|
||||
if-no-files-found: warn
|
||||
94
.github/workflows/pr-report.yaml
vendored
94
.github/workflows/pr-report.yaml
vendored
@@ -2,7 +2,7 @@ name: 'PR: Unified Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
|
||||
workflows: ['CI: Size Data', 'CI: Performance Report']
|
||||
types:
|
||||
- completed
|
||||
|
||||
@@ -67,23 +67,73 @@ jobs:
|
||||
core.setOutput('base', livePr.base.ref);
|
||||
core.setOutput('head-sha', livePr.head.sha);
|
||||
|
||||
- name: Find size workflow run
|
||||
- name: Find size workflow run for this commit
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-size
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
workflow-id: ci-size-data.yaml
|
||||
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci-size-data.yaml',
|
||||
head_sha: headSha,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
- name: Find perf workflow run
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
|
||||
- name: Find perf workflow run for this commit
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-perf
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
workflow-id: ci-perf-report.yaml
|
||||
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci-perf-report.yaml',
|
||||
head_sha: headSha,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
|
||||
- name: Download size data (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
|
||||
@@ -104,25 +154,6 @@ jobs:
|
||||
path: temp/size-prev
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Find coverage workflow run
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-coverage
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
with:
|
||||
workflow-id: ci-tests-e2e-coverage.yaml
|
||||
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
|
||||
not-found-status: skip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download coverage data
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-coverage.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: e2e-coverage
|
||||
run_id: ${{ steps.find-coverage.outputs.run-id }}
|
||||
path: temp/coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download perf metrics (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
@@ -158,10 +189,9 @@ jobs:
|
||||
- name: Generate unified report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
run: >
|
||||
pnpm exec tsx scripts/unified-report.ts
|
||||
node scripts/unified-report.js
|
||||
--size-status=${{ steps.find-size.outputs.status }}
|
||||
--perf-status=${{ steps.find-perf.outputs.status }}
|
||||
--coverage-status=${{ steps.find-coverage.outputs.status }}
|
||||
> pr-report.md
|
||||
|
||||
- name: Remove legacy separate comments
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [627.5973510742188, 423.0972900390625],
|
||||
"size": [144.15234375, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [1],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [554.8743286132812, 100.95539093017578],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [685.1265869140625, 439.1734619140625],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [58.7671207025881, 137.7124650620126]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
@@ -401,28 +400,10 @@ export class ComfyPage {
|
||||
|
||||
export const testComfySnapToGridGridSize = 50
|
||||
|
||||
const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
}>({
|
||||
page: async ({ page, browserName }, use) => {
|
||||
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
|
||||
return use(page)
|
||||
}
|
||||
|
||||
await page.coverage.startJSCoverage({ resetOnNavigation: false })
|
||||
await use(page)
|
||||
const coverage = await page.coverage.stopJSCoverage()
|
||||
|
||||
const mcr = MCR({
|
||||
outputDir: './coverage/playwright',
|
||||
reports: []
|
||||
})
|
||||
await mcr.add(coverage)
|
||||
},
|
||||
|
||||
comfyPage: async ({ page, request }, use, testInfo) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
@@ -126,69 +99,41 @@ export class BuilderSelectHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a widget on the canvas to select it as a builder input.
|
||||
* @param nodeTitle The displayed title of the node.
|
||||
* @param widgetName The widget name to click.
|
||||
*/
|
||||
async selectInputWidget(nodeTitle: string, widgetName: string) {
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async selectInputWidget(node: NodeReference) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
const nodeRef = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
await node.centerOnNode()
|
||||
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
get inputItemTitles(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
}
|
||||
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
get previewWidgetLabels(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.widgetLabel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an IoItem from one index to another in the inputs step.
|
||||
* Items are identified by their 0-based position among visible IoItems.
|
||||
*/
|
||||
async dragInputItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a widget item from one index to another in the preview/arrange step.
|
||||
*/
|
||||
async dragPreviewItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.widgetItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an output node on the canvas to select it as a builder output.
|
||||
* @param nodeTitle The displayed title of the output node.
|
||||
*/
|
||||
async selectOutputNode(nodeTitle: string) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
const nodeRef = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||
String(nodeRef.id)
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await this.page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async selectOutputNode() {
|
||||
const saveImageNodeId = await this.page.evaluate(() => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!saveImageNodeId)
|
||||
throw new Error('SaveImage/PreviewImage node not found')
|
||||
const saveImageRef =
|
||||
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await this.page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await nodeLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +111,7 @@ export const TestIds = {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as',
|
||||
widgetItem: 'builder-widget-item',
|
||||
widgetLabel: 'builder-widget-label'
|
||||
opensAs: 'builder-opens-as'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { writePerfReport } from './helpers/perfReporter'
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
export default async function globalTeardown() {
|
||||
export default function globalTeardown() {
|
||||
writePerfReport()
|
||||
|
||||
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
|
||||
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])
|
||||
}
|
||||
|
||||
if (process.env.COLLECT_COVERAGE === 'true') {
|
||||
const mcr = MCR({
|
||||
outputDir: './coverage/playwright',
|
||||
reports: [['lcovonly', { file: 'coverage.lcov' }], ['text-summary']],
|
||||
sourceFilter: {
|
||||
'**/node_modules/**': false,
|
||||
'**/browser_tests/**': false,
|
||||
'**/*': true
|
||||
}
|
||||
})
|
||||
await mcr.generate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
import { comfyExpect } from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
interface BuilderSetupResult {
|
||||
inputNodeTitle: string
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
*
|
||||
@@ -20,48 +13,41 @@ interface BuilderSetupResult {
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param prepareGraph - Optional callback to transform the graph before
|
||||
* entering builder. Receives the KSampler node ref and returns the
|
||||
* input node title and widget names to select.
|
||||
* Defaults to KSampler with its first widget.
|
||||
* Mutually exclusive with widgetNames.
|
||||
* @param widgetNames - Widget names to select from the KSampler node.
|
||||
* Only used when prepareGraph is not provided.
|
||||
* Mutually exclusive with prepareGraph.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
prepareGraph?: (ksampler: NodeReference) => Promise<BuilderSetupResult>,
|
||||
widgetNames?: string[]
|
||||
): Promise<void> {
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
|
||||
const { inputNodeTitle, widgetNames: inputWidgets } = prepareGraph
|
||||
? await prepareGraph(ksampler)
|
||||
: { inputNodeTitle: 'KSampler', widgetNames: widgetNames ?? ['seed'] }
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
|
||||
for (const name of inputWidgets) {
|
||||
await appMode.select.selectInputWidget(inputNodeTitle, name)
|
||||
}
|
||||
await appMode.select.selectInputWidget(inputNode)
|
||||
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
return inputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
await setupBuilder(comfyPage, async (ksampler) => {
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -72,52 +58,10 @@ export async function setupSubgraphBuilder(
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return {
|
||||
inputNodeTitle: 'New Subgraph',
|
||||
widgetNames: ['seed']
|
||||
}
|
||||
return subgraphNode
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
export async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph' = 'App'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await comfyExpect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await comfyExpect(appMode.saveAs.successMessage).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
export async function openWorkflowFromSidebar(
|
||||
comfyPage: ComfyPage,
|
||||
name: string
|
||||
) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyExpect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain(name)
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
|
||||
async function saveCloseAndReopenAsApp(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Drag first input to last position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(0, 2)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
|
||||
test('Drag last input to first position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(2, 0)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'cfg',
|
||||
'seed',
|
||||
'steps'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'cfg',
|
||||
'seed',
|
||||
'steps'
|
||||
])
|
||||
})
|
||||
|
||||
test('Drag input to middle position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(0, 1)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'seed',
|
||||
'cfg'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'seed',
|
||||
'cfg'
|
||||
])
|
||||
})
|
||||
|
||||
test('Reorder in preview step reflects in app mode after save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragPreviewItem(0, 2)
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
const workflowName = `${Date.now()} reorder-preview`
|
||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
|
||||
test('Reorder inputs persists after save and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.dragInputItem(0, 2)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
const workflowName = `${Date.now()} reorder-persist`
|
||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -2,14 +2,45 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
async function openWorkflowFromSidebar(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain(name)
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* After a first save, open save-as again from the chevron,
|
||||
* fill name + view type, and save.
|
||||
@@ -172,9 +203,10 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
|
||||
// Select I/O to enable the button
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget('KSampler', 'seed')
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await appMode.select.selectInputWidget(ksampler)
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
// State 2: Enabled "Save as" (unsaved, has outputs)
|
||||
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Zero UUID workflow: subgraph undo rendering',
|
||||
{ tag: ['@workflow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
test.setTimeout(30000) // Extend timeout as we need to reload the page an additional time
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.page.reload() // Reload page as we need to enter in Vue mode
|
||||
await comfyPage.page.waitForFunction(() => !!window.app?.graph)
|
||||
})
|
||||
|
||||
test('Undo after subgraph enter/exit renders all nodes when workflow starts with zero UUID', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/basic-subgraph-zero-uuid'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const assertInSubgraph = async (inSubgraph: boolean) => {
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph())
|
||||
.toBe(inSubgraph)
|
||||
}
|
||||
|
||||
// Root graph has 1 subgraph node, rendered in the DOM
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await assertInSubgraph(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await assertInSubgraph(false)
|
||||
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// After undo, the subgraph node is still visible and rendered
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -44,7 +44,6 @@
|
||||
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "nx run test",
|
||||
@@ -175,7 +174,6 @@
|
||||
"lint-staged": "catalog:",
|
||||
"markdown-table": "catalog:",
|
||||
"mixpanel-browser": "catalog:",
|
||||
"monocart-coverage-reports": "catalog:",
|
||||
"nx": "catalog:",
|
||||
"oxfmt": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 226 B After Width: | Height: | Size: 236 B |
@@ -3,12 +3,15 @@ import type { PlaywrightTestConfig } from '@playwright/test'
|
||||
|
||||
const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
|
||||
? {
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
// VERY HELPFUL: Skip screenshot tests locally
|
||||
// grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/,
|
||||
timeout: 30_000, // Longer timeout for breakpoints
|
||||
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
|
||||
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
|
||||
|
||||
use: {
|
||||
trace: 'on',
|
||||
video: 'on'
|
||||
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
|
||||
video: 'on' // Always record video (CI uses 'retain-on-failure')
|
||||
}
|
||||
}
|
||||
: {
|
||||
@@ -33,7 +36,7 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 15000,
|
||||
grepInvert: /@mobile|@perf|@audit|@cloud/
|
||||
grepInvert: /@mobile|@perf|@audit|@cloud/ // Run all tests except those tagged with @mobile, @perf, @audit, or @cloud
|
||||
},
|
||||
|
||||
{
|
||||
@@ -62,28 +65,60 @@ export default defineConfig({
|
||||
name: 'chromium-2x',
|
||||
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 2 },
|
||||
timeout: 15000,
|
||||
grep: /@2x/
|
||||
grep: /@2x/ // Run all tests tagged with @2x
|
||||
},
|
||||
|
||||
{
|
||||
name: 'chromium-0.5x',
|
||||
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 0.5 },
|
||||
timeout: 15000,
|
||||
grep: /@0.5x/
|
||||
grep: /@0.5x/ // Run all tests tagged with @0.5x
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
{
|
||||
name: 'cloud',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 15000,
|
||||
grep: /@cloud/,
|
||||
grepInvert: /@oss/
|
||||
grep: /@cloud/, // Run only tests tagged with @cloud
|
||||
grepInvert: /@oss/ // Exclude tests tagged with @oss
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'mobile-chrome',
|
||||
use: { ...devices['Pixel 5'], hasTouch: true },
|
||||
grep: /@mobile/
|
||||
grep: /@mobile/ // Run only tests tagged with @mobile
|
||||
}
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
]
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'pnpm dev',
|
||||
// url: 'http://127.0.0.1:5173',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
})
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -276,9 +276,6 @@ catalogs:
|
||||
mixpanel-browser:
|
||||
specifier: ^2.71.0
|
||||
version: 2.71.0
|
||||
monocart-coverage-reports:
|
||||
specifier: ^2.12.9
|
||||
version: 2.12.9
|
||||
nx:
|
||||
specifier: 22.6.1
|
||||
version: 22.6.1
|
||||
@@ -765,9 +762,6 @@ importers:
|
||||
mixpanel-browser:
|
||||
specifier: 'catalog:'
|
||||
version: 2.71.0
|
||||
monocart-coverage-reports:
|
||||
specifier: 'catalog:'
|
||||
version: 2.12.9
|
||||
nx:
|
||||
specifier: 'catalog:'
|
||||
version: 22.6.1
|
||||
@@ -5003,14 +4997,6 @@ packages:
|
||||
peerDependencies:
|
||||
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
acorn-loose@8.5.2:
|
||||
resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn-walk@8.3.5:
|
||||
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@7.4.1:
|
||||
resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -5595,9 +5581,6 @@ packages:
|
||||
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0}
|
||||
|
||||
console-grid@2.2.3:
|
||||
resolution: {integrity: sha512-+mecFacaFxGl+1G31IsCx41taUXuW2FxX+4xIE0TIPhgML+Jb9JFcBWGhhWerd1/vhScubdmHqTwOhB0KCUUAg==}
|
||||
|
||||
constantinople@4.0.1:
|
||||
resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==}
|
||||
|
||||
@@ -5927,9 +5910,6 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
eight-colors@1.3.3:
|
||||
resolution: {integrity: sha512-4B54S2Qi4pJjeHmCbDIsveQZWQ/TSSQng4ixYJ9/SYHHpeS5nYK0pzcHvWzWUfRsvJQjwoIENhAwqg59thQceg==}
|
||||
|
||||
ejs@3.1.10:
|
||||
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7476,9 +7456,6 @@ packages:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
||||
lz-utils@2.1.0:
|
||||
resolution: {integrity: sha512-CMkfimAypidTtWjNDxY8a1bc1mJdyEh04V2FfEQ5Zh8Nx4v7k850EYa+dOWGn9hKG5xOyHP5MkuduAZCTHRvJw==}
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -7755,13 +7732,6 @@ packages:
|
||||
resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
monocart-coverage-reports@2.12.9:
|
||||
resolution: {integrity: sha512-vtFqbC3Egl4nVa1FSIrQvMPO6HZtb9lo+3IW7/crdvrLNW2IH8lUsxaK0TsKNmMO2mhFWwqQywLV2CZelqPgwA==}
|
||||
hasBin: true
|
||||
|
||||
monocart-locator@1.0.2:
|
||||
resolution: {integrity: sha512-v8W5hJLcWMIxLCcSi/MHh+VeefI+ycFmGz23Froer9QzWjrbg4J3gFJBuI/T1VLNoYxF47bVPPxq8ZlNX4gVCw==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -14202,14 +14172,6 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
acorn-loose@8.5.2:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
acorn-walk@8.3.5:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
acorn@7.4.1: {}
|
||||
|
||||
acorn@8.16.0: {}
|
||||
@@ -14935,8 +14897,6 @@ snapshots:
|
||||
|
||||
consola@3.4.2: {}
|
||||
|
||||
console-grid@2.2.3: {}
|
||||
|
||||
constantinople@4.0.1:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
@@ -15279,8 +15239,6 @@ snapshots:
|
||||
minimatch: 9.0.1
|
||||
semver: 7.7.4
|
||||
|
||||
eight-colors@1.3.3: {}
|
||||
|
||||
ejs@3.1.10:
|
||||
dependencies:
|
||||
jake: 10.9.2
|
||||
@@ -17056,8 +17014,6 @@ snapshots:
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
lz-utils@2.1.0: {}
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
@@ -17530,23 +17486,6 @@ snapshots:
|
||||
|
||||
modern-tar@0.7.3: {}
|
||||
|
||||
monocart-coverage-reports@2.12.9:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn-loose: 8.5.2
|
||||
acorn-walk: 8.3.5
|
||||
commander: 14.0.3
|
||||
console-grid: 2.2.3
|
||||
eight-colors: 1.3.3
|
||||
foreground-child: 3.3.1
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
lz-utils: 2.1.0
|
||||
monocart-locator: 1.0.2
|
||||
|
||||
monocart-locator@1.0.2: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
@@ -93,7 +93,6 @@ catalog:
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
nx: 22.6.1
|
||||
oxfmt: ^0.40.0
|
||||
oxlint: ^1.55.0
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
interface FileStats {
|
||||
lines: number
|
||||
covered: number
|
||||
}
|
||||
|
||||
interface UncoveredFile {
|
||||
file: string
|
||||
pct: number
|
||||
missed: number
|
||||
}
|
||||
|
||||
const lcovPath = process.argv[2] || 'coverage/playwright/coverage.lcov'
|
||||
|
||||
if (!existsSync(lcovPath)) {
|
||||
process.stdout.write(
|
||||
'## 🔬 E2E Coverage\n\n> ⚠️ No coverage data found. Check the CI workflow logs.\n'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const lcov = readFileSync(lcovPath, 'utf-8')
|
||||
|
||||
let totalLines = 0
|
||||
let coveredLines = 0
|
||||
let totalFunctions = 0
|
||||
let coveredFunctions = 0
|
||||
let totalBranches = 0
|
||||
let coveredBranches = 0
|
||||
|
||||
const fileStats = new Map<string, FileStats>()
|
||||
let currentFile = ''
|
||||
|
||||
for (const line of lcov.split('\n')) {
|
||||
if (line.startsWith('SF:')) {
|
||||
currentFile = line.slice(3)
|
||||
} else if (line.startsWith('LF:')) {
|
||||
const n = parseInt(line.slice(3), 10) || 0
|
||||
totalLines += n
|
||||
const entry = fileStats.get(currentFile) ?? { lines: 0, covered: 0 }
|
||||
entry.lines = n
|
||||
fileStats.set(currentFile, entry)
|
||||
} else if (line.startsWith('LH:')) {
|
||||
const n = parseInt(line.slice(3), 10) || 0
|
||||
coveredLines += n
|
||||
const entry = fileStats.get(currentFile) ?? { lines: 0, covered: 0 }
|
||||
entry.covered = n
|
||||
fileStats.set(currentFile, entry)
|
||||
} else if (line.startsWith('FNF:')) {
|
||||
totalFunctions += parseInt(line.slice(4), 10) || 0
|
||||
} else if (line.startsWith('FNH:')) {
|
||||
coveredFunctions += parseInt(line.slice(4), 10) || 0
|
||||
} else if (line.startsWith('BRF:')) {
|
||||
totalBranches += parseInt(line.slice(4), 10) || 0
|
||||
} else if (line.startsWith('BRH:')) {
|
||||
coveredBranches += parseInt(line.slice(4), 10) || 0
|
||||
}
|
||||
}
|
||||
|
||||
function pct(covered: number, total: number): string {
|
||||
if (total === 0) return '—'
|
||||
return ((covered / total) * 100).toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function bar(covered: number, total: number): string {
|
||||
if (total === 0) return '—'
|
||||
const p = (covered / total) * 100
|
||||
if (p >= 80) return '🟢'
|
||||
if (p >= 50) return '🟡'
|
||||
return '🔴'
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('## 🔬 E2E Coverage')
|
||||
lines.push('')
|
||||
lines.push('| Metric | Covered | Total | Pct | |')
|
||||
lines.push('|---|--:|--:|--:|---|')
|
||||
lines.push(
|
||||
`| Lines | ${coveredLines} | ${totalLines} | ${pct(coveredLines, totalLines)} | ${bar(coveredLines, totalLines)} |`
|
||||
)
|
||||
lines.push(
|
||||
`| Functions | ${coveredFunctions} | ${totalFunctions} | ${pct(coveredFunctions, totalFunctions)} | ${bar(coveredFunctions, totalFunctions)} |`
|
||||
)
|
||||
lines.push(
|
||||
`| Branches | ${coveredBranches} | ${totalBranches} | ${pct(coveredBranches, totalBranches)} | ${bar(coveredBranches, totalBranches)} |`
|
||||
)
|
||||
|
||||
const uncovered: UncoveredFile[] = [...fileStats.entries()]
|
||||
.filter(([, s]) => s.lines > 0)
|
||||
.map(([file, s]) => ({
|
||||
file: file.replace(/^.*\/src\//, 'src/'),
|
||||
pct: s.lines > 0 ? (s.covered / s.lines) * 100 : 100,
|
||||
missed: s.lines - s.covered
|
||||
}))
|
||||
.filter((f) => f.missed > 0)
|
||||
.sort((a, b) => b.missed - a.missed)
|
||||
.slice(0, 10)
|
||||
|
||||
if (uncovered.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('<details>')
|
||||
lines.push('<summary>Top 10 files by uncovered lines</summary>')
|
||||
lines.push('')
|
||||
lines.push('| File | Coverage | Missed |')
|
||||
lines.push('|---|--:|--:|')
|
||||
for (const f of uncovered) {
|
||||
lines.push(`| \`${f.file}\` | ${f.pct.toFixed(1)}% | ${f.missed} |`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('</details>')
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
@@ -1,213 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const TARGET = 80
|
||||
const MILESTONE_STEP = 5
|
||||
const BAR_WIDTH = 20
|
||||
|
||||
interface CoverageData {
|
||||
percentage: number
|
||||
totalLines: number
|
||||
coveredLines: number
|
||||
}
|
||||
|
||||
interface SlackBlock {
|
||||
type: 'section'
|
||||
text: {
|
||||
type: 'mrkdwn'
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
function parseLcovContent(content: string): CoverageData | null {
|
||||
let totalLines = 0
|
||||
let coveredLines = 0
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('LF:')) {
|
||||
totalLines += parseInt(line.slice(3), 10) || 0
|
||||
} else if (line.startsWith('LH:')) {
|
||||
coveredLines += parseInt(line.slice(3), 10) || 0
|
||||
}
|
||||
}
|
||||
|
||||
if (totalLines === 0) return null
|
||||
|
||||
return {
|
||||
percentage: (coveredLines / totalLines) * 100,
|
||||
totalLines,
|
||||
coveredLines
|
||||
}
|
||||
}
|
||||
|
||||
function parseLcov(filePath: string): CoverageData | null {
|
||||
if (!existsSync(filePath)) return null
|
||||
return parseLcovContent(readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function progressBar(percentage: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percentage))
|
||||
const filled = Math.round((clamped / 100) * BAR_WIDTH)
|
||||
const empty = BAR_WIDTH - filled
|
||||
return '█'.repeat(filled) + '░'.repeat(empty)
|
||||
}
|
||||
|
||||
function formatPct(value: number): string {
|
||||
return value.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function formatDelta(delta: number): string {
|
||||
const sign = delta >= 0 ? '+' : ''
|
||||
return sign + delta.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function crossedMilestone(prev: number, curr: number): number | null {
|
||||
const prevBucket = Math.floor(prev / MILESTONE_STEP)
|
||||
const currBucket = Math.floor(curr / MILESTONE_STEP)
|
||||
|
||||
if (currBucket > prevBucket) {
|
||||
return currBucket * MILESTONE_STEP
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildMilestoneBlock(label: string, milestone: number): SlackBlock {
|
||||
if (milestone >= TARGET) {
|
||||
return {
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: [
|
||||
`🏆 *GOAL REACHED: ${label} coverage hit ${milestone}%!* 🏆`,
|
||||
`\`${progressBar(milestone)}\` ${milestone}% ✅`,
|
||||
'The team did it! 🎊🥳🎉'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = TARGET - milestone
|
||||
return {
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: [
|
||||
`🎉🎉🎉 *MILESTONE: ${label} coverage hit ${milestone}%!*`,
|
||||
`\`${progressBar(milestone)}\` ${milestone}% → ${TARGET}% target`,
|
||||
`${remaining} percentage point${remaining !== 1 ? 's' : ''} to go!`
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
prUrl: string
|
||||
prNumber: string
|
||||
author: string
|
||||
} {
|
||||
let prUrl = ''
|
||||
let prNumber = ''
|
||||
let author = ''
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith('--pr-url=')) prUrl = arg.slice('--pr-url='.length)
|
||||
else if (arg.startsWith('--pr-number='))
|
||||
prNumber = arg.slice('--pr-number='.length)
|
||||
else if (arg.startsWith('--author=')) author = arg.slice('--author='.length)
|
||||
}
|
||||
|
||||
return { prUrl, prNumber, author }
|
||||
}
|
||||
|
||||
function formatCoverageRow(
|
||||
label: string,
|
||||
current: CoverageData,
|
||||
baseline: CoverageData
|
||||
): string {
|
||||
const delta = current.percentage - baseline.percentage
|
||||
return `*${label}:* ${formatPct(baseline.percentage)} → ${formatPct(current.percentage)} (${formatDelta(delta)})`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { prUrl, prNumber, author } = parseArgs(process.argv.slice(2))
|
||||
|
||||
const unitCurrent = parseLcov('coverage/lcov.info')
|
||||
const unitBaseline = parseLcov('temp/coverage-baseline/lcov.info')
|
||||
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 e2eImproved =
|
||||
e2eCurrent !== null &&
|
||||
e2eBaseline !== null &&
|
||||
e2eCurrent.percentage > e2eBaseline.percentage
|
||||
|
||||
if (!unitImproved && !e2eImproved) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const blocks: SlackBlock[] = []
|
||||
|
||||
const summaryLines: string[] = []
|
||||
summaryLines.push(
|
||||
`✅ *Coverage improved!* — <${prUrl}|PR #${prNumber}> by <https://github.com/${author}|${author}>`
|
||||
)
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitCurrent && unitBaseline) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
|
||||
}
|
||||
|
||||
if (e2eCurrent && e2eBaseline) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
|
||||
}
|
||||
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitCurrent) {
|
||||
summaryLines.push(
|
||||
`\`${progressBar(unitCurrent.percentage)}\` ${formatPct(unitCurrent.percentage)} unit → ${TARGET}% target`
|
||||
)
|
||||
}
|
||||
if (e2eCurrent) {
|
||||
summaryLines.push(
|
||||
`\`${progressBar(e2eCurrent.percentage)}\` ${formatPct(e2eCurrent.percentage)} e2e → ${TARGET}% target`
|
||||
)
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: summaryLines.join('\n')
|
||||
}
|
||||
})
|
||||
|
||||
if (unitCurrent && unitBaseline) {
|
||||
const milestone = crossedMilestone(
|
||||
unitBaseline.percentage,
|
||||
unitCurrent.percentage
|
||||
)
|
||||
if (milestone !== null) {
|
||||
blocks.push(buildMilestoneBlock('Unit test', milestone))
|
||||
}
|
||||
}
|
||||
|
||||
if (e2eCurrent && e2eBaseline) {
|
||||
const milestone = crossedMilestone(
|
||||
e2eBaseline.percentage,
|
||||
e2eCurrent.percentage
|
||||
)
|
||||
if (milestone !== null) {
|
||||
blocks.push(buildMilestoneBlock('E2E test', milestone))
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { text: 'Coverage improved!', blocks }
|
||||
process.stdout.write(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,9 +1,11 @@
|
||||
// @ts-check
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
const args: string[] = process.argv.slice(2)
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
function getArg(name: string): string | undefined {
|
||||
/** @param {string} name */
|
||||
function getArg(name) {
|
||||
const prefix = `--${name}=`
|
||||
const arg = args.find((a) => a.startsWith(prefix))
|
||||
return arg ? arg.slice(prefix.length) : undefined
|
||||
@@ -11,10 +13,11 @@ function getArg(name: string): string | undefined {
|
||||
|
||||
const sizeStatus = getArg('size-status') ?? 'pending'
|
||||
const perfStatus = getArg('perf-status') ?? 'pending'
|
||||
const coverageStatus = getArg('coverage-status') ?? 'skip'
|
||||
|
||||
const lines: string[] = []
|
||||
/** @type {string[]} */
|
||||
const lines = []
|
||||
|
||||
// --- Size section ---
|
||||
if (sizeStatus === 'ready') {
|
||||
try {
|
||||
const sizeReport = execFileSync('node', ['scripts/size-report.js'], {
|
||||
@@ -40,6 +43,7 @@ if (sizeStatus === 'ready') {
|
||||
|
||||
lines.push('')
|
||||
|
||||
// --- Perf section ---
|
||||
if (perfStatus === 'ready' && existsSync('test-results/perf-metrics.json')) {
|
||||
try {
|
||||
const perfReport = execFileSync(
|
||||
@@ -68,33 +72,4 @@ if (perfStatus === 'ready' && existsSync('test-results/perf-metrics.json')) {
|
||||
lines.push('> ⏳ Performance tests in progress…')
|
||||
}
|
||||
|
||||
if (coverageStatus === 'ready' && existsSync('temp/coverage/coverage.lcov')) {
|
||||
try {
|
||||
const coverageReport = execFileSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'tsx',
|
||||
'scripts/coverage-report.ts',
|
||||
'temp/coverage/coverage.lcov'
|
||||
],
|
||||
{ encoding: 'utf-8' }
|
||||
).trimEnd()
|
||||
lines.push('')
|
||||
lines.push(coverageReport)
|
||||
} catch {
|
||||
lines.push('')
|
||||
lines.push('## 🔬 E2E Coverage')
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'> ⚠️ Failed to render coverage report. Check the CI workflow logs.'
|
||||
)
|
||||
}
|
||||
} else if (coverageStatus === 'failed') {
|
||||
lines.push('')
|
||||
lines.push('## 🔬 E2E Coverage')
|
||||
lines.push('')
|
||||
lines.push('> ⚠️ Coverage collection failed. Check the CI workflow logs.')
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
@@ -170,7 +170,6 @@ defineExpose({ handleDragDrop })
|
||||
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
||||
: undefined
|
||||
"
|
||||
data-testid="builder-widget-item"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
@@ -182,7 +181,6 @@ defineExpose({ handleDragDrop })
|
||||
>
|
||||
<span
|
||||
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
|
||||
data-testid="builder-widget-label"
|
||||
>
|
||||
{{ action.widget.label || action.widget.name }}
|
||||
</span>
|
||||
|
||||
@@ -89,7 +89,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { CurveInterpolation, CurvePoint } from './types'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
|
||||
const {
|
||||
curveColor = 'white',
|
||||
|
||||
@@ -5,8 +5,7 @@ import type { CurvePoint } from './types'
|
||||
import {
|
||||
createLinearInterpolator,
|
||||
createMonotoneInterpolator,
|
||||
curvesToLUT,
|
||||
histogramToPath
|
||||
curvesToLUT
|
||||
} from './curveUtils'
|
||||
|
||||
describe('createMonotoneInterpolator', () => {
|
||||
@@ -164,37 +163,3 @@ describe('curvesToLUT', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('histogramToPath', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
})
|
||||
|
||||
it('returns a closed SVG path for valid histogram', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
})
|
||||
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
// Most bins should map to y=0 (1 - 100/100 = 0) since
|
||||
// the 99.5th percentile is 100, not the outlier 100000
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,34 +149,6 @@ export function createMonotoneInterpolator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a histogram (arbitrary number of bins) into an SVG path string.
|
||||
* Applies square-root scaling and normalizes using the 99.5th percentile
|
||||
* to avoid outlier spikes.
|
||||
*/
|
||||
export function histogramToPath(histogram: Uint32Array): string {
|
||||
const len = histogram.length
|
||||
if (len === 0) return ''
|
||||
|
||||
const sqrtValues = new Float32Array(len)
|
||||
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])
|
||||
|
||||
const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
|
||||
const max = sorted[Math.floor((len - 1) * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const invMax = 1 / max
|
||||
const lastIdx = len - 1
|
||||
const parts: string[] = ['M0,1']
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = lastIdx === 0 ? 0.5 : i / lastIdx
|
||||
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
|
||||
parts.push(`L${x},${y}`)
|
||||
}
|
||||
parts.push('L1,1 Z')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function curvesToLUT(
|
||||
points: CurvePoint[],
|
||||
interpolation: CurveInterpolation = 'monotone_cubic'
|
||||
|
||||
131
src/components/range/RangeEditor.test.ts
Normal file
131
src/components/range/RangeEditor.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RangeEditor from './RangeEditor.vue'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
|
||||
return mount(RangeEditor, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('RangeEditor', () => {
|
||||
it('renders with min and max handles', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('highlights selected range in plain mode', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
const highlight = wrapper.find('[data-testid="range-highlight"]')
|
||||
expect(highlight.attributes('x')).toBe('0.2')
|
||||
expect(
|
||||
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
|
||||
).toBeCloseTo(0.6, 6)
|
||||
})
|
||||
|
||||
it('dims area outside the range in histogram mode', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
|
||||
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0.2, max: 0.8 },
|
||||
display: 'histogram',
|
||||
histogram
|
||||
})
|
||||
|
||||
const left = wrapper.find('[data-testid="range-dim-left"]')
|
||||
const right = wrapper.find('[data-testid="range-dim-right"]')
|
||||
expect(left.attributes('width')).toBe('0.2')
|
||||
expect(right.attributes('x')).toBe('0.8')
|
||||
})
|
||||
|
||||
it('hides midpoint handle by default', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 }
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows midpoint handle when showMidpoint is true', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 },
|
||||
showMidpoint: true
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders gradient background when display is gradient', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1 },
|
||||
display: 'gradient',
|
||||
gradientStops: [
|
||||
{ offset: 0, color: [0, 0, 0] as const },
|
||||
{ offset: 1, color: [255, 255, 255] as const }
|
||||
]
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
|
||||
expect(wrapper.find('linearGradient').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders histogram path when display is histogram with data', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
|
||||
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1 },
|
||||
display: 'histogram',
|
||||
histogram
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders inputs for min and max', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
expect(inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders midpoint input when showMidpoint is true', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 },
|
||||
showMidpoint: true
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
expect(inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('normalizes handle positions with custom value range', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 64, max: 192 },
|
||||
valueMin: 0,
|
||||
valueMax: 255
|
||||
})
|
||||
|
||||
const minHandle = wrapper.find('[data-testid="handle-min"]')
|
||||
const maxHandle = wrapper.find('[data-testid="handle-max"]')
|
||||
|
||||
expect(
|
||||
Number.parseFloat((minHandle.element as HTMLElement).style.left)
|
||||
).toBeCloseTo(25, 0)
|
||||
expect(
|
||||
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
|
||||
).toBeCloseTo(75, 0)
|
||||
})
|
||||
})
|
||||
290
src/components/range/RangeEditor.vue
Normal file
290
src/components/range/RangeEditor.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="relative select-none"
|
||||
@pointerdown.stop="onTrackPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
:class="
|
||||
cn(
|
||||
'block w-full rounded-sm bg-node-component-surface',
|
||||
display === 'histogram' ? 'aspect-3/2' : 'h-8'
|
||||
)
|
||||
"
|
||||
>
|
||||
<defs v-if="display === 'gradient'">
|
||||
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop
|
||||
v-for="(stop, i) in computedStops"
|
||||
:key="i"
|
||||
:offset="stop.offset"
|
||||
:stop-color="`rgb(${stop.color[0]},${stop.color[1]},${stop.color[2]})`"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect
|
||||
v-if="display === 'gradient'"
|
||||
data-testid="gradient-bg"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"
|
||||
:fill="`url(#${gradientId})`"
|
||||
/>
|
||||
|
||||
<path
|
||||
v-if="display === 'histogram' && histogramPath"
|
||||
data-testid="histogram-path"
|
||||
:d="histogramPath"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.3"
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="display === 'plain'"
|
||||
data-testid="range-highlight"
|
||||
:x="minNorm"
|
||||
y="0"
|
||||
:width="Math.max(0, maxNorm - minNorm)"
|
||||
height="1"
|
||||
fill="white"
|
||||
fill-opacity="0.15"
|
||||
/>
|
||||
<template v-if="display === 'histogram'">
|
||||
<rect
|
||||
v-if="minNorm > 0"
|
||||
data-testid="range-dim-left"
|
||||
x="0"
|
||||
y="0"
|
||||
:width="minNorm"
|
||||
height="1"
|
||||
fill="black"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
<rect
|
||||
v-if="maxNorm < 1"
|
||||
data-testid="range-dim-right"
|
||||
:x="maxNorm"
|
||||
y="0"
|
||||
:width="1 - maxNorm"
|
||||
height="1"
|
||||
fill="black"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
|
||||
<template v-if="!disabled">
|
||||
<div
|
||||
data-testid="handle-min"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${minNorm * 100}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('min', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="#333"
|
||||
stroke="#aaa"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showMidpoint && modelValue.midpoint !== undefined"
|
||||
data-testid="handle-midpoint"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${midpointPercent}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('midpoint', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="#888"
|
||||
stroke="#ccc"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="handle-max"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${maxNorm * 100}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('max', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="white"
|
||||
stroke="#555"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!disabled"
|
||||
class="mt-3 flex items-center justify-between"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<ScrubableNumberInput
|
||||
v-model="minValue"
|
||||
:display-value="formatValue(minValue)"
|
||||
:min="valueMin"
|
||||
:max="valueMax"
|
||||
:step="step"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-if="showMidpoint && modelValue.midpoint !== undefined"
|
||||
v-model="midpointValue"
|
||||
:display-value="midpointValue.toFixed(2)"
|
||||
:min="midpointScale === 'gamma' ? 0.01 : 0"
|
||||
:max="midpointScale === 'gamma' ? 9.99 : 1"
|
||||
:step="0.01"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="maxValue"
|
||||
:display-value="formatValue(maxValue)"
|
||||
:min="valueMin"
|
||||
:max="valueMax"
|
||||
:step="step"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef, useId, useTemplateRef } from 'vue'
|
||||
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
import { useRangeEditor } from '@/composables/useRangeEditor'
|
||||
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { normalize } from '@/utils/mathUtil'
|
||||
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import { gammaToPosition, positionToGamma } from './rangeUtils'
|
||||
|
||||
const {
|
||||
display = 'plain',
|
||||
gradientStops,
|
||||
showMidpoint = false,
|
||||
midpointScale = 'linear',
|
||||
histogram,
|
||||
disabled = false,
|
||||
valueMin = 0,
|
||||
valueMax = 1
|
||||
} = defineProps<{
|
||||
display?: 'plain' | 'gradient' | 'histogram'
|
||||
gradientStops?: ColorStop[]
|
||||
showMidpoint?: boolean
|
||||
midpointScale?: 'linear' | 'gamma'
|
||||
histogram?: Uint32Array | null
|
||||
disabled?: boolean
|
||||
valueMin?: number
|
||||
valueMax?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<RangeValue>({ required: true })
|
||||
|
||||
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
|
||||
const gradientId = useId()
|
||||
|
||||
const { handleTrackPointerDown, startDrag } = useRangeEditor({
|
||||
trackRef,
|
||||
modelValue,
|
||||
valueMin: toRef(() => valueMin),
|
||||
valueMax: toRef(() => valueMax),
|
||||
showMidpoint: toRef(() => showMidpoint)
|
||||
})
|
||||
|
||||
function onTrackPointerDown(e: PointerEvent) {
|
||||
if (!disabled) handleTrackPointerDown(e)
|
||||
}
|
||||
|
||||
const isIntegerRange = computed(() => valueMax - valueMin >= 2)
|
||||
const step = computed(() => (isIntegerRange.value ? 1 : 0.01))
|
||||
|
||||
function formatValue(v: number): string {
|
||||
return isIntegerRange.value ? Math.round(v).toString() : v.toFixed(2)
|
||||
}
|
||||
|
||||
const minNorm = computed(() =>
|
||||
normalize(modelValue.value.min, valueMin, valueMax)
|
||||
)
|
||||
const maxNorm = computed(() =>
|
||||
normalize(modelValue.value.max, valueMin, valueMax)
|
||||
)
|
||||
|
||||
const computedStops = computed(
|
||||
() =>
|
||||
gradientStops ?? [
|
||||
{ offset: 0, color: [0, 0, 0] as const },
|
||||
{ offset: 1, color: [255, 255, 255] as const }
|
||||
]
|
||||
)
|
||||
|
||||
const midpointPercent = computed(() => {
|
||||
const { min, max, midpoint } = modelValue.value
|
||||
if (midpoint === undefined) return 0
|
||||
const midAbs = min + midpoint * (max - min)
|
||||
return normalize(midAbs, valueMin, valueMax) * 100
|
||||
})
|
||||
|
||||
const minValue = computed({
|
||||
get: () => modelValue.value.min,
|
||||
set: (min) => {
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
min: Math.min(clamp(min, valueMin, valueMax), modelValue.value.max)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const maxValue = computed({
|
||||
get: () => modelValue.value.max,
|
||||
set: (max) => {
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
max: Math.max(clamp(max, valueMin, valueMax), modelValue.value.min)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const midpointValue = computed({
|
||||
get: () => {
|
||||
const pos = modelValue.value.midpoint ?? 0.5
|
||||
return midpointScale === 'gamma' ? positionToGamma(pos) : pos
|
||||
},
|
||||
set: (val) => {
|
||||
const position =
|
||||
midpointScale === 'gamma'
|
||||
? clamp(gammaToPosition(val), 0, 1)
|
||||
: clamp(val, 0, 1)
|
||||
modelValue.value = { ...modelValue.value, midpoint: position }
|
||||
}
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
</script>
|
||||
74
src/components/range/WidgetRange.vue
Normal file
74
src/components/range/WidgetRange.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<RangeEditor
|
||||
:model-value="effectiveValue"
|
||||
:display="widget?.options?.display"
|
||||
:gradient-stops="widget?.options?.gradient_stops"
|
||||
:show-midpoint="widget?.options?.show_midpoint"
|
||||
:midpoint-scale="widget?.options?.midpoint_scale"
|
||||
:histogram="histogram"
|
||||
:disabled="isDisabled"
|
||||
:value-min="widget?.options?.value_min"
|
||||
:value-max="widget?.options?.value_max"
|
||||
@update:model-value="onValueChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import {
|
||||
singleValueExtractor,
|
||||
useUpstreamValue
|
||||
} from '@/composables/useUpstreamValue'
|
||||
import type {
|
||||
IWidgetRangeOptions,
|
||||
RangeValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import RangeEditor from './RangeEditor.vue'
|
||||
import { isRangeValue } from './rangeUtils'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<RangeValue>({
|
||||
default: () => ({ min: 0, max: 1 })
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => !!widget.options?.disabled)
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const histogram = computed(() => {
|
||||
const locatorId = widget.nodeLocatorId
|
||||
if (!locatorId) return null
|
||||
const output = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const key = `histogram_${widget.name}`
|
||||
const data = (output as Record<string, unknown>)?.[key]
|
||||
if (!Array.isArray(data) || data.length === 0) return null
|
||||
return new Uint32Array(data)
|
||||
})
|
||||
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => widget.linkedUpstream,
|
||||
singleValueExtractor(isRangeValue)
|
||||
)
|
||||
|
||||
watch(upstreamValue, (upstream) => {
|
||||
if (isDisabled.value && upstream) {
|
||||
modelValue.value = upstream
|
||||
}
|
||||
})
|
||||
|
||||
const effectiveValue = computed(() =>
|
||||
isDisabled.value && upstreamValue.value
|
||||
? upstreamValue.value
|
||||
: modelValue.value
|
||||
)
|
||||
|
||||
function onValueChange(value: RangeValue) {
|
||||
modelValue.value = value
|
||||
}
|
||||
</script>
|
||||
49
src/components/range/rangeUtils.test.ts
Normal file
49
src/components/range/rangeUtils.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { gammaToPosition, isRangeValue, positionToGamma } from './rangeUtils'
|
||||
|
||||
describe('positionToGamma', () => {
|
||||
it('converts 0.5 to gamma 1.0', () => {
|
||||
expect(positionToGamma(0.5)).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('converts 0.25 to gamma 2.0', () => {
|
||||
expect(positionToGamma(0.25)).toBeCloseTo(2.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gammaToPosition', () => {
|
||||
it('converts gamma 1.0 to position 0.5', () => {
|
||||
expect(gammaToPosition(1.0)).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
it('converts gamma 2.0 to position 0.25', () => {
|
||||
expect(gammaToPosition(2.0)).toBeCloseTo(0.25)
|
||||
})
|
||||
|
||||
it('round-trips with positionToGamma', () => {
|
||||
for (const pos of [0.1, 0.3, 0.5, 0.7, 0.9]) {
|
||||
expect(gammaToPosition(positionToGamma(pos))).toBeCloseTo(pos)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRangeValue', () => {
|
||||
it('returns true for valid range', () => {
|
||||
expect(isRangeValue({ min: 0, max: 1 })).toBe(true)
|
||||
expect(isRangeValue({ min: 0, max: 1, midpoint: 0.5 })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-objects', () => {
|
||||
expect(isRangeValue(null)).toBe(false)
|
||||
expect(isRangeValue(42)).toBe(false)
|
||||
expect(isRangeValue('foo')).toBe(false)
|
||||
expect(isRangeValue([0, 1])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for objects missing min or max', () => {
|
||||
expect(isRangeValue({ min: 0 })).toBe(false)
|
||||
expect(isRangeValue({ max: 1 })).toBe(false)
|
||||
expect(isRangeValue({ min: 'a', max: 1 })).toBe(false)
|
||||
})
|
||||
})
|
||||
23
src/components/range/rangeUtils.ts
Normal file
23
src/components/range/rangeUtils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function positionToGamma(position: number): number {
|
||||
// Avoid log2(0) = -Infinity and log2(1) = 0 (division by zero in gamma)
|
||||
const clamped = clamp(position, 0.001, 0.999)
|
||||
return -Math.log2(clamped)
|
||||
}
|
||||
|
||||
export function gammaToPosition(gamma: number): number {
|
||||
return Math.pow(2, -gamma)
|
||||
}
|
||||
|
||||
export function isRangeValue(value: unknown): value is RangeValue {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
||||
return false
|
||||
const v = value as Record<string, unknown>
|
||||
const hasFiniteBounds = Number.isFinite(v.min) && Number.isFinite(v.max)
|
||||
const hasValidMidpoint =
|
||||
v.midpoint === undefined || Number.isFinite(v.midpoint)
|
||||
return hasFiniteBounds && hasValidMidpoint
|
||||
}
|
||||
114
src/composables/useRangeEditor.ts
Normal file
114
src/composables/useRangeEditor.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import { denormalize, normalize } from '@/utils/mathUtil'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type HandleType = 'min' | 'max' | 'midpoint'
|
||||
|
||||
interface UseRangeEditorOptions {
|
||||
trackRef: Ref<HTMLElement | null>
|
||||
modelValue: Ref<RangeValue>
|
||||
valueMin: Ref<number>
|
||||
valueMax: Ref<number>
|
||||
showMidpoint: Ref<boolean>
|
||||
}
|
||||
|
||||
export function useRangeEditor({
|
||||
trackRef,
|
||||
modelValue,
|
||||
valueMin,
|
||||
valueMax,
|
||||
showMidpoint
|
||||
}: UseRangeEditorOptions) {
|
||||
const activeHandle = ref<HandleType | null>(null)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
|
||||
function pointerToValue(e: PointerEvent): number {
|
||||
const el = trackRef.value
|
||||
if (!el) return valueMin.value
|
||||
const rect = el.getBoundingClientRect()
|
||||
const normalized = clamp((e.clientX - rect.left) / rect.width, 0, 1)
|
||||
return denormalize(normalized, valueMin.value, valueMax.value)
|
||||
}
|
||||
|
||||
function nearestHandle(value: number): HandleType {
|
||||
const { min, max, midpoint } = modelValue.value
|
||||
const dMin = Math.abs(value - min)
|
||||
const dMax = Math.abs(value - max)
|
||||
let best: HandleType = dMin <= dMax ? 'min' : 'max'
|
||||
const bestDist = Math.min(dMin, dMax)
|
||||
if (midpoint !== undefined && showMidpoint.value) {
|
||||
const midAbs = min + midpoint * (max - min)
|
||||
if (Math.abs(value - midAbs) < bestDist) {
|
||||
best = 'midpoint'
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function updateValue(handle: HandleType, value: number) {
|
||||
const current = modelValue.value
|
||||
const clamped = clamp(value, valueMin.value, valueMax.value)
|
||||
|
||||
if (handle === 'min') {
|
||||
modelValue.value = { ...current, min: Math.min(clamped, current.max) }
|
||||
} else if (handle === 'max') {
|
||||
modelValue.value = { ...current, max: Math.max(clamped, current.min) }
|
||||
} else {
|
||||
const range = current.max - current.min
|
||||
const midNorm =
|
||||
range > 0 ? normalize(clamped, current.min, current.max) : 0
|
||||
const midpoint = Math.max(0, Math.min(1, midNorm))
|
||||
modelValue.value = { ...current, midpoint }
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
startDrag(nearestHandle(pointerToValue(e)), e)
|
||||
}
|
||||
|
||||
function startDrag(handle: HandleType, e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
cleanupDrag?.()
|
||||
|
||||
activeHandle.value = handle
|
||||
const el = trackRef.value
|
||||
if (!el) return
|
||||
|
||||
el.setPointerCapture(e.pointerId)
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!activeHandle.value) return
|
||||
updateValue(activeHandle.value, pointerToValue(ev))
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (!activeHandle.value) return
|
||||
activeHandle.value = null
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', endDrag)
|
||||
el.removeEventListener('lostpointercapture', endDrag)
|
||||
cleanupDrag = null
|
||||
}
|
||||
|
||||
cleanupDrag = endDrag
|
||||
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', endDrag)
|
||||
el.addEventListener('lostpointercapture', endDrag)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDrag?.()
|
||||
})
|
||||
|
||||
return {
|
||||
activeHandle,
|
||||
handleTrackPointerDown,
|
||||
startDrag
|
||||
}
|
||||
}
|
||||
@@ -143,14 +143,14 @@ describe('Autogrow', () => {
|
||||
test('Can add autogrow with min input count', () => {
|
||||
const node = testNode()
|
||||
addAutogrow(node, { min: 4, input: inputsSpec })
|
||||
expect(node.inputs.length).toBe(5)
|
||||
expect(node.inputs.length).toBe(4)
|
||||
})
|
||||
test('Adding connections will cause growth up to max', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test', max: 3 })
|
||||
expect(node.inputs.length).toBe(2)
|
||||
expect(node.inputs.length).toBe(1)
|
||||
|
||||
connectInput(node, 0, graph)
|
||||
expect(node.inputs.length).toBe(2)
|
||||
@@ -159,7 +159,7 @@ describe('Autogrow', () => {
|
||||
connectInput(node, 2, graph)
|
||||
expect(node.inputs.length).toBe(3)
|
||||
})
|
||||
test('Removing connections decreases to min + 1', async () => {
|
||||
test('Removing connections decreases to min', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
@@ -204,9 +204,9 @@ describe('Autogrow', () => {
|
||||
'0.a0',
|
||||
'0.a1',
|
||||
'0.a2',
|
||||
'2.b0',
|
||||
'2.b1',
|
||||
'2.b2',
|
||||
'1.b0',
|
||||
'1.b1',
|
||||
'1.b2',
|
||||
'aa'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -511,7 +511,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
lastInput
|
||||
)
|
||||
}
|
||||
const removalChecks = groupInputs.slice(min * stride)
|
||||
const removalChecks = groupInputs.slice((min - 1) * stride)
|
||||
let i
|
||||
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
|
||||
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
|
||||
@@ -597,6 +597,6 @@ function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
|
||||
prefix,
|
||||
inputSpecs: inputsV2
|
||||
}
|
||||
for (let i = 0; i === 0 || i < min + 1; i++)
|
||||
for (let i = 0; i === 0 || i < min; i++)
|
||||
addAutogrowGroup(i, inputSpecV2.name, node)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
@@ -1006,25 +1005,3 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
|
||||
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zero UUID handling in configure', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
})
|
||||
|
||||
it('rejects zeroUuid for root graphs and assigns a new ID', () => {
|
||||
const graph = new LGraph()
|
||||
const data = graph.serialize()
|
||||
data.id = zeroUuid
|
||||
graph.configure(data)
|
||||
expect(graph.id).not.toBe(zeroUuid)
|
||||
})
|
||||
|
||||
it('preserves zeroUuid for subgraphs', () => {
|
||||
const graph = new LGraph()
|
||||
const subgraphData = { ...createTestSubgraphData(), id: zeroUuid }
|
||||
const subgraph = graph.createSubgraph(subgraphData)
|
||||
subgraph.configure(subgraphData)
|
||||
expect(subgraph.id).toBe(zeroUuid)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2480,8 +2480,8 @@ export class LGraph
|
||||
protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void {
|
||||
const { id, extra } = data
|
||||
|
||||
// Create a new graph ID if none is provided or the zero UUID is used on the root graph
|
||||
if (id && !(this.isRootGraph && id === zeroUuid)) {
|
||||
// Create a new graph ID if none is provided
|
||||
if (id) {
|
||||
this.id = id
|
||||
} else if (this.id === zeroUuid) {
|
||||
this.id = createUuidv4()
|
||||
|
||||
@@ -139,6 +139,7 @@ export type IWidget =
|
||||
| IBoundingBoxWidget
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
| IRangeWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -341,6 +342,30 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface RangeValue {
|
||||
min: number
|
||||
max: number
|
||||
midpoint?: number
|
||||
}
|
||||
|
||||
export interface IWidgetRangeOptions extends IWidgetOptions {
|
||||
display?: 'plain' | 'gradient' | 'histogram'
|
||||
gradient_stops?: ColorStop[]
|
||||
show_midpoint?: boolean
|
||||
midpoint_scale?: 'linear' | 'gamma'
|
||||
value_min?: number
|
||||
value_max?: number
|
||||
}
|
||||
|
||||
export interface IRangeWidget extends IBaseWidget<
|
||||
RangeValue,
|
||||
'range',
|
||||
IWidgetRangeOptions
|
||||
> {
|
||||
type: 'range'
|
||||
value: RangeValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
|
||||
16
src/lib/litegraph/src/widgets/RangeWidget.ts
Normal file
16
src/lib/litegraph/src/widgets/RangeWidget.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IRangeWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class RangeWidget
|
||||
extends BaseWidget<IRangeWidget>
|
||||
implements IRangeWidget
|
||||
{
|
||||
override type = 'range' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Range')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
import { ImageCompareWidget } from './ImageCompareWidget'
|
||||
import { PainterWidget } from './PainterWidget'
|
||||
import { RangeWidget } from './RangeWidget'
|
||||
import { ImageCropWidget } from './ImageCropWidget'
|
||||
import { KnobWidget } from './KnobWidget'
|
||||
import { LegacyWidget } from './LegacyWidget'
|
||||
@@ -60,6 +61,7 @@ export type WidgetTypeMap = {
|
||||
boundingbox: BoundingBoxWidget
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
range: RangeWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -140,6 +142,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(CurveWidget, narrowedWidget, node)
|
||||
case 'painter':
|
||||
return toClass(PainterWidget, narrowedWidget, node)
|
||||
case 'range':
|
||||
return toClass(RangeWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IRangeWidget,
|
||||
IWidgetRangeOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { RangeInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useRangeWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec): IRangeWidget => {
|
||||
const spec = inputSpec as RangeInputSpec
|
||||
const defaultValue = spec.default ?? { min: 0.0, max: 1.0 }
|
||||
|
||||
const options: IWidgetRangeOptions = {
|
||||
display: spec.display,
|
||||
gradient_stops: spec.gradient_stops,
|
||||
show_midpoint: spec.show_midpoint,
|
||||
midpoint_scale: spec.midpoint_scale,
|
||||
value_min: spec.value_min,
|
||||
value_max: spec.value_max
|
||||
}
|
||||
|
||||
const rawWidget = node.addWidget(
|
||||
'range',
|
||||
spec.name,
|
||||
{ ...defaultValue },
|
||||
() => {},
|
||||
options
|
||||
)
|
||||
|
||||
if (rawWidget.type !== 'range') {
|
||||
throw new Error(`Unexpected widget type: ${rawWidget.type}`)
|
||||
}
|
||||
|
||||
return rawWidget as IRangeWidget
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ const WidgetCurve = defineAsyncComponent(
|
||||
const WidgetPainter = defineAsyncComponent(
|
||||
() => import('@/components/painter/WidgetPainter.vue')
|
||||
)
|
||||
const WidgetRange = defineAsyncComponent(
|
||||
() => import('@/components/range/WidgetRange.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -197,6 +200,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['PAINTER'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'range',
|
||||
{
|
||||
component: WidgetRange,
|
||||
aliases: ['RANGE'],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -234,7 +245,8 @@ const EXPANDING_TYPES = [
|
||||
'load3D',
|
||||
'curve',
|
||||
'painter',
|
||||
'imagecompare'
|
||||
'imagecompare',
|
||||
'range'
|
||||
] as const
|
||||
|
||||
export function shouldExpand(type: string): boolean {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import {
|
||||
zBaseInputOptions,
|
||||
zBooleanInputOptions,
|
||||
zColorStop,
|
||||
zComboInputOptions,
|
||||
zFloatInputOptions,
|
||||
zIntInputOptions,
|
||||
@@ -140,6 +141,25 @@ const zCurveInputSpec = zBaseInputOptions.extend({
|
||||
default: zCurveData.optional()
|
||||
})
|
||||
|
||||
const zRangeValue = z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
midpoint: z.number().optional()
|
||||
})
|
||||
|
||||
const zRangeInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('RANGE'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
default: zRangeValue.optional(),
|
||||
display: z.enum(['plain', 'gradient', 'histogram']).optional(),
|
||||
gradient_stops: z.array(zColorStop).optional(),
|
||||
show_midpoint: z.boolean().optional(),
|
||||
midpoint_scale: z.enum(['linear', 'gamma']).optional(),
|
||||
value_min: z.number().optional(),
|
||||
value_max: z.number().optional()
|
||||
})
|
||||
|
||||
const zCustomInputSpec = zBaseInputOptions.extend({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
@@ -161,6 +181,7 @@ const zInputSpec = z.union([
|
||||
zGalleriaInputSpec,
|
||||
zTextareaInputSpec,
|
||||
zCurveInputSpec,
|
||||
zRangeInputSpec,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
@@ -206,6 +227,7 @@ export type ChartInputSpec = z.infer<typeof zChartInputSpec>
|
||||
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
|
||||
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
|
||||
export type CurveInputSpec = z.infer<typeof zCurveInputSpec>
|
||||
export type RangeInputSpec = z.infer<typeof zRangeInputSpec>
|
||||
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
|
||||
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
|
||||
@@ -56,16 +56,14 @@ export const zIntInputOptions = zNumericInputOptions.extend({
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zColorStop = z.object({
|
||||
offset: z.number(),
|
||||
color: z.tuple([z.number(), z.number(), z.number()])
|
||||
})
|
||||
|
||||
export const zFloatInputOptions = zNumericInputOptions.extend({
|
||||
round: z.union([z.number(), z.literal(false)]).optional(),
|
||||
gradient_stops: z
|
||||
.array(
|
||||
z.object({
|
||||
offset: z.number(),
|
||||
color: z.tuple([z.number(), z.number(), z.number()])
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
gradient_stops: z.array(zColorStop).optional()
|
||||
})
|
||||
|
||||
export const zBooleanInputOptions = zBaseInputOptions.extend({
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/com
|
||||
import { useIntWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
|
||||
import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget'
|
||||
import { usePainterWidget } from '@/renderer/extensions/vueNodes/widgets/composables/usePainterWidget'
|
||||
import { useRangeWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRangeWidget'
|
||||
import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget'
|
||||
import { useTextareaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
@@ -310,6 +311,7 @@ export const ComfyWidgets = {
|
||||
PAINTER: transformWidgetConstructorV2ToV1(usePainterWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
CURVE: transformWidgetConstructorV2ToV1(useCurveWidget()),
|
||||
RANGE: transformWidgetConstructorV2ToV1(useRangeWidget()),
|
||||
...dynamicWidgets
|
||||
} as const
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
||||
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export const VIEWPORT_CACHE_MAX_SIZE = 32
|
||||
@@ -139,19 +138,11 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss — fit to content only if no nodes are currently visible.
|
||||
// loadGraphData may have already restored extra.ds or called fitView
|
||||
// for templates, so only intervene when the viewport is truly empty.
|
||||
// Cache miss — fit to content after the canvas has the new graph.
|
||||
// rAF fires after layout + paint, when nodes are positioned.
|
||||
const expectedGraphId = graphId
|
||||
requestAnimationFrame(() => {
|
||||
if (getActiveGraphId() !== graphId) return
|
||||
if (!canvas.graph) return
|
||||
|
||||
const nodes = canvas.graph.nodes
|
||||
if (!nodes?.length) return
|
||||
|
||||
canvas.ds.computeVisibleArea(canvas.viewport)
|
||||
if (anyItemOverlapsRect(nodes, canvas.ds.visible_area)) return
|
||||
|
||||
if (getActiveGraphId() !== expectedGraphId) return
|
||||
useLitegraphService().fitView()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,11 +24,8 @@ vi.mock('@/scripts/app', () => {
|
||||
scale: 1,
|
||||
offset: [0, 0],
|
||||
state: { scale: 1, offset: [0, 0] },
|
||||
fitToBounds: vi.fn(),
|
||||
visible_area: [0, 0, 1000, 1000],
|
||||
computeVisibleArea: vi.fn()
|
||||
fitToBounds: vi.fn()
|
||||
},
|
||||
viewport: [0, 0, 1000, 1000],
|
||||
setDirty: mockSetDirty,
|
||||
get empty() {
|
||||
return true
|
||||
@@ -187,11 +184,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
// Ensure no cached entry
|
||||
store.viewportCache.delete(':root')
|
||||
|
||||
// Add a node outside the visible area so anyItemOverlapsRect returns false
|
||||
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
|
||||
mockGraph.nodes = [{ pos: [9999, 9999], size: [100, 100] }]
|
||||
mockGraph._nodes = mockGraph.nodes
|
||||
|
||||
// Use the root graph ID so the stale-guard passes
|
||||
store.restoreViewport('root')
|
||||
|
||||
@@ -202,10 +194,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
rafCallbacks[0](performance.now())
|
||||
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
|
||||
// Cleanup
|
||||
mockGraph.nodes = []
|
||||
mockGraph._nodes = []
|
||||
})
|
||||
|
||||
it('skips fitView if active graph changed before rAF fires', () => {
|
||||
|
||||
35
src/utils/histogramUtil.test.ts
Normal file
35
src/utils/histogramUtil.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { histogramToPath } from './histogramUtil'
|
||||
|
||||
describe('histogramToPath', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
})
|
||||
|
||||
it('returns a closed SVG path for valid histogram', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
})
|
||||
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
})
|
||||
})
|
||||
27
src/utils/histogramUtil.ts
Normal file
27
src/utils/histogramUtil.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Convert a histogram (arbitrary number of bins) into an SVG path string.
|
||||
* Applies square-root scaling and normalizes using the 99.5th percentile
|
||||
* to avoid outlier spikes.
|
||||
*/
|
||||
export function histogramToPath(histogram: Uint32Array): string {
|
||||
const len = histogram.length
|
||||
if (len === 0) return ''
|
||||
|
||||
const sqrtValues = new Float32Array(len)
|
||||
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])
|
||||
|
||||
const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
|
||||
const max = sorted[Math.floor((len - 1) * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const invMax = 1 / max
|
||||
const lastIdx = len - 1
|
||||
const parts: string[] = ['M0,1']
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = lastIdx === 0 ? 0.5 : i / lastIdx
|
||||
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
|
||||
parts.push(`L${x},${y}`)
|
||||
}
|
||||
parts.push('L1,1 Z')
|
||||
return parts.join(' ')
|
||||
}
|
||||
@@ -1,9 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil'
|
||||
import {
|
||||
computeUnionBounds,
|
||||
denormalize,
|
||||
gcd,
|
||||
lcm,
|
||||
normalize
|
||||
} from '@/utils/mathUtil'
|
||||
|
||||
describe('mathUtil', () => {
|
||||
describe('normalize', () => {
|
||||
it('normalizes value to 0-1', () => {
|
||||
expect(normalize(128, 0, 256)).toBe(0.5)
|
||||
expect(normalize(0, 0, 255)).toBe(0)
|
||||
expect(normalize(255, 0, 255)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0 when min equals max', () => {
|
||||
expect(normalize(5, 5, 5)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('denormalize', () => {
|
||||
it('converts normalized value back to range', () => {
|
||||
expect(denormalize(0.5, 0, 256)).toBe(128)
|
||||
expect(denormalize(0, 0, 255)).toBe(0)
|
||||
expect(denormalize(1, 0, 255)).toBe(255)
|
||||
})
|
||||
|
||||
it('round-trips with normalize', () => {
|
||||
expect(denormalize(normalize(100, 0, 255), 0, 255)).toBeCloseTo(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gcd', () => {
|
||||
it('should compute greatest common divisor correctly', () => {
|
||||
expect(gcd(48, 18)).toBe(6)
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
/**
|
||||
* Linearly maps a value from [min, max] to [0, 1].
|
||||
* Returns 0 when min equals max to avoid division by zero.
|
||||
*/
|
||||
export function normalize(value: number, min: number, max: number): number {
|
||||
return max === min ? 0 : (value - min) / (max - min)
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearly maps a normalized value from [0, 1] back to [min, max].
|
||||
*/
|
||||
export function denormalize(
|
||||
normalized: number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
return min + normalized * (max - min)
|
||||
}
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
/** Simple 2D point or size as [x, y] or [width, height] */
|
||||
|
||||
Reference in New Issue
Block a user