Compare commits

..

2 Commits

Author SHA1 Message Date
Terry Jia
2019704fc3 fix: address review feedback for Range editor 2026-04-08 21:19:02 -04:00
Terry Jia
b95636fc81 Range editor 2026-04-07 14:09:58 -04:00
51 changed files with 1142 additions and 1516 deletions

View File

@@ -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));

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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'

View File

@@ -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()
}
}

View File

@@ -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,

View File

@@ -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'
])
})
})

View File

@@ -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()

View File

@@ -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)
})
}
)

View File

@@ -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:",

View File

@@ -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

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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

View File

@@ -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')

View File

@@ -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()

View File

@@ -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')

View File

@@ -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>

View File

@@ -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',

View File

@@ -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)
})
})

View File

@@ -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'

View 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)
})
})

View 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>

View 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>

View 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)
})
})

View 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
}

View 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
}
}

View File

@@ -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'
])
})

View File

@@ -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)
}

View File

@@ -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)
})
})

View File

@@ -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()

View File

@@ -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[]

View 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 {}
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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({

View File

@@ -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

View File

@@ -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()
})
}

View File

@@ -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', () => {

View 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)
})
})

View 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(' ')
}

View File

@@ -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)

View File

@@ -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] */