Compare commits

..

9 Commits

Author SHA1 Message Date
huang47
27ba172198 ci: enforce exact critical unit coverage 2026-07-14 12:27:06 -07:00
huang47
5c76c00af1 ci: find exact workflow artifacts 2026-07-14 12:27:06 -07:00
huang47
e02c4a8e4a ci: compare coverage across Git revisions 2026-07-14 12:27:06 -07:00
huang47
e1d23d6126 ci: add critical coverage reports 2026-07-14 12:27:06 -07:00
Christian Byrne
945a143626 fix(ci): harden pr-backport — independent per-target attempts + comment on conflict failures (#13412)
## Symptom

When the auto-backport workflow (`.github/workflows/pr-backport.yaml`)
hits a cherry-pick **conflict**, it is supposed to comment on the
original PR telling the author to backport manually. On PR #13359
(backport to `cloud/1.45`, cherry-pick of `d6c582c39` conflicting on
`useSubscriptionDialog.test.ts`) **no comment was posted** — the author
(@huntcsg) got no notification and had to find the failure by digging
into Actions logs. See run
[28616756256](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/28616756256/job/84862400420).

## Root cause

The "Comment on failures" step actually ran and reached the `conflicts`
branch — the failure reason *was* populated and the `if:` condition
*was* met. The real failure is at the last line of the loop, under the
step's default `bash -e` shell:

```yaml
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
```

The job log ends with:

```
GraphQL: Unable to create comment because issue is locked (addComment)
##[error]Process completed with exit code 1.
```

PR #13359 is `locked: true`, so `gh pr comment` returns non-zero.
Because the call was unguarded under `set -e`, the step aborted on the
spot: the comment was lost and — critically for the general case — any
remaining failed targets in the loop would also be skipped. The step is
then marked failed with no actionable output on the PR.

(Related PR #13167 "attempt each backport target branch independently"
is still open/unmerged; this is a residual gap in the failure-comment
path.)

## Fix

Wrap every `gh pr comment` call in a `post_comment` helper. On failure
it emits a `::warning::` naming the target, the reason, and the
manual-backport branch (`backport-<pr>-to-<target>`) instead of
aborting, so the loop always attempts a comment for each failed target
and surfaces a clear log message when GitHub refuses (e.g. locked
issue). The conflict comment body now also states the manual backport
branch explicitly.

- YAML: `python3 -c 'import yaml; yaml.safe_load(...)'` passes.
- `actionlint`: no new warnings in the changed step (the 2 pre-existing
`SC2016` notes on the intentional single-quoted `envsubst` var lists are
unchanged).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:09:31 -07:00
pythongosssss
193bbaba81 fix: dont remove unowned callbacks when cleaning hooks on unmount (#12380)
## Summary

Minimaps unmounted cleanup blindly restored the callbacks that are
originally captured, even if other systems have chained their own
callbacks onto this, breaking other parts of the system (e.g. vue node
graph manager).
Recreation:    

1. Ensure minimap open
2. Enter subgraph 
3. Exit subgraph
4. Close minimap
5. Try adding a node/unpackign subgraph/etc <--- broken

## Changes

- **What**: 
- only replace callbacks that we own
- else function becomes no-op

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12380-fix-dont-remove-unowned-callbacks-when-cleaning-hooks-on-unmount-3666d73d3650817cbfe0d98ab98528b8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-07-10 20:24:20 +00:00
Alexis Rolland
1eacb224a1 Update commit author login retrieval in CLA workflow (#13544)
## Summary

Make CLA more robust by including commit authors in the allowlist even
if they have no GitHub account. This to ensure only PR authors are
required to sign.

## Changes

- **What**: `cla.yaml`
2026-07-10 20:16:51 +00:00
Terry Jia
4ed2fe70f3 feat: accept bboxes input and add grid snapping to Create Bounding Boxes (#13376)
## Summary
- Seed/override the canvas from an upstream bboxes input
- Add dotted grid background and magnetic snap-to-grid controls
- Darken the canvas well for contrast

BE is https://github.com/Comfy-Org/ComfyUI/pull/14724

## Screenshots (if applicable)



https://github.com/user-attachments/assets/7282f4d5-7cac-46f0-9d73-d0add37f4eb9
2026-07-10 15:40:17 +00:00
Terry Jia
5da5ee5031 feat: wire up Save 3D (Advanced) node family (CORE-329) (#13330)
## Summary
Register the save-side advanced nodes in the Load3D viewer
infrastructure: Save3DAdvanced reuses the mesh advanced extension, while
SaveGaussianSplat and SavePointCloud reuse the splat/point cloud preview
extensions.

Parameterize both extension factories with a loadFolder so save nodes
load the persisted file from the output folder instead of temp, and add
the node types to the lazy-load and viewport-state sets.

BE change https://github.com/Comfy-Org/ComfyUI/pull/14701
## Screenshots (if applicable)
Save 3D (Advanced)
<img width="1328" height="939" alt="image"
src="https://github.com/user-attachments/assets/c5f3cbe0-6e57-463c-9128-67490c2fc89e"
/>

Save Splat
<img width="1296" height="1052" alt="image"
src="https://github.com/user-attachments/assets/f05bbcf8-9794-4861-9dd7-3015d38b11d9"
/>
2026-07-10 15:39:51 +00:00
54 changed files with 3495 additions and 950 deletions

View File

@@ -8,17 +8,25 @@ inputs:
head-sha:
description: The commit SHA to find runs for
required: true
event:
description: Optional workflow event to match
required: false
default: ''
not-found-status:
description: Status to output when no run exists
description: Status to output when no matching run exists after polling
required: false
default: pending
wait-seconds:
description: Maximum time to poll for a matching successful run
required: false
default: '0'
token:
description: GitHub token for API access
required: true
outputs:
status:
description: One of 'ready', 'pending', 'failed', or the not-found-status value
description: One of 'ready', 'pending', 'failed', or the configured not-found status
value: ${{ steps.find.outputs.status }}
run-id:
description: The workflow run ID (only set when status is 'ready')
@@ -33,33 +41,94 @@ runs:
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
EVENT_NAME: ${{ inputs.event }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
WAIT_SECONDS: ${{ inputs.wait-seconds }}
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);
const waitSeconds = Number(process.env.WAIT_SECONDS);
if (!Number.isSafeInteger(waitSeconds) || waitSeconds < 0) {
core.setFailed('wait-seconds must be a non-negative integer');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
const deadline = Date.now() + waitSeconds * 1000;
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
while (true) {
let run;
let response;
let requestFailed = false;
try {
response = 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,
event: process.env.EVENT_NAME || undefined,
per_page: 10,
});
} catch (error) {
const status = error?.status;
const retryable =
typeof status !== 'number' ||
status === 408 ||
status === 429 ||
status >= 500;
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
if (!retryable) {
core.setFailed(
`listWorkflowRuns failed with status ${status}: ${String(error)}`
);
return;
}
requestFailed = true;
core.warning(`listWorkflowRuns failed: ${String(error)}`);
}
if (response) {
const workflowRuns = response.data.workflow_runs;
const successfulRun = workflowRuns.find(
({ status, conclusion }) =>
status === 'completed' && conclusion === 'success'
);
if (successfulRun) {
core.setOutput('status', 'ready');
core.setOutput('run-id', String(successfulRun.id));
return;
}
run =
workflowRuns.find(({ status }) => status !== 'completed') ??
workflowRuns[0];
}
if (run?.status === 'completed') {
if (run.conclusion === 'success') {
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
} else {
core.setOutput('status', 'failed');
}
return;
}
const remainingMilliseconds = deadline - Date.now();
if (remainingMilliseconds <= 0) {
core.setOutput(
'status',
requestFailed
? 'failed'
: run
? 'pending'
: process.env.NOT_FOUND_STATUS
);
return;
}
await new Promise((resolve) =>
setTimeout(resolve, Math.min(15_000, remainingMilliseconds))
);
}

View File

@@ -9,8 +9,8 @@ on:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name != 'push' }}
jobs:
changes:
@@ -38,14 +38,19 @@ jobs:
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Extract critical unit coverage
run: pnpm coverage:critical:extract --sha="${{ github.sha }}"
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'
if: always() && github.event_name != 'merge_group'
uses: actions/upload-artifact@v6
with:
name: unit-coverage
path: coverage/lcov.info
path: |
coverage/lcov.info
coverage/critical-unit-coverage.json
retention-days: 30
if-no-files-found: warn
if-no-files-found: error
- name: Upload coverage to Codecov
if: always()
@@ -55,3 +60,111 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
critical-unit-regression:
needs: test
if: >-
${{
github.event_name == 'pull_request' &&
needs.test.result == 'success' &&
(
github.base_ref == 'main' ||
github.base_ref == 'master' ||
startsWith(github.base_ref, 'dev') ||
startsWith(github.base_ref, 'core/') ||
startsWith(github.base_ref, 'desktop/')
)
}}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
- name: Resolve tested base commit
id: tested-base
run: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download head unit coverage
uses: actions/download-artifact@v7
with:
name: unit-coverage
path: temp/head-unit-coverage
- name: Find base unit coverage run
id: find-base-unit
uses: ./.github/actions/find-workflow-run
with:
workflow-id: ci-tests-unit.yaml
head-sha: ${{ steps.tested-base.outputs.sha }}
event: push
wait-seconds: 600
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download base unit coverage
id: download-base-unit
if: steps.find-base-unit.outputs.status == 'ready'
continue-on-error: true
uses: actions/download-artifact@v7
with:
name: unit-coverage
run-id: ${{ steps.find-base-unit.outputs.run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: temp/base-unit-coverage
- name: Regenerate missing base unit coverage
if: steps.find-base-unit.outputs.status != 'ready' || steps.download-base-unit.outcome != 'success'
env:
BASE_SHA: ${{ steps.tested-base.outputs.sha }}
run: |
base_dir="$RUNNER_TEMP/coverage-base"
cleanup() {
git worktree remove --force "$base_dir" || true
}
trap cleanup EXIT
git worktree add "$base_dir" "$BASE_SHA"
(
cd "$base_dir"
pnpm install --frozen-lockfile
pnpm test:coverage
if [ -f scripts/critical-coverage/extractCriticalCoverage.ts ]; then
pnpm coverage:critical:extract --sha="$BASE_SHA"
fi
)
mkdir -p temp/base-unit-coverage
cp "$base_dir/coverage/lcov.info" temp/base-unit-coverage/lcov.info
if [ -f "$base_dir/coverage/critical-unit-coverage.json" ]; then
cp "$base_dir/coverage/critical-unit-coverage.json" \
temp/base-unit-coverage/
fi
- name: Prepare critical unit coverage reports
env:
BASE_SHA: ${{ steps.tested-base.outputs.sha }}
run: |
mkdir -p temp/base-critical-coverage temp/head-critical-coverage
if [ -f temp/base-unit-coverage/critical-unit-coverage.json ]; then
cp temp/base-unit-coverage/critical-unit-coverage.json \
temp/base-critical-coverage/
else
pnpm coverage:critical:extract \
--input temp/base-unit-coverage/lcov.info \
--output temp/base-critical-coverage/critical-unit-coverage.json \
--sha="$BASE_SHA"
fi
cp temp/head-unit-coverage/critical-unit-coverage.json \
temp/head-critical-coverage/
- name: Compare critical unit coverage
run: >
pnpm coverage:critical:compare
--base temp/base-critical-coverage/critical-unit-coverage.json
--head temp/head-critical-coverage/critical-unit-coverage.json

View File

@@ -38,9 +38,11 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -278,32 +278,49 @@ jobs:
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
git push --force-with-lease origin "${BACKPORT_BRANCH}"
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
else
git push origin "${BACKPORT_BRANCH}"
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
# Return to main (keep the branch, we need it for PR)
git checkout main
git checkout main || git checkout -f main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort
git cherry-pick --abort || true
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main
git branch -D "${BACKPORT_BRANCH}"
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
fi
echo "::endgroup::"
@@ -384,6 +401,10 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -416,19 +437,37 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
elif [ "${reason}" = "already-exists" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -444,10 +483,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
fi
done

View File

@@ -476,6 +476,37 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
.toBe(true)
})
test(
'Closing minimap after subgraph navigation keeps Vue render in sync',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
// Round-trip layers Vue's onNodeAdded wrapper on top of the minimap's.
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await comfyPage.subgraph.exitViaBreadcrumb()
// Minimap unmount must not clobber the Vue wrapper layered above it.
await comfyPage.page
.getByTestId(TestIds.canvas.closeMinimapButton)
.click()
const subgraphFixture =
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await comfyPage.contextMenu.openForVueNode(subgraphFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
await comfyPage.contextMenu.waitForHidden()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(2)
await expect(
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
).toHaveCount(0)
}
)
})
test.describe('Minimap mobile', { tag: ['@mobile', '@canvas'] }, () => {

View File

@@ -19,6 +19,8 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"coverage:critical:compare": "tsx scripts/critical-coverage/compareCriticalCoverage.ts",
"coverage:critical:extract": "tsx scripts/critical-coverage/extractCriticalCoverage.ts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",

View File

@@ -0,0 +1,494 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, onTestFinished } from 'vitest'
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
import {
readCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
import type {
CriticalBranchCoverage,
CriticalCoverageReport
} from './criticalCoverageReport'
const require = createRequire(import.meta.url)
const TSX_CLI = require.resolve('tsx/cli')
const SCRIPT_PATH = join(
dirname(fileURLToPath(import.meta.url)),
'compareCriticalCoverage.ts'
)
interface SourceChange {
base: string
head: string
headPath?: string
}
const BASE_SHA = 'a'.repeat(40)
const HEAD_SHA = 'b'.repeat(40)
describe('compareCriticalCoverage CLI', () => {
it('passes a non-negative delta and writes the job summary', () => {
const directory = createTempDirectory()
const summaryPath = join(directory, 'summary.md')
const shared = createBranch('src/stores/a.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared]),
createReport(HEAD_SHA, [shared])
)
const result = runComparison(paths, { GITHUB_STEP_SUMMARY: summaryPath })
expect(result.status).toBe(0)
const summary = readFileSync(summaryPath, 'utf-8')
expect(summary).toContain('Critical Unit Coverage Gate: PASS')
expect(summary).toContain('| Covered branch delta | 0 |')
})
it('fails a negative delta and lists the regressed branch', () => {
const directory = createTempDirectory()
const covered = createBranch('src/stores/a.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [covered]),
createReport(HEAD_SHA, [{ ...covered, taken: 0, covered: false }])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stdout).toContain('Critical Unit Coverage Gate: FAIL')
expect(result.stdout).toContain('| Covered branch delta | -1 |')
expect(result.stdout).toContain('| `src/stores/a.ts` | 1 | 0:0 | 1 | 0 |')
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('renders an unknown taken count as unavailable', () => {
const directory = createTempDirectory()
const covered = createBranch('src/stores/a.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [covered]),
createReport(HEAD_SHA, [{ ...covered, taken: null, covered: false }])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stdout).toContain('| `src/stores/a.ts` | 1 | 0:0 | 1 | - |')
})
it('fails when newly covered branches offset a regression', () => {
const directory = createTempDirectory()
const regressed = createBranch('src/stores/regressed.ts', true)
const improved = createBranch('src/stores/improved.ts', false)
const paths = writeReports(
directory,
createReport(BASE_SHA, [regressed, improved]),
createReport(HEAD_SHA, [
{ ...regressed, taken: 0, covered: false },
{ ...improved, taken: 1, covered: true }
])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stdout).toContain('| Covered branch delta | 0 |')
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when a covered branch disappears from unchanged source', () => {
const directory = createTempDirectory()
const shared = createBranch('src/stores/shared.ts', true)
const missing = createBranch('src/stores/missing.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared, missing]),
createReport(HEAD_SHA, [shared])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
expect(result.stdout).toContain(
'| `src/stores/missing.ts` | 1 | 0:0 | 1 | - |'
)
})
it('ignores a covered branch on a changed source line', () => {
const directory = createTempDirectory()
const file = 'src/stores/changed.ts'
const shared = createBranch('src/stores/shared.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared, createBranch(file, true)]),
createReport(HEAD_SHA, [shared])
)
const result = runComparison(
paths,
{},
{
[file]: {
base: 'export const changed = value ? 1 : 0\n',
head: 'export const changed = value ?? 0\n'
}
}
)
expect(result.status).toBe(0)
expect(result.stdout).toContain('| Base-only branches | 1 |')
})
it('fails when a covered branch moves and becomes uncovered', () => {
const directory = createTempDirectory()
const shared = createBranch('src/stores/shared.ts', true)
const moved = createBranch('src/stores/moved.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared, moved]),
createReport(HEAD_SHA, [
shared,
{
...moved,
key: 'src/stores/moved.ts:2:0:0',
line: 2,
taken: 0,
covered: false
}
])
)
const result = runComparison(
paths,
{},
{
'src/stores/moved.ts': {
base: 'export const moved = value ? 1 : 0\n',
head: '\nexport const moved = value ? 1 : 0\n'
}
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when an inserted branch renumbers later LCOV blocks', () => {
const directory = createTempDirectory()
const file = 'src/stores/renumbered.ts'
const target = createBranch(file, true)
const inserted = createBranch(file, true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [target]),
createReport(HEAD_SHA, [
inserted,
{
...target,
key: `${file}:2:1:0`,
line: 2,
block: '1',
taken: 0,
covered: false
}
])
)
const result = runComparison(
paths,
{},
{
[file]: {
base: 'export const target = value ? 1 : 0\n',
head: 'export const inserted = other ? 1 : 0\nexport const target = value ? 1 : 0\n'
}
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when same-line branch ordinals cross a digit boundary', () => {
const directory = createTempDirectory()
const file = 'src/stores/doubleDigit.ts'
const baseCovered = createBranch(file, true, '9')
const baseUncovered = createBranch(file, false, '10')
const headUncovered = createBranch(file, false, '10')
const headCovered = createBranch(file, true, '11')
const paths = writeReports(
directory,
createReport(BASE_SHA, [baseCovered, baseUncovered]),
createReport(HEAD_SHA, [headUncovered, headCovered])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when a renamed critical file loses branch coverage', () => {
const directory = createTempDirectory()
const baseFile = 'src/stores/renamedBase.ts'
const headFile = 'src/stores/renamedHead.ts'
const shared = createBranch('src/stores/shared.ts', true)
const baseBranch = createBranch(baseFile, true)
const headBranch = createBranch(headFile, false)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared, baseBranch]),
createReport(HEAD_SHA, [shared, headBranch])
)
const source = 'export const renamed = value ? 1 : 0\n'
const result = runComparison(
paths,
{},
{
[baseFile]: { base: source, head: source, headPath: headFile }
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when the reports have no comparable branches', () => {
const directory = createTempDirectory()
const summaryPath = join(directory, 'summary.md')
const paths = writeReports(
directory,
createReport(BASE_SHA, [createBranch('src/stores/base.ts', true)]),
createReport(HEAD_SHA, [createBranch('src/stores/head.ts', true)])
)
const result = runComparison(paths, { GITHUB_STEP_SUMMARY: summaryPath })
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'No comparable critical unit branches found (base: 1, head: 1)'
)
expect(readFileSync(summaryPath, 'utf-8')).toContain(
'Critical Unit Coverage Gate: FAIL'
)
})
it('fails when the head removes a critical directory', () => {
const directory = createTempDirectory()
const shared = createBranch('src/stores/shared.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared], ['src/stores', 'src/utils']),
createReport(HEAD_SHA, [shared], ['src/stores'])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'Critical coverage scope removed: src/utils'
)
})
it('allows the head to add a critical directory', () => {
const directory = createTempDirectory()
const shared = createBranch('src/stores/shared.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared], ['src/stores']),
createReport(HEAD_SHA, [shared], ['src/stores', 'src/utils'])
)
expect(runComparison(paths).status).toBe(0)
})
it('reports input errors without a stack trace', () => {
const directory = createTempDirectory()
const summaryPath = join(directory, 'summary.md')
const missingPath = join(directory, 'missing.json')
const result = spawnSync(
process.execPath,
[TSX_CLI, SCRIPT_PATH, '--base', missingPath, '--head', missingPath],
{
cwd: directory,
encoding: 'utf-8',
env: { ...process.env, GITHUB_STEP_SUMMARY: summaryPath }
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain(`ENOENT: no such file or directory`)
expect(result.stderr).not.toContain(' at ')
expect(readFileSync(summaryPath, 'utf-8')).toContain(
'Critical Unit Coverage Gate: ERROR'
)
})
it('rejects unknown options', () => {
const result = spawnSync(
process.execPath,
[TSX_CLI, SCRIPT_PATH, '--unknown'],
{ encoding: 'utf-8' }
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('--unknown')
expect(result.stderr).not.toContain(' at ')
})
})
function runComparison(
paths: { basePath: string; headPath: string },
env: NodeJS.ProcessEnv = {},
sourceChanges?: Record<string, SourceChange>
): ReturnType<typeof spawnSync> {
const cwd = dirname(paths.basePath)
const { baseSha, headSha } = createGitHistory(cwd, sourceChanges ?? {})
updateReportSha(paths.basePath, baseSha)
updateReportSha(paths.headPath, headSha)
return spawnSync(
process.execPath,
[TSX_CLI, SCRIPT_PATH, '--base', paths.basePath, '--head', paths.headPath],
{
cwd,
encoding: 'utf-8',
env: { ...process.env, ...env }
}
)
}
function createGitHistory(
directory: string,
sourceChanges: Record<string, SourceChange>
): { baseSha: string; headSha: string } {
execFileSync('git', ['init', '-q'], { cwd: directory })
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
cwd: directory
})
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: directory })
writeFileSync(join(directory, 'fixture.txt'), 'base\n')
for (const [path, change] of Object.entries(sourceChanges)) {
const absolutePath = join(directory, path)
mkdirSync(dirname(absolutePath), { recursive: true })
writeFileSync(absolutePath, change.base)
}
execFileSync('git', ['add', 'fixture.txt', ...Object.keys(sourceChanges)], {
cwd: directory
})
execFileSync('git', ['commit', '-q', '-m', 'base'], { cwd: directory })
const baseSha = gitSha(directory)
for (const [path, change] of Object.entries(sourceChanges)) {
const headPath = change.headPath ?? path
if (headPath !== path) {
mkdirSync(dirname(join(directory, headPath)), { recursive: true })
execFileSync('git', ['mv', path, headPath], { cwd: directory })
}
writeFileSync(join(directory, headPath), change.head)
}
if (Object.keys(sourceChanges).length > 0) {
const changedPaths = Object.entries(sourceChanges).map(
([path, change]) => change.headPath ?? path
)
execFileSync('git', ['add', '-A', '--', ...changedPaths], {
cwd: directory
})
}
execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'head'], {
cwd: directory
})
return { baseSha, headSha: gitSha(directory) }
}
function updateReportSha(path: string, sha: string): void {
const report = readCriticalCoverageReport(path)
writeCriticalCoverageReport({ ...report, sha }, path)
}
function gitSha(directory: string): string {
return execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: directory,
encoding: 'utf-8'
}).trim()
}
function writeReports(
directory: string,
base: CriticalCoverageReport,
head: CriticalCoverageReport
): { basePath: string; headPath: string } {
const basePath = join(directory, 'base.json')
const headPath = join(directory, 'head.json')
writeCriticalCoverageReport(base, basePath)
writeCriticalCoverageReport(head, headPath)
return { basePath, headPath }
}
function createReport(
sha: string,
branches: CriticalBranchCoverage[],
criticalDirs: readonly string[] = CRITICAL_COVERAGE_DIRS
): CriticalCoverageReport {
return {
schemaVersion: 1,
source: 'lcov',
sha,
generatedAt: '2026-07-10T00:00:00.000Z',
inputPath: 'lcov.info',
criticalDirs,
totals: {
files: new Set(branches.map(({ file }) => file)).size,
branches: branches.length,
coveredBranches: branches.filter(({ covered }) => covered).length
},
branches
}
}
function createBranch(
file: string,
covered: boolean,
block = '0'
): CriticalBranchCoverage {
return {
key: `${file}:1:${block}:0`,
file,
line: 1,
block,
branch: '0',
taken: covered ? 1 : 0,
covered
}
}
function createTempDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), 'critical-coverage-cli-'))
onTestFinished(() => rmSync(directory, { recursive: true, force: true }))
return directory
}

View File

@@ -0,0 +1,162 @@
import { appendFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { createGitLocationMapper } from './criticalCoverageGitDiff'
import {
compareCriticalCoverageReports,
readCriticalCoverageReport
} from './criticalCoverageReport'
import type { CriticalCoverageComparison } from './criticalCoverageReport'
interface Options {
base: string
head: string
}
interface GateResult {
status: 'PASS' | 'FAIL'
message: string
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const summary = `## Critical Unit Coverage Gate: ERROR\n\n${message}`
process.stderr.write(`${message}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
process.exitCode = 1
}
function main(): void {
const options = parseOptions(process.argv.slice(2))
const base = readCriticalCoverageReport(options.base)
const head = readCriticalCoverageReport(options.head)
const comparison = compareCriticalCoverageReports(
base,
head,
createGitLocationMapper(base.sha, head.sha)
)
const result = evaluateCoverageGate(comparison)
const summary = formatComparison(comparison, result)
process.stdout.write(`${summary}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
if (result.status === 'FAIL') {
process.stderr.write(`${result.message}\n`)
process.exitCode = 1
}
}
function parseOptions(args: string[]): Options {
const { values: options } = parseArgs({
args,
options: {
base: { type: 'string' },
head: { type: 'string' }
},
strict: true,
allowPositionals: false
})
if (!options.base || !options.head) {
throw new Error(
'Usage: compareCriticalCoverage --base <json> --head <json>'
)
}
return {
base: options.base,
head: options.head
}
}
function evaluateCoverageGate(
comparison: CriticalCoverageComparison
): GateResult {
if (comparison.commonBranches === 0) {
return {
status: 'FAIL',
message: `No comparable critical unit branches found (base: ${comparison.baseBranches}, head: ${comparison.headBranches}).`
}
}
if (comparison.regressions.length > 0) {
const regressionCount = comparison.regressions.length
return {
status: 'FAIL',
message: `Critical unit coverage regressed on ${regressionCount} previously covered ${regressionCount === 1 ? 'branch' : 'branches'}.`
}
}
return {
status: 'PASS',
message: 'Critical branch coverage did not regress.'
}
}
function formatComparison(
comparison: CriticalCoverageComparison,
result: GateResult
): string {
const lines = [
`## Critical Unit Coverage Gate: ${result.status}`,
'',
`Base tested commit: \`${comparison.baseSha}\``,
`PR tested commit: \`${comparison.headSha}\``,
'',
'| Metric | Count |',
'|---|--:|',
`| Base critical branches | ${comparison.baseBranches} |`,
`| Head critical branches | ${comparison.headBranches} |`,
`| Comparable critical branches | ${comparison.commonBranches} |`,
`| Covered in base | ${comparison.commonCoveredBranchesInBase} |`,
`| Covered in head | ${comparison.commonCoveredBranchesInHead} |`,
`| Covered branch delta | ${formatSignedCount(comparison.coveredBranchDelta)} |`,
`| Base-only branches | ${comparison.baseOnlyBranches} |`,
`| Head-only branches | ${comparison.headOnlyBranches} |`,
`| Covered-to-uncovered branches | ${comparison.regressions.length} |`,
'',
`${result.status}: ${result.message}`
]
if (comparison.regressions.length === 0) {
return lines.join('\n')
}
lines.push('')
lines.push('| File | Line | Branch | Base | Head |')
lines.push('|---|--:|---|--:|--:|')
for (const regression of comparison.regressions.slice(0, 25)) {
lines.push(
`| \`${regression.file}\` | ${regression.line} | ${regression.block}:${regression.branch} | ${formatTaken(regression.baseTaken)} | ${formatTaken(regression.headTaken)} |`
)
}
if (comparison.regressions.length > 25) {
lines.push('')
lines.push(
`${comparison.regressions.length - 25} additional regressions omitted from this summary.`
)
}
return lines.join('\n')
}
function formatTaken(value: number | null): string {
return value === null ? '-' : String(value)
}
function formatSignedCount(value: number): string {
return value > 0 ? `+${value}` : String(value)
}

View File

@@ -0,0 +1,45 @@
export const CRITICAL_COVERAGE_DIRS = [
'src/base',
'src/composables',
'src/core',
'src/lib/litegraph/src/node',
'src/lib/litegraph/src/subgraph',
'src/lib/litegraph/src/utils',
'src/platform/assets/composables',
'src/platform/assets/mappings',
'src/platform/assets/schemas',
'src/platform/assets/services',
'src/platform/assets/utils',
'src/platform/errorCatalog',
'src/platform/keybindings',
'src/platform/missingMedia',
'src/platform/missingModel',
'src/platform/navigation',
'src/platform/nodeReplacement',
'src/platform/remote',
'src/platform/remoteConfig',
'src/platform/secrets',
'src/platform/settings',
'src/platform/workflow',
'src/platform/workspace/api',
'src/platform/workspace/auth',
'src/platform/workspace/composables',
'src/platform/workspace/stores',
'src/platform/workspace/utils',
'src/schemas',
'src/scripts',
'src/services',
'src/stores',
'src/utils',
'src/workbench/extensions/manager/composables',
'src/workbench/extensions/manager/services',
'src/workbench/extensions/manager/stores',
'src/workbench/extensions/manager/utils',
'src/workbench/utils'
] as const
export function isCriticalCoveragePath(filePath: string): boolean {
return CRITICAL_COVERAGE_DIRS.some(
(dir) => filePath === dir || filePath.startsWith(`${dir}/`)
)
}

View File

@@ -0,0 +1,187 @@
import { execFileSync } from 'node:child_process'
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
import { isGitCommitSha } from './criticalCoverageReport'
import type {
CriticalCoverageLocation,
CriticalCoverageLocationMapper
} from './criticalCoverageReport'
interface DiffFile {
headPath: string | null
hunks: DiffHunk[]
}
interface DiffHunk {
baseStart: number
baseCount: number
headCount: number
}
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,(\d+))? @@/
const MAX_GIT_OUTPUT_BYTES = 100 * 1024 * 1024
export function createGitLocationMapper(
baseSha: string,
headSha: string,
cwd = process.cwd()
): CriticalCoverageLocationMapper {
if (!isGitCommitSha(baseSha) || !isGitCommitSha(headSha)) {
throw new Error(
'Critical coverage comparison requires full Git commit SHAs'
)
}
const files = readDiffFiles(baseSha, headSha, cwd)
const diff = runGitDiff(baseSha, headSha, cwd, [
'--unified=0',
'--no-color',
'--no-ext-diff'
])
addDiffHunks(files, diff)
return (file, line) => mapBaseLocation(files.get(file), file, line)
}
function readDiffFiles(
baseSha: string,
headSha: string,
cwd: string
): Map<string, DiffFile> {
const output = runGitDiff(baseSha, headSha, cwd, ['--name-status', '-z'])
const tokens = output.split('\0')
const files = new Map<string, DiffFile>()
for (let index = 0; index < tokens.length; ) {
const status = tokens[index++]
if (!status) {
continue
}
const basePath = tokens[index++]
if (!basePath) {
break
}
if (status.startsWith('R')) {
const headPath = tokens[index++]
if (!headPath) {
break
}
files.set(basePath, { headPath, hunks: [] })
continue
}
if (status === 'A') {
continue
}
files.set(basePath, {
headPath: status === 'D' ? null : basePath,
hunks: []
})
}
return files
}
function runGitDiff(
baseSha: string,
headSha: string,
cwd: string,
options: string[]
): string {
return execFileSync(
'git',
[
'-c',
'core.quotePath=false',
'diff',
'--find-renames',
...options,
baseSha,
headSha,
'--',
...CRITICAL_COVERAGE_DIRS
],
{ cwd, encoding: 'utf-8', maxBuffer: MAX_GIT_OUTPUT_BYTES }
)
}
function addDiffHunks(files: Map<string, DiffFile>, diff: string): void {
let basePath: string | null = null
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) {
basePath = null
continue
}
if (line.startsWith('--- ')) {
basePath = parseDiffPath(line.slice(4), 'a/')
continue
}
const match = HUNK_HEADER.exec(line)
const file = basePath ? files.get(basePath) : undefined
if (!match || !file) {
continue
}
file.hunks.push({
baseStart: Number(match[1]),
baseCount: Number(match[2] ?? 1),
headCount: Number(match[3] ?? 1)
})
}
}
function parseDiffPath(path: string, prefix: string): string | null {
return path.startsWith(prefix) ? path.slice(prefix.length) : null
}
function mapBaseLocation(
file: DiffFile | undefined,
basePath: string,
line: number
): CriticalCoverageLocation | null {
if (!file) {
return { file: basePath, line }
}
if (file.headPath === null) {
return null
}
const mappedLine = mapBaseLine(file.hunks, line)
return mappedLine === null ? null : { file: file.headPath, line: mappedLine }
}
function mapBaseLine(hunks: DiffHunk[], line: number): number | null {
let offset = 0
for (const hunk of hunks) {
if (hunk.baseCount === 0) {
if (line <= hunk.baseStart) {
return line + offset
}
offset += hunk.headCount
continue
}
if (line < hunk.baseStart) {
return line + offset
}
if (line < hunk.baseStart + hunk.baseCount) {
return null
}
offset += hunk.headCount - hunk.baseCount
}
return line + offset
}

View File

@@ -0,0 +1,404 @@
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, onTestFinished } from 'vitest'
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
import {
compareCriticalCoverageReports,
createCriticalCoverageReport,
readCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
import type {
CriticalBranchCoverage,
CriticalCoverageReport
} from './criticalCoverageReport'
const GENERATED_AT = '2026-07-10T00:00:00.000Z'
const BASE_SHA = 'a'.repeat(40)
const HEAD_SHA = 'b'.repeat(40)
const require = createRequire(import.meta.url)
const TSX_CLI = require.resolve('tsx/cli')
const EXTRACT_SCRIPT_PATH = join(
dirname(fileURLToPath(import.meta.url)),
'extractCriticalCoverage.ts'
)
interface InvalidReportCase {
name: string
corrupt(report: CriticalCoverageReport): unknown
}
const INVALID_REPORT_CASES: InvalidReportCase[] = [
{
name: 'invalid commit SHA',
corrupt(report) {
return { ...report, sha: '--output=/tmp/coverage' }
}
},
{
name: 'a branch outside the recorded critical directories',
corrupt(report) {
return { ...report, criticalDirs: ['src/components'] }
}
},
{
name: 'fractional totals',
corrupt(report) {
return {
...report,
totals: { ...report.totals, files: 0.5 }
}
}
},
{
name: 'inconsistent totals',
corrupt(report) {
return {
...report,
totals: { ...report.totals, branches: report.totals.branches + 1 }
}
}
},
{
name: 'arbitrary branch keys',
corrupt(report) {
return {
...report,
branches: report.branches.map((branch) => ({
...branch,
key: 'arbitrary'
}))
}
}
},
{
name: 'duplicate branch keys',
corrupt(report) {
return {
...report,
totals: {
...report.totals,
branches: report.totals.branches * 2,
coveredBranches: report.totals.coveredBranches * 2
},
branches: [...report.branches, ...report.branches]
}
}
},
{
name: 'inconsistent branch coverage',
corrupt(report) {
return {
...report,
totals: { ...report.totals, coveredBranches: 0 },
branches: report.branches.map((branch) => ({
...branch,
covered: false
}))
}
}
}
]
describe('createCriticalCoverageReport', () => {
it('extracts critical branches and calculates coverage totals', () => {
const fixture = createLcovFixture(`
SF:src/stores/queueStore.ts
BRDA:10,0,0,3
BRDA:10,0,1,0
BRDA:11,0,0,-
BRDA:0,0,2,1
BRDA:12,invalid,0,1
BRDA:13,0,0,1.5
end_of_record
SF:src/components/QueuePanel.vue
BRDA:20,0,0,4
end_of_record
`)
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
expect(report.totals).toEqual({
files: 1,
branches: 3,
coveredBranches: 1
})
expect(
report.branches.map(({ key, taken, covered }) => ({
key,
taken,
covered
}))
).toEqual([
{
key: 'src/stores/queueStore.ts:10:0:0',
taken: 3,
covered: true
},
{
key: 'src/stores/queueStore.ts:10:0:1',
taken: 0,
covered: false
},
{
key: 'src/stores/queueStore.ts:11:0:0',
taken: null,
covered: false
}
])
})
it('merges duplicate branch records into a consistent coverage state', () => {
const fixture = createLcovFixture(`
SF:src/stores/queueStore.ts
BRDA:10,0,0,-
BRDA:10,0,0,2
end_of_record
`)
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
expect(report.branches).toEqual([
expect.objectContaining({ taken: 2, covered: true })
])
})
it('rejects an empty LCOV taken value', () => {
const fixture = createLcovFixture(`
SF:src/stores/queueStore.ts
BRDA:10,0,0,
end_of_record
`)
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
expect(report.branches).toEqual([])
})
it('reports extraction errors without a stack trace', () => {
const directory = createTempDirectory()
const summaryPath = join(directory, 'summary.md')
const missingPath = join(directory, 'missing.info')
const result = spawnSync(
process.execPath,
[TSX_CLI, EXTRACT_SCRIPT_PATH, '--input', missingPath, '--sha', HEAD_SHA],
{
encoding: 'utf-8',
env: { ...process.env, GITHUB_STEP_SUMMARY: summaryPath }
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain(`ENOENT: no such file or directory`)
expect(result.stderr).not.toContain(' at ')
expect(readFileSync(summaryPath, 'utf-8')).toContain(
'Critical Unit Coverage Extraction: ERROR'
)
})
it.for([
{
name: 'an unknown option',
args: ['--unknown'],
expected: '--unknown'
},
{
name: 'an incomplete option',
args: ['--input', `--sha=${HEAD_SHA}`],
expected: '--input'
}
])('rejects $name', ({ args, expected }) => {
const result = spawnSync(
process.execPath,
[TSX_CLI, EXTRACT_SCRIPT_PATH, ...args],
{ encoding: 'utf-8' }
)
expect(result.status).toBe(1)
expect(result.stderr).toContain(expected)
expect(result.stderr).not.toContain(' at ')
})
})
describe('readCriticalCoverageReport', () => {
it('accepts a report with its own valid critical-directory scope', () => {
const directory = createTempDirectory()
const inputPath = join(directory, 'coverage.json')
const report = {
...createReport(HEAD_SHA, [
createBranch('src/stores/queueStore.ts', true)
]),
criticalDirs: ['src/stores']
}
writeFileSync(inputPath, JSON.stringify(report))
expect(readCriticalCoverageReport(inputPath)).toEqual(report)
})
it('round-trips negative LCOV branch counts', () => {
const fixture = createLcovFixture(`
SF:src/utils/linkFixer.ts
BRDA:449,72,1,-2
end_of_record
`)
const outputPath = join(fixture.directory, 'coverage.json')
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
writeCriticalCoverageReport(report, outputPath)
expect(readCriticalCoverageReport(outputPath)).toEqual(report)
})
it('round-trips branch ordinals reused on different lines', () => {
const fixture = createLcovFixture(`
SF:src/stores/queueStore.ts
BRDA:10,0,0,1
BRDA:11,0,0,1
end_of_record
`)
const outputPath = join(fixture.directory, 'coverage.json')
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
writeCriticalCoverageReport(report, outputPath)
expect(readCriticalCoverageReport(outputPath)).toEqual(report)
})
it('rejects malformed artifacts', () => {
const directory = createTempDirectory()
const inputPath = join(directory, 'coverage.json')
writeFileSync(
inputPath,
JSON.stringify({
...createReport(HEAD_SHA, []),
branches: [{ key: 'invalid' }]
})
)
expect(() => readCriticalCoverageReport(inputPath)).toThrow(
`Invalid critical coverage report: ${inputPath}`
)
})
it.for(INVALID_REPORT_CASES)('rejects $name', ({ corrupt }) => {
const directory = createTempDirectory()
const inputPath = join(directory, 'coverage.json')
const report = createReport(HEAD_SHA, [
createBranch('src/stores/queueStore.ts', true)
])
writeFileSync(inputPath, JSON.stringify(corrupt(report)))
expect(() => readCriticalCoverageReport(inputPath)).toThrow(
`Invalid critical coverage report: ${inputPath}`
)
})
})
describe('compareCriticalCoverageReports', () => {
it('reports covered base-only branches as regressions', () => {
const shared = createBranch('src/stores/shared.ts', true)
const base = createReport(BASE_SHA, [
shared,
createBranch('src/stores/base-only.ts', true)
])
const head = createReport(HEAD_SHA, [
shared,
createBranch('src/stores/head-only.ts', false)
])
expect(compareCriticalCoverageReports(base, head)).toMatchObject({
commonBranches: 1,
baseOnlyBranches: 1,
headOnlyBranches: 1,
commonCoveredBranchesInBase: 1,
commonCoveredBranchesInHead: 1,
coveredBranchDelta: 0,
regressions: [
expect.objectContaining({
file: 'src/stores/base-only.ts',
baseTaken: 1,
headTaken: null
})
]
})
})
})
function createLcovFixture(lcov: string): {
directory: string
inputPath: string
} {
const directory = createTempDirectory()
const inputPath = join(directory, 'lcov.info')
writeFileSync(inputPath, lcov.trimStart())
return { directory, inputPath }
}
function createTempDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), 'critical-coverage-'))
onTestFinished(() => rmSync(directory, { recursive: true, force: true }))
return directory
}
function createReport(
sha: string,
branches: CriticalBranchCoverage[]
): CriticalCoverageReport {
return {
schemaVersion: 1,
source: 'lcov',
sha,
generatedAt: GENERATED_AT,
inputPath: 'lcov.info',
criticalDirs: CRITICAL_COVERAGE_DIRS,
totals: {
files: new Set(branches.map(({ file }) => file)).size,
branches: branches.length,
coveredBranches: branches.filter(({ covered }) => covered).length
},
branches
}
}
function createBranch(file: string, covered: boolean): CriticalBranchCoverage {
return {
key: `${file}:1:0:0`,
file,
line: 1,
block: '0',
branch: '0',
taken: covered ? 1 : 0,
covered
}
}

View File

@@ -0,0 +1,485 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
CRITICAL_COVERAGE_DIRS,
isCriticalCoveragePath
} from './criticalCoverageDirs'
export interface CriticalBranchCoverage {
key: string
file: string
line: number
block: string
branch: string
taken: number | null
covered: boolean
}
export interface CriticalCoverageReport {
schemaVersion: 1
source: 'lcov'
sha: string
generatedAt: string
inputPath: string
criticalDirs: readonly string[]
totals: {
files: number
branches: number
coveredBranches: number
}
branches: CriticalBranchCoverage[]
}
export interface CriticalCoverageRegression extends CriticalBranchCoverage {
baseTaken: number | null
headTaken: number | null
}
export interface CriticalCoverageComparison {
baseSha: string
headSha: string
baseBranches: number
headBranches: number
commonBranches: number
baseOnlyBranches: number
headOnlyBranches: number
commonCoveredBranchesInBase: number
commonCoveredBranchesInHead: number
coveredBranchDelta: number
regressions: CriticalCoverageRegression[]
}
export interface CriticalCoverageLocation {
file: string
line: number
}
export type CriticalCoverageLocationMapper = (
file: string,
line: number
) => CriticalCoverageLocation | null
interface CreateReportOptions {
inputPath: string
sha: string
generatedAt?: string
cwd?: string
}
interface IndexedBranch {
branch: CriticalBranchCoverage
mappedLocation: CriticalCoverageLocation | null
}
export function createCriticalCoverageReport({
inputPath,
sha,
generatedAt = new Date().toISOString(),
cwd = process.cwd()
}: CreateReportOptions): CriticalCoverageReport {
if (!isGitCommitSha(sha)) {
throw new Error(`Invalid Git commit SHA: ${sha}`)
}
const lcov = readFileSync(inputPath, 'utf-8')
const branches = parseCriticalBranches(lcov, cwd)
const files = new Set(branches.map((branch) => branch.file))
const coveredBranches = branches.filter((branch) => branch.covered).length
return {
schemaVersion: 1,
source: 'lcov',
sha,
generatedAt,
inputPath,
criticalDirs: CRITICAL_COVERAGE_DIRS,
totals: {
files: files.size,
branches: branches.length,
coveredBranches
},
branches
}
}
export function writeCriticalCoverageReport(
report: CriticalCoverageReport,
outputPath: string
): void {
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`)
}
export function readCriticalCoverageReport(
inputPath: string
): CriticalCoverageReport {
const parsed: unknown = JSON.parse(readFileSync(inputPath, 'utf-8'))
if (!isCriticalCoverageReport(parsed)) {
throw new Error(`Invalid critical coverage report: ${inputPath}`)
}
return parsed
}
export function compareCriticalCoverageReports(
base: CriticalCoverageReport,
head: CriticalCoverageReport,
mapBaseLocation: CriticalCoverageLocationMapper = identityLocationMapper
): CriticalCoverageComparison {
assertCriticalCoverageScopePreserved(base.criticalDirs, head.criticalDirs)
const baseBranches = indexBranches(base.branches, mapBaseLocation, 'base')
const headBranches = indexBranches(
head.branches,
identityLocationMapper,
'head'
)
const regressions: CriticalCoverageRegression[] = []
let commonBranches = 0
let commonCoveredBranchesInBase = 0
let commonCoveredBranchesInHead = 0
let baseOnlyBranches = 0
for (const [key, indexedBaseBranch] of baseBranches) {
const indexedHeadBranch = headBranches.get(key)
const baseBranch = indexedBaseBranch.branch
if (!indexedHeadBranch) {
baseOnlyBranches++
if (baseBranch.covered && indexedBaseBranch.mappedLocation) {
regressions.push({
...baseBranch,
file: indexedBaseBranch.mappedLocation.file,
line: indexedBaseBranch.mappedLocation.line,
taken: null,
covered: false,
baseTaken: baseBranch.taken,
headTaken: null
})
}
continue
}
const headBranch = indexedHeadBranch.branch
commonBranches++
if (baseBranch.covered) {
commonCoveredBranchesInBase++
}
if (headBranch.covered) {
commonCoveredBranchesInHead++
}
if (baseBranch.covered && !headBranch.covered) {
regressions.push({
...headBranch,
baseTaken: baseBranch.taken,
headTaken: headBranch.taken
})
}
}
return {
baseSha: base.sha,
headSha: head.sha,
baseBranches: base.branches.length,
headBranches: head.branches.length,
commonBranches,
baseOnlyBranches,
headOnlyBranches: [...headBranches.keys()].filter(
(key) => !baseBranches.has(key)
).length,
commonCoveredBranchesInBase,
commonCoveredBranchesInHead,
coveredBranchDelta:
commonCoveredBranchesInHead - commonCoveredBranchesInBase,
regressions: regressions.sort(compareBranches)
}
}
function parseCriticalBranches(
lcov: string,
cwd: string
): CriticalBranchCoverage[] {
let currentFile = ''
const branches = new Map<string, CriticalBranchCoverage>()
for (const line of lcov.split('\n')) {
if (line.startsWith('SF:')) {
currentFile = normalizeCoveragePath(line.slice(3), cwd)
continue
}
if (!line.startsWith('BRDA:') || !isCriticalCoveragePath(currentFile)) {
continue
}
const branch = parseBranchData(currentFile, line.slice(5))
if (!branch) {
continue
}
const existing = branches.get(branch.key)
if (!existing) {
branches.set(branch.key, branch)
continue
}
branches.set(branch.key, mergeBranchCoverage(existing, branch))
}
return [...branches.values()].sort(compareBranches)
}
function parseBranchData(
file: string,
data: string
): CriticalBranchCoverage | null {
const [lineValue, block, branch, takenValue] = data.split(',')
const line = Number(lineValue)
const taken = takenValue === '-' ? null : Number(takenValue)
if (
!Number.isInteger(line) ||
line <= 0 ||
!isCoverageOrdinal(block) ||
!isCoverageOrdinal(branch) ||
takenValue === undefined ||
takenValue.trim().length === 0 ||
(taken !== null && !Number.isInteger(taken))
) {
return null
}
const covered = taken !== null && taken > 0
const key = `${file}:${line}:${block}:${branch}`
return {
key,
file,
line,
block,
branch,
taken,
covered
}
}
function mergeBranchCoverage(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): CriticalBranchCoverage {
const taken =
left.taken === null && right.taken === null
? null
: (left.taken ?? 0) + (right.taken ?? 0)
return {
...left,
taken,
covered: taken !== null && taken > 0
}
}
function compareBranches(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): number {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
Number(left.block) - Number(right.block) ||
Number(left.branch) - Number(right.branch)
)
}
function indexBranches(
branches: CriticalBranchCoverage[],
mapLocation: CriticalCoverageLocationMapper,
unmappedPrefix: string
): Map<string, IndexedBranch> {
const indexed = new Map<string, IndexedBranch>()
const occurrences = new Map<string, number>()
for (const branch of [...branches].sort(compareBranches)) {
const sourceLine = `${branch.file}:${branch.line}`
const occurrence = occurrences.get(sourceLine) ?? 0
const mappedLocation = mapLocation(branch.file, branch.line)
const identity =
mappedLocation === null
? `${unmappedPrefix}:${branch.key}`
: `${mappedLocation.file}:${mappedLocation.line}:${occurrence}`
occurrences.set(sourceLine, occurrence + 1)
indexed.set(identity, { branch, mappedLocation })
}
return indexed
}
function assertCriticalCoverageScopePreserved(
baseDirs: readonly string[],
headDirs: readonly string[]
): void {
const headScope = new Set(headDirs)
const removedDirs = baseDirs.filter((dir) => !headScope.has(dir))
if (removedDirs.length > 0) {
throw new Error(
`Critical coverage scope removed: ${removedDirs.join(', ')}`
)
}
}
function identityLocationMapper(
file: string,
line: number
): CriticalCoverageLocation {
return { file, line }
}
function normalizeCoveragePath(filePath: string, cwd: string): string {
const decodedPath = filePath.startsWith('file://')
? fileURLToPath(filePath)
: filePath
const relativePath = isAbsolute(decodedPath)
? relative(cwd, decodedPath)
: decodedPath
const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\.\//, '')
if (!normalizedPath.startsWith('../')) {
return normalizedPath
}
const srcIndex = normalizedPath.indexOf('/src/')
return srcIndex === -1 ? normalizedPath : normalizedPath.slice(srcIndex + 1)
}
function isCriticalCoverageReport(
value: unknown
): value is CriticalCoverageReport {
if (!isRecord(value)) {
return false
}
const totals = value.totals
const branches = value.branches
const criticalDirs = value.criticalDirs
if (
value.schemaVersion !== 1 ||
value.source !== 'lcov' ||
!isGitCommitSha(value.sha) ||
typeof value.generatedAt !== 'string' ||
typeof value.inputPath !== 'string' ||
!isCriticalCoverageDirs(criticalDirs) ||
!isRecord(totals) ||
!isNonNegativeInteger(totals.files) ||
!isNonNegativeInteger(totals.branches) ||
!isNonNegativeInteger(totals.coveredBranches) ||
!Array.isArray(branches) ||
!branches.every((branch) => isCriticalBranchCoverage(branch, criticalDirs))
) {
return false
}
const keys = new Set(branches.map(({ key }) => key))
const files = new Set(branches.map(({ file }) => file))
const coveredBranches = branches.filter(({ covered }) => covered).length
return (
keys.size === branches.length &&
totals.files === files.size &&
totals.branches === branches.length &&
totals.coveredBranches === coveredBranches
)
}
function isCriticalBranchCoverage(
value: unknown,
criticalDirs: readonly string[]
): value is CriticalBranchCoverage {
if (!isRecord(value)) {
return false
}
const taken = value.taken
if (
typeof value.key !== 'string' ||
typeof value.file !== 'string' ||
!isCoveragePathInDirs(value.file, criticalDirs) ||
!isNonNegativeInteger(value.line) ||
value.line === 0 ||
!isCoverageOrdinal(value.block) ||
!isCoverageOrdinal(value.branch) ||
(taken !== null &&
(typeof taken !== 'number' || !Number.isInteger(taken))) ||
typeof value.covered !== 'boolean'
) {
return false
}
return (
value.key ===
`${value.file}:${value.line}:${value.block}:${value.branch}` &&
value.covered === (typeof taken === 'number' && taken > 0)
)
}
function isCriticalCoverageDirs(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every(
(dir) =>
typeof dir === 'string' &&
dir.length > 0 &&
dir === dir.trim() &&
!dir.startsWith('/') &&
!dir.endsWith('/') &&
!dir.includes('\\') &&
!dir.split('/').includes('..')
) &&
new Set(value).size === value.length
)
}
function isCoveragePathInDirs(
filePath: string,
criticalDirs: readonly string[]
): boolean {
return criticalDirs.some(
(dir) => filePath === dir || filePath.startsWith(`${dir}/`)
)
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
export function isGitCommitSha(value: unknown): value is string {
return (
typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)
)
}
function isCoverageOrdinal(value: unknown): value is string {
return (
typeof value === 'string' &&
/^\d+$/.test(value) &&
Number.isSafeInteger(Number(value))
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

View File

@@ -0,0 +1,65 @@
import { appendFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import {
createCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
interface Options {
input: string
output: string
sha: string
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const summary = `## Critical Unit Coverage Extraction: ERROR\n\n${message}`
process.stderr.write(`${message}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
process.exitCode = 1
}
function main(): void {
const options = parseOptions(process.argv.slice(2))
const report = createCriticalCoverageReport({
inputPath: options.input,
sha: options.sha
})
writeCriticalCoverageReport(report, options.output)
process.stdout.write(
[
`Critical coverage branches: ${report.totals.coveredBranches}/${report.totals.branches}`,
`Critical coverage files: ${report.totals.files}`,
`Wrote ${options.output}`
].join('\n') + '\n'
)
}
function parseOptions(args: string[]): Options {
const { values } = parseArgs({
args,
options: {
input: { type: 'string' },
output: { type: 'string' },
sha: { type: 'string' }
},
strict: true,
allowPositionals: false
})
return {
input: values.input ?? 'coverage/lcov.info',
output: values.output ?? 'coverage/critical-unit-coverage.json',
sha: values.sha ?? process.env.GITHUB_SHA ?? ''
}
}

View File

@@ -12,7 +12,6 @@ import { computed, onMounted, watch } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { installPartnerNodeEnforcement } from '@/platform/workspace/partnerNodeAccess/installPartnerNodeEnforcement'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -22,8 +21,6 @@ import { useConflictDetection } from '@/workbench/extensions/manager/composables
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
installPartnerNodeEnforcement()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)

View File

@@ -4,37 +4,67 @@
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
<div class="flex flex-col">
<div
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
>
<Button
variant="textonly"
size="unset"
:aria-pressed="grid"
:class="
cn(
actionBtnClass,
grid && 'bg-component-node-widget-background-selected'
)
"
@click="grid = !grid"
>
<i class="icon-[lucide--grid-3x3] size-4" />
<span>{{ $t('boundingBoxes.grid') }}</span>
</Button>
<Button
variant="textonly"
size="unset"
:class="cn(actionBtnClass, 'ml-auto')"
@click="clearAll"
>
<i class="icon-[lucide--undo-2] size-4" />
<span>{{ $t('boundingBoxes.clearAll') }}</span>
</Button>
</div>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
</div>
<div
@@ -122,16 +152,6 @@
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
@@ -147,6 +167,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { NodeId } from '@/types/nodeId'
const actionBtnClass =
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
const { nodeId } = defineProps<{ nodeId: NodeId }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
@@ -172,7 +195,8 @@ const {
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,

View File

@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
await userEvent.click(screen.getByRole('button', { name: '+' }))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
@@ -44,18 +44,14 @@ describe('PaletteSwatchRow', () => {
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
it('opens the color picker when a swatch is clicked', async () => {
const { container } = renderRow(['#ff0000'])
const swatch = container.querySelector('[data-index="0"]')!
await userEvent.click(swatch)
expect(swatch.getAttribute('data-state')).toBe('open')
})
it('starts a drag on pointer down without emitting', async () => {

View File

@@ -1,17 +1,25 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
<ColorPicker
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
:key="i"
:model-value="hex"
:alpha="false"
@update:model-value="(value) => updateAt(i, value)"
>
<template #trigger>
<button
type="button"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
</template>
</ColorPicker>
<button
v-if="modelValue.length < max"
type="button"
@@ -21,12 +29,6 @@
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
@@ -34,6 +36,7 @@
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
@@ -41,8 +44,9 @@ const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
modelValue,
container
})
</script>

View File

@@ -14,20 +14,27 @@ import { cn } from '@comfyorg/tailwind-utils'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
const { alpha = true } = defineProps<{
class?: string
disabled?: boolean
alpha?: boolean
}>()
const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
function readHsva(hex: string): HSVA {
const next = hexToHsva(hex || '#000000')
if (!alpha) next.a = 100
return next
}
const hsva = ref<HSVA>(readHsva(modelValue.value))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = hexToHsva(newVal || '#000000')
hsva.value = readHsva(newVal)
}
})
@@ -67,49 +74,51 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
<slot name="trigger">
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</slot>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
@@ -123,6 +132,7 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
:alpha
/>
</PopoverContent>
</PopoverPortal>

View File

@@ -13,6 +13,8 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const { alpha = true } = defineProps<{ alpha?: boolean }>()
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
@@ -37,6 +39,7 @@ const { t } = useI18n()
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-if="alpha"
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
@@ -72,7 +75,7 @@ const { t } = useI18n()
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<span class="shrink-0 border-l border-border-subtle pl-1"
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
</div>

View File

@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
@@ -167,10 +167,31 @@ describe('fromBoundingBoxes', () => {
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
palette: ['#ffffff']
})
})
it('normalizes palette entries and drops invalid colors', () => {
const boxes: BoundingBox[] = [
{
x: 0,
y: 0,
width: 10,
height: 10,
metadata: {
type: 'obj',
text: '',
desc: '',
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
}
}
]
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
'#ff0000',
'#aabbcc'
])
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({

View File

@@ -202,6 +202,22 @@ function isBoundingBox(b: unknown): b is BoundingBox {
)
}
function normalizeHexColor(color: unknown): string | null {
if (typeof color !== 'string') return null
const hex = color.trim().toLowerCase()
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
if (short) {
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
}
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
}
function normalizePalette(palette: unknown): string[] {
return Array.isArray(palette)
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
: []
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
@@ -219,9 +235,7 @@ export function fromBoundingBoxes(
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
palette: normalizePalette(meta.palette)
}
})
}

View File

@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
const { appState, outputState } = vi.hoisted(() => ({
appState: { node: null as unknown },
outputState: {
outputs: undefined as unknown,
nodeOutputs: null as { value: Record<string, unknown> } | null
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
vi.mock('@/stores/nodeOutputStore', async () => {
const { ref } = await import('vue')
const nodeOutputs = ref<Record<string, unknown>>({})
outputState.nodeOutputs = nodeOutputs
return {
useNodeOutputStore: () => ({
nodeOutputs,
nodePreviewImages: ref({}),
getNodeImageUrls: () => undefined,
getNodeOutputs: () => outputState.outputs
})
}
})
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
@@ -27,6 +45,9 @@ const ctx = {
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
arc: () => {},
fill: () => {},
rect: () => {},
clip: () => {},
font: '',
@@ -58,17 +79,32 @@ function makeCanvas(): HTMLCanvasElement {
return el
}
function makeNode() {
interface MockNode {
widgets: { name: string; value: unknown }[]
findInputSlot: (name: string) => number
getInputNode: () => null
isInputConnected?: () => boolean
}
function makeNode(): MockNode {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
{ name: 'height', value: 512 },
{ name: 'last_incoming', value: [] }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const lastIncomingOf = (node: MockNode) =>
node.widgets.find((w) => w.name === 'last_incoming')!.value
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
}
const pe = (
clientX: number,
clientY: number,
@@ -96,6 +132,8 @@ interface Captured extends Api {
modelValue: Ref<BoundingBox[]>
}
const modelBoxes = (c: Captured) => c.modelValue.value
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
@@ -128,9 +166,19 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
...over
})
function makeConnectedNode(): MockNode {
return {
...makeNode(),
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
isInputConnected: () => true
}
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
outputState.outputs = undefined
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
@@ -168,8 +216,8 @@ describe('useBoundingBoxes drawing', () => {
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
@@ -177,7 +225,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
@@ -185,7 +233,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(modelBoxes(c)).toHaveLength(1)
})
})
@@ -194,7 +242,7 @@ describe('useBoundingBoxes region editing', () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
expect(modelBoxes(c)[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
@@ -205,14 +253,18 @@ describe('useBoundingBoxes region editing', () => {
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
})
it('clears all regions', async () => {
it('clears all regions and invalidates the applied upstream input', async () => {
const node = makeNode()
setLastIncomingOf(node, [box()])
appState.node = node
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
expect(lastIncomingOf(node)).toEqual([])
})
})
@@ -226,7 +278,7 @@ describe('useBoundingBoxes inline editor', () => {
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
@@ -239,6 +291,168 @@ describe('useBoundingBoxes inline editor', () => {
})
})
describe('useBoundingBoxes incoming bboxes input', () => {
it('adopts cached outputs on mount without overwriting existing edits', () => {
const node = makeConnectedNode()
appState.node = node
const incoming = [box({ x: 0, width: 100 })]
outputState.outputs = { input_bboxes: incoming }
const c = setup([box({ x: 200, width: 300 })])
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(300)
expect(lastIncomingOf(node)).toEqual(incoming)
})
it('does not re-apply an already applied output after a remount', async () => {
const node = makeConnectedNode()
const incoming = [box({ x: 0, width: 100 })]
setLastIncomingOf(node, incoming)
appState.node = node
outputState.outputs = { input_bboxes: incoming }
const c = setup([box({ x: 200, width: 300 })])
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(modelBoxes(c)[0].width).toBe(300)
})
it('ignores incoming output when the input is not connected', () => {
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
const c = setup([])
expect(modelBoxes(c)).toHaveLength(0)
})
it('repopulates from the next run after clearing the canvas', async () => {
appState.node = makeConnectedNode()
const c = setup([])
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
c.clearAll()
await flush()
expect(modelBoxes(c)).toHaveLength(0)
outputState.nodeOutputs!.value = { n: 2 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(100)
})
it('does not apply output updates while the input is disconnected', async () => {
let connected = true
appState.node = {
...makeConnectedNode(),
isInputConnected: () => connected
}
const c = setup([])
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
c.clearAll()
await flush()
connected = false
outputState.nodeOutputs!.value = { n: 2 }
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
it('does not apply incoming boxes while the user is drawing', async () => {
appState.node = makeConnectedNode()
const c = setup([])
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(50, 50))
outputState.outputs = {
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
}
outputState.nodeOutputs!.value = { n: 1 }
await flush()
c.onDocPointerUp(pe(50, 50))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(205)
})
it('applies incoming boxes when outputs stream in after mount', async () => {
const node = makeConnectedNode()
appState.node = node
const c = setup([])
expect(modelBoxes(c)).toHaveLength(0)
const incoming = [box({ x: 0, width: 100 })]
outputState.outputs = { input_bboxes: incoming }
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(100)
expect(lastIncomingOf(node)).toEqual(incoming)
})
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
const node = makeConnectedNode()
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
appState.node = node
const c = setup([box({ x: 200, width: 300 })])
const changed = [box({ x: 64, width: 128 })]
outputState.outputs = { input_bboxes: changed }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)[0].width).toBe(128)
expect(lastIncomingOf(node)).toEqual(changed)
})
})
describe('useBoundingBoxes grid snapping', () => {
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].x).toBe(64)
expect(modelBoxes(c)[0].width).toBe(256)
})
it('does not snap when grid is disabled', async () => {
const c = setup()
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(55, 55))
c.onDocPointerUp(pe(55, 55))
await flush()
expect(modelBoxes(c)[0].width).toBe(230)
})
it('keeps the anchored edge fixed when resizing a single edge', async () => {
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
c.onPointerDown(pe(60, 30))
c.onCanvasPointerMove(pe(80, 30))
c.onDocPointerUp(pe(80, 30))
await flush()
expect(modelBoxes(c)[0].x).toBe(51)
})
it('removes a box that a resize collapses to zero size', async () => {
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
c.onPointerDown(pe(37, 25))
c.onCanvasPointerMove(pe(14, 25))
c.onDocPointerUp(pe(14, 25))
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])

View File

@@ -1,4 +1,5 @@
import { useElementSize } from '@vueuse/core'
import { cloneDeep, isEqual } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
@@ -15,6 +16,7 @@ import type {
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
@@ -25,6 +27,10 @@ const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
const GRID_PX = 32
const MAX_GRID_CELLS = 64
const DOT_ALPHA = 0.18
const DOT_RADIUS = 1
interface InlineEditorState {
value: string
@@ -57,6 +63,7 @@ export function useBoundingBoxes(
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const grid = ref(true)
const { width: containerWidth } = useElementSize(canvasContainer)
@@ -96,6 +103,89 @@ export function useBoundingBoxes(
return Math.max(0, Math.min(1, n))
}
function gridSpec() {
const axisFraction = (size: number) =>
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
return {
fx: axisFraction(widthValue.value),
fy: axisFraction(heightValue.value)
}
}
function snapFraction(value: number, step: number) {
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
}
function snapRegion(region: Region, mode: HitMode): Region {
if (!grid.value) return region
const { fx, fy } = gridSpec()
if (mode === 'move') {
return {
...region,
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
}
}
const snapLeft =
mode === 'draw' ||
mode === 'resize-l' ||
mode === 'resize-tl' ||
mode === 'resize-bl'
const snapRight =
mode === 'draw' ||
mode === 'resize-r' ||
mode === 'resize-tr' ||
mode === 'resize-br'
const snapTop =
mode === 'draw' ||
mode === 'resize-t' ||
mode === 'resize-tl' ||
mode === 'resize-tr'
const snapBottom =
mode === 'draw' ||
mode === 'resize-b' ||
mode === 'resize-bl' ||
mode === 'resize-br'
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
const x2 = snapRight
? snapFraction(region.x + region.w, fx)
: region.x + region.w
const y2 = snapBottom
? snapFraction(region.y + region.h, fy)
: region.y + region.h
return {
...region,
x: x1,
y: y1,
w: Math.max(0, x2 - x1),
h: Math.max(0, y2 - y1)
}
}
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
const el = canvasEl.value
if (!el) return
const { fx, fy } = gridSpec()
if (fx <= 0 || fy <= 0) return
const cols = Math.round(1 / fx)
const rows = Math.round(1 / fy)
ctx.save()
ctx.globalAlpha = DOT_ALPHA
ctx.fillStyle = getComputedStyle(el).color
ctx.beginPath()
for (let i = 0; i <= cols; i++) {
const cx = Math.min(1, i * fx) * W
for (let j = 0; j <= rows; j++) {
const cy = Math.min(1, j * fy) * H
ctx.moveTo(cx + DOT_RADIUS, cy)
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
}
}
ctx.fill()
ctx.restore()
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
@@ -146,6 +236,8 @@ export function useBoundingBoxes(
ctx.fillRect(0, 0, W, H)
}
if (grid.value) drawDots(ctx, W, H)
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
@@ -366,7 +458,7 @@ export function useBoundingBoxes(
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
requestDraw()
}
@@ -375,7 +467,7 @@ export function useBoundingBoxes(
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
if (b && (b.w < 0.005 || b.h < 0.005)) {
removeRegion(activeIndex.value)
}
syncState()
@@ -510,6 +602,7 @@ export function useBoundingBoxes(
function clearAll() {
state.value.regions = []
activeIndex.value = -1
setLastIncoming([])
syncState()
}
@@ -530,6 +623,23 @@ export function useBoundingBoxes(
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
watch(
litegraphNode,
(node) => {
const props = node?.properties as { bboxGrid?: unknown } | undefined
if (props && typeof props.bboxGrid === 'boolean')
grid.value = props.bboxGrid
},
{ immediate: true }
)
watch(grid, (enabled) => {
const props = litegraphNode.value?.properties as
| Record<string, unknown>
| undefined
if (props) props.bboxGrid = enabled
requestDraw()
})
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
@@ -580,10 +690,63 @@ export function useBoundingBoxes(
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
function lastIncomingWidget() {
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
}
function lastIncomingValue(): BoundingBox[] {
const value = lastIncomingWidget()?.value
return Array.isArray(value) ? (value as BoundingBox[]) : []
}
function setLastIncoming(boxes: BoundingBox[]) {
const widget = lastIncomingWidget()
if (!widget) return
const next = cloneDeep(boxes)
widget.value = next
widget.callback?.(next)
}
function applyIncomingBoxes(apply = true) {
if (drawing.value) return
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('bboxes')
if (slot < 0 || !node.isInputConnected(slot)) return
const outputs = nodeOutputStore.getNodeOutputs(node) as
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
| undefined
const incoming = outputs?.input_bboxes
if (!incoming?.length) return
const applied = lastIncomingValue()
if (isEqual(incoming, applied)) return
if (!apply) {
if (!applied.length && state.value.regions.length)
setLastIncoming(incoming)
return
}
state.value.regions = fromBoundingBoxes(
incoming,
widthValue.value,
heightValue.value
)
activeIndex.value = state.value.regions.length ? 0 : -1
setLastIncoming(incoming)
syncState()
}
watch(
() => nodeOutputStore.nodeOutputs,
() => {
updateBgImage()
applyIncomingBoxes()
},
{ deep: true }
)
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
applyIncomingBoxes(false)
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
@@ -608,6 +771,7 @@ export function useBoundingBoxes(
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
}
}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
@@ -13,17 +13,12 @@ afterEach(() => {
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
return { modelValue, container, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
@@ -37,31 +32,17 @@ describe('usePaletteSwatchRow', () => {
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
it('updates the color at an index', () => {
const { modelValue, updateAt } = setup(['#a', '#b'])
updateAt(1, '#123456')
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
it('ignores an update that does not change the color', () => {
const { modelValue, updateAt } = setup(['#a'])
const before = modelValue.value
updateAt(0, '#a')
expect(modelValue.value).toBe(before)
})
it('reorders via drag when the pointer crosses another swatch', () => {

View File

@@ -5,30 +5,16 @@ import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
container
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
function updateAt(i: number, value: string) {
if (modelValue.value[i] === value) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
next[i] = value
modelValue.value = next
}
@@ -105,8 +91,7 @@ export function usePaletteSwatchRow({
})
return {
openPicker,
onPickerInput,
updateAt,
remove,
addColor,
onPointerDown

View File

@@ -77,6 +77,14 @@ vi.mock('pinia', async (importOriginal) => {
}
})
const { settingGetMock } = vi.hoisted(() => ({
settingGetMock: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: settingGetMock })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
@@ -95,6 +103,9 @@ describe('useLoad3d', () => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
)
mockNode = createMockLGraphNode({
properties: {
@@ -356,6 +367,20 @@ describe('useLoad3d', () => {
expect(composable.isPreview.value).toBe(true)
})
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
Object.defineProperty(mockNode, 'constructor', {
value: { comfyClass: 'Save3DAdvanced' },
configurable: true
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.isPreview.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
@@ -383,7 +408,37 @@ describe('useLoad3d', () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
})
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
typeof useCanvasStore
>
)
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
)
const composable = useLoad3d(mockNode)
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
})
it('attaches event listeners before running queued ready callbacks', async () => {
const composable = useLoad3d(mockNode)
let listenersAttachedWhenCallbackRan = false
composable.waitForLoad3d(() => {
listenersAttachedWhenCallbackRan =
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
})
await composable.initializeLoad3d(document.createElement('div'))
expect(listenersAttachedWhenCallbackRan).toBe(true)
})
it('passes getZoomScale callback to createLoad3d', async () => {

View File

@@ -8,6 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import {
isAssetPreviewSupported,
persistThumbnail
@@ -118,7 +119,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const sceneConfig = ref<SceneConfig>({
showGrid: true,
backgroundColor: '#000000',
backgroundColor: getActivePinia()
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
: '#282828',
backgroundImage: '',
backgroundRenderMode: 'tiled'
})
@@ -192,6 +195,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
node.constructor.comfyClass?.startsWith('Preview') ||
!(widthWidget && heightWidget)
) {
@@ -248,6 +252,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeToLoad3dMap.set(node, load3d)
handleEvents('add')
const callbacks = pendingCallbacks.get(node)
if (callbacks && load3d) {
@@ -263,8 +269,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) invokeReadyCallback(callback, load3d)
})
}
handleEvents('add')
} catch (error) {
console.error('Error initializing Load3d:', error)
useToastStore().addAlert(

View File

@@ -4,7 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
if (sceneConfig) {
backgroundColor.value =

View File

@@ -32,7 +32,8 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
const widgets: MockWidget[] = [
{ name: 'width', hidden: false, options: {} },
{ name: 'height', hidden: false, options: {} },
{ name: 'other', hidden: false, options: {} }
{ name: 'other', hidden: false, options: {} },
{ name: 'last_incoming', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
@@ -73,6 +74,15 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
expect(node.widgets[0].options.hidden).toBe(false)
})
it('always hides the internal last_incoming widget', () => {
for (const connected of [true, false]) {
const node = makeNode(connected)
state.extension!.nodeCreated(node)
expect(node.widgets[3].hidden).toBe(true)
expect(node.widgets[3].options.hidden).toBe(true)
}
})
it('writes visibility through the widget value store when present', () => {
state.widgetState = { options: {} }
const node = makeNode(true)

View File

@@ -3,6 +3,7 @@ import { useExtensionService } from '@/services/extensionService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const DIMENSION_WIDGETS = new Set(['width', 'height'])
const INTERNAL_WIDGETS = new Set(['last_incoming'])
useExtensionService().registerExtension({
name: 'Comfy.CreateBoundingBoxes',
@@ -15,20 +16,30 @@ useExtensionService().registerExtension({
const widgetValueStore = useWidgetValueStore()
const setWidgetHidden = (
widget: NonNullable<typeof node.widgets>[number],
hidden: boolean
) => {
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
}
const syncDimensionVisibility = () => {
const slot = node.findInputSlot('background')
const hidden = slot >= 0 && node.isInputConnected(slot)
for (const widget of node.widgets ?? []) {
if (!DIMENSION_WIDGETS.has(widget.name)) continue
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
if (DIMENSION_WIDGETS.has(widget.name)) setWidgetHidden(widget, hidden)
}
}
for (const widget of node.widgets ?? []) {
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,

View File

@@ -143,14 +143,23 @@ async function loadExtensionsFresh(): Promise<{
load3DExt: ExtCreated
preview3DExt: ExtCreated
preview3DAdvancedExt: ExtCreated
save3DAdvancedExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3d')
const extByName = (name: string): ExtCreated => {
const call = registerExtensionMock.mock.calls.find(
(c) => (c[0] as ExtCreated).name === name
)
if (!call) throw new Error(`Extension ${name} was not registered`)
return call[0] as ExtCreated
}
return {
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
load3DExt: extByName('Comfy.Load3D'),
preview3DExt: extByName('Comfy.Preview3D'),
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
}
}
@@ -264,14 +273,15 @@ function setupBaseMocks() {
describe('load3d module registration', () => {
beforeEach(setupBaseMocks)
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(load3DExt.name).toBe('Comfy.Load3D')
expect(preview3DExt.name).toBe('Comfy.Preview3D')
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
})
})
@@ -711,6 +721,39 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
})
})
describe('Comfy.Save3DAdvanced.onNodeOutputsUpdated', () => {
beforeEach(setupBaseMocks)
it('restores the saved model from the output folder when opened from history', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
getNodeByLocatorIdMock.mockReturnValue(node)
save3DAdvancedExt.onNodeOutputsUpdated!({
'7': { result: ['3d\\ComfyUI_00001.glb'] }
} as never)
expect(node.properties['Last Time Model File']).toBe('3d/ComfyUI_00001.glb')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001.glb',
expect.objectContaining({ silentOnNotFound: true })
)
})
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
getNodeByLocatorIdMock.mockReturnValue(node)
save3DAdvancedExt.onNodeOutputsUpdated!({
'7': { result: ['mesh.glb'] }
} as never)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
})
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
@@ -1032,6 +1075,50 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
})
})
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
await save3DAdvancedExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('restores persisted models from the output folder, not temp', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
comfyClass: 'Save3DAdvanced',
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
})
await save3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001_.glb',
{ silentOnNotFound: true }
)
})
it('onExecuted loads the saved file from the output folder', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
await save3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00002_.glb',
{ silentOnNotFound: true }
)
})
})
describe('Comfy.Load3D scene widget serializeValue caching', () => {
beforeEach(setupBaseMocks)

View File

@@ -15,8 +15,10 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
import type {
CameraConfig,
CameraState,
LoadFolder,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
LOAD3D_NONE_MODEL,
@@ -48,6 +50,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComfyExtension } from '@/types/comfy'
import { isLoad3dNode } from '@/utils/litegraphUtil'
const inputSpecLoad3D: CustomInputSpec = {
@@ -287,8 +290,11 @@ useExtensionService().registerExtension({
getCustomWidgets() {
const VIEWPORT_STATE_NODES = new Set([
'Preview3DAdvanced',
'Save3DAdvanced',
'PreviewGaussianSplat',
'PreviewPointCloud'
'PreviewPointCloud',
'SaveGaussianSplat',
'SavePointCloud'
])
return {
LOAD_3D(node) {
@@ -679,155 +685,215 @@ useExtensionService().registerExtension({
}
})
useExtensionService().registerExtension({
name: 'Comfy.Preview3DAdvanced',
function applyPreview3DAdvancedResult(
node: LGraphNode,
load3d: Load3d,
result: NonNullable<Preview3DAdvancedOutput['result']>,
loadFolder: LoadFolder,
comfyClass: string
): void {
const filePath = result[0]
if (!filePath) return
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
if (load3d.isSplatModel()) return []
const cameraState = result[1]
const modelTransform = result[2]?.[0]
if (!cameraState && !modelTransform) return
return createExportMenuItems(load3d)
},
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
if (modelTransform) load3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
error
)
})
}
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
function createPreview3DAdvancedExtension(
comfyClass: string,
extensionName: string,
loadFolder: LoadFolder
): ComfyExtension {
return {
name: extensionName,
const [oldWidth, oldHeight] = node.size
onNodeOutputsUpdated(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
) {
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
const result = (output as Preview3DAdvancedOutput).result
if (!result?.[0]) continue
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
const node = getNodeByLocatorId(app.rootGraph, locatorId)
if (!node || node.constructor.comfyClass !== comfyClass) continue
await nextTick()
const onExecuted = node.onExecuted
useLoad3d(node).onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
'Failed to restore camera state for Preview3DAdvanced:',
error
useLoad3d(node).waitForLoad3d((load3d) => {
applyPreview3DAdvancedResult(
node,
load3d,
result,
loadFolder,
comfyClass
)
})
})
useLoad3d(node).waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
load3d.setTargetSize(
widthWidget.value as number,
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
},
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== comfyClass) return []
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
if (load3d.isSplatModel()) return []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
return createExportMenuItems(load3d)
},
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== comfyClass) return
const result = output.result
const filePath = result?.[0]
const [oldWidth, oldHeight] = node.size
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
await nextTick()
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
const onExecuted = node.onExecuted
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraState = result?.[1]
const modelTransform = result?.[2]?.[0]
if (cameraState || modelTransform) {
const targetGeneration = currentLoad3d.currentLoadGeneration
void currentLoad3d
.whenLoadIdle()
.then(() => {
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
return
if (cameraState) currentLoad3d.setCameraState(cameraState)
if (modelTransform)
currentLoad3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
error
)
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
`Failed to restore camera state for ${comfyClass}:`,
error
)
})
})
waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
load3d.setTargetSize(
widthWidget.value as number,
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
}
})
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
const result = output.result
if (!result?.[0]) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
applyPreview3DAdvancedResult(
node,
resolveLoad3d(),
result,
loadFolder,
comfyClass
)
}
})
}
}
})
}
useExtensionService().registerExtension(
createPreview3DAdvancedExtension(
'Preview3DAdvanced',
'Comfy.Preview3DAdvanced',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DAdvancedExtension(
'Save3DAdvanced',
'Comfy.Save3DAdvanced',
'output'
)
)

View File

@@ -14,6 +14,7 @@ export type MaterialMode =
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'
export type LoadFolder = 'temp' | 'output'
interface CameraQuaternion {
x: number

View File

@@ -3,21 +3,24 @@
* Adding a new node type that uses the viewer = one line change here.
*/
const LOAD3D_PREVIEW_NODES = new Set([
const LOAD3D_RESULT_VIEWER_NODES = new Set([
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud'
'PreviewPointCloud',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
...LOAD3D_RESULT_VIEWER_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)
export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
LOAD3D_RESULT_VIEWER_NODES.has(nodeType)
export const isLoad3dNode = (nodeType: string): boolean =>
LOAD3D_ALL_NODES.has(nodeType)

View File

@@ -90,7 +90,10 @@ describe('load3dLazy', () => {
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud',
'SaveGLB'
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
])(
'recognizes %s as a 3D node type and triggers the lazy-load path',
async (nodeType) => {

View File

@@ -76,14 +76,24 @@ type ExtCreated = ComfyExtension & {
async function loadExtensionsFresh(): Promise<{
splatExt: ExtCreated
pointCloudExt: ExtCreated
saveSplatExt: ExtCreated
savePointCloudExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3dPreviewExtensions')
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
const extByName = (name: string): ExtCreated => {
const call = registerExtensionMock.mock.calls.find(
(c) => (c[0] as ExtCreated).name === name
)
if (!call) throw new Error(`Extension ${name} was not registered`)
return call[0] as ExtCreated
}
return {
splatExt: splatCall[0] as ExtCreated,
pointCloudExt: pointCloudCall[0] as ExtCreated
splatExt: extByName('Comfy.PreviewGaussianSplat'),
pointCloudExt: extByName('Comfy.PreviewPointCloud'),
saveSplatExt: extByName('Comfy.SaveGaussianSplat'),
savePointCloudExt: extByName('Comfy.SavePointCloud')
}
}
@@ -92,6 +102,7 @@ interface FakeLoad3d {
isSplatModel: ReturnType<typeof vi.fn>
forceRender: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
applyModelTransform: ReturnType<typeof vi.fn>
setTargetSize: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
@@ -106,6 +117,7 @@ function makeLoad3dMock(): FakeLoad3d {
isSplatModel: vi.fn(() => false),
forceRender: vi.fn(),
setCameraState: vi.fn(),
applyModelTransform: vi.fn(),
setTargetSize: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective'),
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
@@ -151,12 +163,59 @@ function setupBaseMocks() {
describe('load3dPreviewExtensions module registration', () => {
beforeEach(setupBaseMocks)
it('registers both preview extensions on import', async () => {
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
it('registers preview and save extensions on import', async () => {
const { splatExt, pointCloudExt, saveSplatExt, savePointCloudExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
expect(saveSplatExt.name).toBe('Comfy.SaveGaussianSplat')
expect(savePointCloudExt.name).toBe('Comfy.SavePointCloud')
})
it('save extensions load the saved file from the output folder, not temp', async () => {
const { saveSplatExt, savePointCloudExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const splatNode = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
await saveSplatExt.nodeCreated(splatNode)
splatNode.onExecuted!({ result: ['3d/ComfyUI_00001_.ply'] })
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'3d/ComfyUI_00001_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
const pcNode = makePreviewNode({ comfyClass: 'SavePointCloud' })
await savePointCloudExt.nodeCreated(pcNode)
pcNode.onExecuted!({ result: ['3d/ComfyUI_00002_.ply'] })
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'3d/ComfyUI_00002_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
it('restores persisted models from the output folder on nodeCreated, not temp', async () => {
const { saveSplatExt } = await loadExtensionsFresh()
const node = makePreviewNode({
comfyClass: 'SaveGaussianSplat',
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.ply' }
})
await saveSplatExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
})
@@ -214,6 +273,44 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
expect(cameraConfig?.state).toEqual(cameraState)
})
it('applies onExecuted results to the remounted instance, not the disposed closure', async () => {
const { splatExt } = await loadExtensionsFresh()
const original = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(original)
)
const node = makePreviewNode()
await splatExt.nodeCreated(node)
const remounted = makeLoad3dMock()
nodeToLoad3dMapMock.set(node, remounted)
node.onExecuted!({
result: ['scene.ply', { position: { x: 1, y: 2, z: 3 } }]
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(remounted.forceRender).toHaveBeenCalled()
expect(original.forceRender).not.toHaveBeenCalled()
})
it('re-applies the model transform from result[2] on execute', async () => {
const { saveSplatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
const transform = { position: { x: 1, y: 2, z: 3 } }
await saveSplatExt.nodeCreated(node)
node.onExecuted!({ result: ['scene.ply', undefined, [transform]] })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
})
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()

View File

@@ -5,6 +5,7 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
import type {
CameraConfig,
CameraState,
LoadFolder,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type Load3d from '@/extensions/core/load3d/Load3d'
@@ -29,7 +30,9 @@ function applyResultToLoad3d(
node: LGraphNode,
load3d: Load3d,
filePath: string,
cameraState: CameraState | undefined
cameraState: CameraState | undefined,
modelTransform: Model3DInfo[number] | undefined,
loadFolder: LoadFolder
): void {
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
@@ -46,7 +49,7 @@ function applyResultToLoad3d(
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
@@ -54,13 +57,15 @@ function applyResultToLoad3d(
void load3d.whenLoadIdle().then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
if (modelTransform) load3d.applyModelTransform(modelTransform)
load3d.forceRender()
})
}
function createPreview3DExtension(
comfyClass: string,
extensionName: string
extensionName: string,
loadFolder: LoadFolder
): ComfyExtension {
const applyPreviewOutput = (
node: LGraphNode,
@@ -68,10 +73,18 @@ function createPreview3DExtension(
): void => {
const filePath = result[0]
const cameraState = result[1]
const modelTransform = result[2]?.[0]
if (!filePath) return
useLoad3d(node).waitForLoad3d((load3d) => {
applyResultToLoad3d(node, load3d, filePath, cameraState)
applyResultToLoad3d(
node,
load3d,
filePath,
cameraState,
modelTransform,
loadFolder
)
})
}
@@ -119,7 +132,7 @@ function createPreview3DExtension(
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -136,6 +149,8 @@ function createPreview3DExtension(
})
waitForLoad3d((load3d) => {
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
@@ -148,10 +163,10 @@ function createPreview3DExtension(
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
load3d.setTargetSize(value, heightWidget.value as number)
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
load3d.setTargetSize(widthWidget.value as number, value)
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
@@ -199,7 +214,14 @@ function createPreview3DExtension(
return
}
applyResultToLoad3d(node, load3d, filePath, result?.[1])
applyResultToLoad3d(
node,
resolveLoad3d(),
filePath,
result?.[1],
result?.[2]?.[0],
loadFolder
)
}
})
}
@@ -207,8 +229,26 @@ function createPreview3DExtension(
}
useExtensionService().registerExtension(
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
createPreview3DExtension(
'PreviewGaussianSplat',
'Comfy.PreviewGaussianSplat',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
createPreview3DExtension(
'PreviewPointCloud',
'Comfy.PreviewPointCloud',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DExtension(
'SaveGaussianSplat',
'Comfy.SaveGaussianSplat',
'output'
)
)
useExtensionService().registerExtension(
createPreview3DExtension('SavePointCloud', 'Comfy.SavePointCloud', 'output')
)

View File

@@ -81,6 +81,9 @@ describe('Comfy.SaveImageExtraOutput', () => {
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',

View File

@@ -16,6 +16,9 @@ const saveNodeTypes = new Set([
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',

View File

@@ -1566,7 +1566,6 @@
"Workspace": "Workspace",
"Error System": "Error System",
"Other": "Other",
"PartnerNodes": "Partner Nodes",
"Secrets": "Secrets",
"Node Library": "Node Library",
"PointCloud": "Point Cloud"
@@ -2171,7 +2170,8 @@
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette"
"colors": "color_palette",
"grid": "Grid"
},
"palette": {
"addColor": "Add a color",
@@ -2855,16 +2855,6 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"partnerNodes": {
"title": "Partner Nodes",
"description": "Control which partner (API) nodes this workspace can discover and run.",
"searchPlaceholder": "Search partner nodes",
"enableAll": "Enable all",
"disableAll": "Disable all",
"enabledCount": "{enabled} of {total} enabled",
"empty": "No partner nodes are available on this server.",
"autoEnableLabel": "Auto-enable newly added partner nodes"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
@@ -4404,15 +4394,7 @@
"hideDevOnly": "Hide Dev-Only Nodes",
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
"hideSubgraph": "Hide Subgraph Nodes",
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search",
"hideDisabledPartnerNodes": "Hide Disabled Partner Nodes",
"hideDisabledPartnerNodesDescription": "Hides partner nodes disabled by workspace policy"
},
"partnerNodeAccess": {
"insertBlockedTitle": "Partner node disabled",
"insertBlockedDetail": "{node} is disabled for this workspace. A workspace owner or admin can re-enable it in Settings > Partner Nodes.",
"runBlockedTitle": "Workflow uses disabled partner nodes",
"runBlockedDetail": "This workflow can't run because it uses partner nodes disabled for this workspace: {nodes}. A workspace owner or admin can re-enable them in Settings > Partner Nodes."
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
},
"secrets": {
"title": "API Keys & Secrets",

View File

@@ -28,7 +28,6 @@ const CATEGORY_ICONS: Record<string, string> = {
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Other: 'icon-[lucide--ellipsis]',
PartnerNodes: 'icon-[lucide--shield-check]',
PlanCredits: 'icon-[lucide--credit-card]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
@@ -193,21 +192,6 @@ export function useSettingUI(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
// PROTOTYPE (DES-484): unconditionally registered so the demo works on any
// distribution. Production gates on shouldShowWorkspacePanel +
// permissions.canManagePartnerNodes (owner/admin only).
const partnerNodesPanel: SettingPanelItem = {
node: {
key: 'workspace-partner-nodes',
label: 'PartnerNodes',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue')
)
}
const secretsPanel: SettingPanelItem = {
node: {
key: 'secrets',
@@ -261,7 +245,6 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
partnerNodesPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
keybindingPanel,
extensionPanel,
@@ -313,7 +296,6 @@ export function useSettingUI(
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
partnerNodesPanel.node,
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
@@ -360,7 +342,6 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
partnerNodesPanel.node,
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel.node]
: []),

View File

@@ -87,4 +87,3 @@ export type SettingPanelType =
| 'subscription'
| 'user'
| 'workspace'
| 'workspace-partner-nodes'

View File

@@ -3,10 +3,6 @@ import { computed, ref, shallowRef, watch } from 'vue'
import { i18n, st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import {
normalizeProviderKey,
usePartnerNodeAccessStore
} from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
import { api } from '@/scripts/api'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
@@ -31,39 +27,6 @@ interface EnhancedTemplate extends TemplateInfo {
searchableText?: string
}
/** Normalized provider keys declared by a template's logo overlay metadata. */
function templateProviderKeys(template: EnhancedTemplate): string[] {
return (template.logos ?? [])
.flatMap((logo) =>
Array.isArray(logo.provider) ? logo.provider : [logo.provider]
)
.map(normalizeProviderKey)
}
/**
* PROTOTYPE (DES-484): whether a template must be hidden because of the
* workspace partner-node policy. The template index carries no node-type
* list, so provider identity from logo metadata is the only per-partner
* signal; `isPartnerNode` (openSource === false) marks partner templates
* that may lack that metadata.
*
* @param template - The template under evaluation; `template.isPartnerNode`
* is true when the template runs on partner/API nodes.
* @param providerKeys - Normalized provider keys from the template's logos
* (may be empty when metadata is missing).
* @param disabledPartners - Normalized keys of partners whose nodes are all
* disabled in this workspace.
*/
function isTemplateBlockedByPartnerPolicy(
template: EnhancedTemplate,
providerKeys: string[],
disabledPartners: Set<string>
): boolean {
if (!template.isPartnerNode) return false
if (!providerKeys.length) return true
return providerKeys.some((key) => disabledPartners.has(key))
}
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
() => {
@@ -286,16 +249,7 @@ export const useWorkflowTemplatesStore = defineStore(
(template) => !template.requiresCustomNodes?.length
)
const disabledPartners = usePartnerNodeAccessStore().fullyDisabledPartners
if (!disabledPartners.size) return filteredTemplates
return filteredTemplates.filter(
(template) =>
!isTemplateBlockedByPartnerPolicy(
template,
templateProviderKeys(template),
disabledPartners
)
)
return filteredTemplates
})
/**

View File

@@ -1,155 +0,0 @@
<template>
<TabPanel value="PartnerNodes" class="h-full">
<div class="flex h-full flex-col">
<div>
<h2 class="text-2xl font-bold">
{{ $t('workspacePanel.partnerNodes.title') }}
</h2>
<div class="mt-1 flex items-center justify-between gap-4">
<p class="my-0 text-sm text-muted">
{{ $t('workspacePanel.partnerNodes.description') }}
</p>
<div class="flex shrink-0 gap-2">
<Button variant="textonly" size="sm" @click="setAllFiltered(true)">
{{ $t('workspacePanel.partnerNodes.enableAll') }}
</Button>
<Button variant="textonly" size="sm" @click="setAllFiltered(false)">
{{ $t('workspacePanel.partnerNodes.disableAll') }}
</Button>
</div>
</div>
</div>
<Divider class="my-4" />
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
class="mb-4"
/>
<div
v-if="!accessStore.partnerNodes.length"
class="py-8 text-center text-sm text-muted"
>
{{ $t('workspacePanel.partnerNodes.empty') }}
</div>
<div v-else class="min-h-0 flex-1 overflow-y-auto">
<section
v-for="group in groupedNodes"
:key="group.partner"
class="mb-6"
>
<h3 class="mb-1 text-sm font-semibold">
{{ group.partner }}
<span class="ml-2 font-normal text-muted">
{{
$t('workspacePanel.partnerNodes.enabledCount', {
enabled: group.enabledCount,
total: group.nodes.length
})
}}
</span>
</h3>
<ul class="m-0 list-none p-0">
<li
v-for="node in group.nodes"
:key="node.name"
class="flex items-center justify-between py-1.5"
>
<span
:class="
cn(
'text-sm',
!accessStore.isNodeTypeEnabled(node.name) && 'opacity-40'
)
"
>
{{ node.displayName }}
</span>
<ToggleSwitch
:model-value="accessStore.isNodeTypeEnabled(node.name)"
@update:model-value="
accessStore.setEnabled([node.name], $event)
"
/>
</li>
</ul>
</section>
</div>
<div class="mt-4 flex items-center gap-2 pt-2">
<ToggleSwitch
:model-value="accessStore.autoEnableNew"
@update:model-value="accessStore.setAutoEnableNew($event)"
/>
<span
:class="cn('text-sm', !accessStore.autoEnableNew && 'text-muted')"
>
{{ $t('workspacePanel.partnerNodes.autoEnableLabel') }}
</span>
</div>
</div>
</TabPanel>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Divider from 'primevue/divider'
import TabPanel from 'primevue/tabpanel'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import type { PartnerNodeEntry } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
interface PartnerGroup {
partner: string
nodes: PartnerNodeEntry[]
enabledCount: number
}
const accessStore = usePartnerNodeAccessStore()
const searchQuery = ref('')
const filteredNodes = computed<PartnerNodeEntry[]>(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return accessStore.partnerNodes
return accessStore.partnerNodes.filter(
(node) =>
node.displayName.toLowerCase().includes(query) ||
node.name.toLowerCase().includes(query) ||
node.partner.toLowerCase().includes(query)
)
})
const groupedNodes = computed<PartnerGroup[]>(() => {
const byPartner = new Map<string, PartnerNodeEntry[]>()
for (const node of filteredNodes.value) {
const nodes = byPartner.get(node.partner) ?? []
nodes.push(node)
byPartner.set(node.partner, nodes)
}
return [...byPartner.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([partner, nodes]) => ({
partner,
nodes: nodes.toSorted((a, b) =>
a.displayName.localeCompare(b.displayName)
),
enabledCount: nodes.filter((node) =>
accessStore.isNodeTypeEnabled(node.name)
).length
}))
})
function setAllFiltered(enabled: boolean) {
accessStore.setEnabled(
filteredNodes.value.map((node) => node.name),
enabled
)
}
</script>

View File

@@ -1,38 +0,0 @@
import { watchEffect } from 'vue'
import { t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePartnerNodeAccessStore } from './partnerNodeAccessStore'
/**
* PROTOTYPE (DES-484): hides workspace-disabled partner nodes from every
* discovery surface. The node-def filter covers search (v1/v2) and both
* node-library sidebars via `visibleNodeDefs`; the `skip_list` sync covers
* the litegraph right-click Add Node menu (same pattern as the dev_only
* sync in nodeDefStore). Creation paths that bypass discovery (paste,
* workflow load) are enforced at run time instead.
*/
export function installPartnerNodeEnforcement() {
const accessStore = usePartnerNodeAccessStore()
const nodeDefStore = useNodeDefStore()
nodeDefStore.registerNodeDefFilter({
id: 'core.partnerNodeAccess',
name: t('nodeFilters.hideDisabledPartnerNodes'),
description: t('nodeFilters.hideDisabledPartnerNodesDescription'),
predicate: (nodeDef) => !accessStore.disabledNodeTypes.has(nodeDef.name)
})
watchEffect(() => {
const disabled = accessStore.disabledNodeTypes
for (const [typeName, nodeType] of Object.entries(
LiteGraph.registered_node_types
)) {
if (nodeType.nodeData?.api_node) {
nodeType.skip_list = disabled.has(typeName)
}
}
})
}

View File

@@ -1,106 +0,0 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getProviderName } from '@/utils/categoryUtil'
export interface PartnerNodeEntry {
name: string
displayName: string
partner: string
}
interface PersistedAccessState {
overrides: Record<string, boolean>
autoEnableNew: boolean
}
/**
* PROTOTYPE (DES-484): persisted per-browser via localStorage. The
* production version reads/writes `/api/workspace/partner-nodes` and is
* enforced server-side at `/prompt`; this store's public shape mirrors
* that contract so consumers survive the swap.
*/
const STORAGE_KEY = 'Comfy.Prototype.PartnerNodeAccess'
export function normalizeProviderKey(provider: string): string {
return provider.toLowerCase().replaceAll(/\s+/g, '-')
}
export const usePartnerNodeAccessStore = defineStore(
'partnerNodeAccess',
() => {
const nodeDefStore = useNodeDefStore()
const persisted = useLocalStorage<PersistedAccessState>(STORAGE_KEY, {
overrides: {},
autoEnableNew: true
})
const partnerNodes = computed<PartnerNodeEntry[]>(() =>
Object.values(nodeDefStore.nodeDefsByName)
.filter((def) => def.api_node)
.map((def) => ({
name: def.name,
displayName: def.display_name,
partner: getProviderName(def.category)
}))
)
const autoEnableNew = computed(() => persisted.value.autoEnableNew)
function isNodeTypeEnabled(name: string): boolean {
return persisted.value.overrides[name] ?? persisted.value.autoEnableNew
}
const disabledNodeTypes = computed<Set<string>>(
() =>
new Set(
partnerNodes.value
.filter((node) => !isNodeTypeEnabled(node.name))
.map((node) => node.name)
)
)
/** Partners whose nodes are all disabled, as normalized provider keys. */
const fullyDisabledPartners = computed<Set<string>>(() => {
const partnerHasEnabledNode = new Map<string, boolean>()
for (const node of partnerNodes.value) {
const key = normalizeProviderKey(node.partner)
const anyEnabled = partnerHasEnabledNode.get(key) ?? false
partnerHasEnabledNode.set(
key,
anyEnabled || isNodeTypeEnabled(node.name)
)
}
return new Set(
[...partnerHasEnabledNode]
.filter(([, anyEnabled]) => !anyEnabled)
.map(([key]) => key)
)
})
function setEnabled(names: string[], enabled: boolean) {
const overrides = { ...persisted.value.overrides }
for (const name of names) {
overrides[name] = enabled
}
persisted.value = { ...persisted.value, overrides }
}
function setAutoEnableNew(enabled: boolean) {
persisted.value = { ...persisted.value, autoEnableNew: enabled }
}
return {
partnerNodes,
autoEnableNew,
disabledNodeTypes,
fullyDisabledPartners,
isNodeTypeEnabled,
setEnabled,
setAutoEnableNew
}
}
)

View File

@@ -24,6 +24,10 @@ interface MockGraph {
onNodeAdded: ((node: MockNode) => void) | null
onNodeRemoved: ((node: MockNode) => void) | null
onConnectionChange: ((node: MockNode) => void) | null
events: {
addEventListener: Mock
removeEventListener: Mock
}
}
interface MockCanvas {
@@ -128,7 +132,11 @@ const setupMocks = () => {
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null
onConnectionChange: null,
events: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}
moduleMockCanvas = {
@@ -292,7 +300,11 @@ describe('useMinimap', () => {
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null
onConnectionChange: null,
events: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}
moduleMockCanvas = {

View File

@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
import type { LGraphEventMap } from '@/lib/litegraph/src/infrastructure/LGraphEventMap'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph'
@@ -45,6 +47,7 @@ describe('useMinimapGraph', () => {
createMockLGraphNode({ id: '2', pos: [300, 200], size: [120, 60] })
],
links: createMockLinks([createMockLLink({ id: toLinkId(1) })]),
events: new CustomEventTarget<LGraphEventMap>(),
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
@@ -108,13 +111,6 @@ describe('useMinimapGraph', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Store original callbacks for comparison
// const originalCallbacks = {
// onNodeAdded: mockGraph.onNodeAdded,
// onNodeRemoved: mockGraph.onNodeRemoved,
// onConnectionChange: mockGraph.onConnectionChange
// }
graphManager.setupEventListeners()
const wrappedCallbacks = {
onNodeAdded: mockGraph.onNodeAdded,
@@ -160,6 +156,96 @@ describe('useMinimapGraph', () => {
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
})
it('cleanup leaves a later wrapper alone when one is layered on top', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
const minimapWrapper = mockGraph.onNodeAdded
// Simulate another system adding its own wrapper on top
const downstream = vi.fn()
const layeredWrapper = vi.fn(function (this: unknown, node: LGraphNode) {
minimapWrapper?.call(this, node)
downstream(node)
})
mockGraph.onNodeAdded = layeredWrapper
graphManager.cleanupEventListeners()
// The newer wrapper must survive cleanup
expect(mockGraph.onNodeAdded).toBe(layeredWrapper)
})
it('a buried wrapper becomes inert after cleanup', () => {
const originalOnNodeAdded = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
const buriedWrapper = mockGraph.onNodeAdded
// Layer something on top so cleanup can't restore.
mockGraph.onNodeAdded = vi.fn()
graphManager.cleanupEventListeners()
vi.mocked(onGraphChangedMock).mockClear()
// Call the method directly and ensure it is a no-op
const testNode = { id: '9' } as LGraphNode
buriedWrapper!(testNode)
expect(originalOnNodeAdded).toHaveBeenCalledWith(testNode)
expect(onGraphChangedMock).not.toHaveBeenCalled()
})
it('invalidates cache and fires update on visual property changes', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
mockGraph.events.dispatch('node:property:changed', {
nodeId: '1',
property: 'color',
oldValue: '',
newValue: '#fff'
})
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('ignores unrelated property changes', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
mockGraph.events.dispatch('node:property:changed', {
nodeId: '1',
property: 'title',
oldValue: 'a',
newValue: 'b'
})
expect(onGraphChangedMock).not.toHaveBeenCalled()
})
it('detaches the property listener on cleanup', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
graphManager.cleanupEventListeners()
mockGraph.events.dispatch('node:property:changed', {
nodeId: '1',
property: 'mode',
oldValue: 0,
newValue: 1
})
expect(onGraphChangedMock).not.toHaveBeenCalled()
})
it('should detect node position changes', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)

View File

@@ -2,11 +2,9 @@ import { useThrottleFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import type {
LGraph,
LGraphNode,
LGraphTriggerEvent
} from '@/lib/litegraph/src/litegraph'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphEventMap } from '@/lib/litegraph/src/infrastructure/LGraphEventMap'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
import { toNodeId } from '@/types/nodeId'
@@ -19,7 +17,6 @@ interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
onTrigger?: (event: LGraphTriggerEvent) => void
}
export function useMinimapGraph(
@@ -39,8 +36,17 @@ export function useMinimapGraph(
// Track LayoutStore version for change detection
const layoutStoreVersion = layoutStore.getVersion()
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
// Cleanup restores originals only when our wrapper is still on top, and
// marks any buried wrapper inert via `entry.live` so it can't fire dead work.
interface InstalledHooks {
originals: GraphCallbacks
wrappers: GraphCallbacks
live: boolean
onPropertyChanged: (
e: CustomEvent<LGraphEventMap['node:property:changed']>
) => void
}
const hooksMap = new Map<string, InstalledHooks>()
const handleGraphChangedThrottled = useThrottleFn(() => {
onGraphChanged()
@@ -48,71 +54,85 @@ export function useMinimapGraph(
const setupEventListeners = () => {
const g = graph.value
if (!g) return
if (!g || hooksMap.has(g.id)) return
// Check if we've already wrapped this graph's callbacks
if (originalCallbacksMap.has(g.id)) {
return
}
// Store the original callbacks for this graph
const originalCallbacks: GraphCallbacks = {
const originals: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange,
onTrigger: g.onTrigger
onConnectionChange: g.onConnectionChange
}
originalCallbacksMap.set(g.id, originalCallbacks)
const wrappers: GraphCallbacks = {}
g.onNodeAdded = function (node: LGraphNode) {
originalCallbacks.onNodeAdded?.call(this, node)
void handleGraphChangedThrottled()
}
g.onNodeRemoved = function (node: LGraphNode) {
originalCallbacks.onNodeRemoved?.call(this, node)
nodeStatesCache.delete(node.id)
void handleGraphChangedThrottled()
}
g.onConnectionChange = function (node: LGraphNode) {
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChangedThrottled()
}
g.onTrigger = function (event: LGraphTriggerEvent) {
originalCallbacks.onTrigger?.call(this, event)
// Listen for visual property changes that affect minimap rendering
const onPropertyChanged = (
e: CustomEvent<LGraphEventMap['node:property:changed']>
) => {
const { property, nodeId } = e.detail
if (
event.type === 'node:property:changed' &&
(event.property === 'mode' ||
event.property === 'bgcolor' ||
event.property === 'color')
property === 'mode' ||
property === 'bgcolor' ||
property === 'color'
) {
// Invalidate cache for this node to force redraw
nodeStatesCache.delete(toNodeId(event.nodeId))
nodeStatesCache.delete(toNodeId(nodeId))
void handleGraphChangedThrottled()
}
}
const entry: InstalledHooks = {
originals,
wrappers,
live: true,
onPropertyChanged
}
hooksMap.set(g.id, entry)
wrappers.onNodeAdded = useChainCallback(originals.onNodeAdded, function () {
if (!entry.live) return
void handleGraphChangedThrottled()
})
g.onNodeAdded = wrappers.onNodeAdded
wrappers.onNodeRemoved = useChainCallback(
originals.onNodeRemoved,
function (node: LGraphNode) {
if (!entry.live) return
nodeStatesCache.delete(node.id)
void handleGraphChangedThrottled()
}
)
g.onNodeRemoved = wrappers.onNodeRemoved
wrappers.onConnectionChange = useChainCallback(
originals.onConnectionChange,
function () {
if (!entry.live) return
void handleGraphChangedThrottled()
}
)
g.onConnectionChange = wrappers.onConnectionChange
g.events.addEventListener('node:property:changed', onPropertyChanged)
}
const cleanupEventListeners = (oldGraph?: LGraph) => {
const g = oldGraph || graph.value
if (!g) return
const entry = hooksMap.get(g.id)
if (!entry) return
const { originals, wrappers } = entry
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
// Graph was never set up (e.g., minimap destroyed before init) - nothing to clean up
return
}
if (g.onNodeAdded === wrappers.onNodeAdded)
g.onNodeAdded = originals.onNodeAdded
if (g.onNodeRemoved === wrappers.onNodeRemoved)
g.onNodeRemoved = originals.onNodeRemoved
if (g.onConnectionChange === wrappers.onConnectionChange)
g.onConnectionChange = originals.onConnectionChange
g.events.removeEventListener(
'node:property:changed',
entry.onPropertyChanged
)
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
g.onTrigger = originalCallbacks.onTrigger
originalCallbacksMap.delete(g.id)
entry.live = false
hooksMap.delete(g.id)
}
const checkForChangesInternal = () => {

View File

@@ -5,7 +5,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useBoundingBoxesWidget } from './useBoundingBoxesWidget'
const widgetOptions = { serialize: true, canvasOnly: false }
const widgetOptions = { serialize: true, canvasOnly: false, hideInPanel: true }
function mockNode() {
return { addWidget: vi.fn(() => ({})) } as unknown as LGraphNode & {

View File

@@ -17,7 +17,8 @@ export const useBoundingBoxesWidget = (): ComfyWidgetConstructorV2 => {
})) ?? []
return node.addWidget('boundingboxes', spec.name, defaultValue, null, {
serialize: true,
canvasOnly: false
canvasOnly: false,
hideInPanel: true
}) as IBaseWidget
}
}

View File

@@ -31,7 +31,6 @@ import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/instal
import { groupMissingNodesByPack } from '@/platform/telemetry/utils/groupMissingNodesByPack'
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -1611,43 +1610,11 @@ export class ComfyApp {
})
}
/**
* PROTOTYPE (DES-484): display names of workspace-disabled partner nodes
* present in the current graph (including subgraphs). Production replaces
* this client check with server-side `/prompt` rejection.
*/
collectDisabledPartnerNodes(): string[] {
const disabled = usePartnerNodeAccessStore().disabledNodeTypes
if (!disabled.size) return []
const blocked = new Set<string>()
forEachNode(this.rootGraph, (node) => {
const nodeData = node.constructor?.nodeData
const typeName = nodeData?.name ?? node.type
if (typeName && disabled.has(typeName)) {
blocked.add(String(nodeData?.display_name ?? typeName))
}
})
return [...blocked]
}
async queuePrompt(
number: number,
batchCount: number = 1,
queueNodeIds?: NodeExecutionId[]
): Promise<boolean> {
const disabledPartnerNodes = this.collectDisabledPartnerNodes()
if (disabledPartnerNodes.length) {
useDialogService().showErrorDialog(
new Error(
t('partnerNodeAccess.runBlockedDetail', {
nodes: disabledPartnerNodes.join(', ')
})
),
{ title: t('partnerNodeAccess.runBlockedTitle') }
)
return false
}
const requestId = this.nextQueueRequestId++
this.queueItems.push({ number, batchCount, queueNodeIds, requestId })
api.dispatchCustomEvent('promptQueueing', {

View File

@@ -38,7 +38,6 @@ import type {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { createPromotedMultilineWidget } from '@/renderer/extensions/vueNodes/widgets/utils/multilineTextarea'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -895,18 +894,6 @@ export const useLitegraphService = () => {
options: CreateNodeOptions = {},
addOptions?: GraphAddOptions
): LGraphNode | null {
if (usePartnerNodeAccessStore().disabledNodeTypes.has(nodeDef.name)) {
useToastStore().add({
severity: 'warn',
summary: t('partnerNodeAccess.insertBlockedTitle'),
detail: t('partnerNodeAccess.insertBlockedDetail', {
node: nodeDef.display_name ?? nodeDef.name
}),
life: 5000
})
return null
}
options.pos ??= getCanvasCenter()
if (isBlueprintType(nodeDef.name)) {

View File

@@ -18,6 +18,7 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import { comfyAPIPlugin } from './build/plugins'
import { CRITICAL_COVERAGE_DIRS } from './scripts/critical-coverage/criticalCoverageDirs'
dotenvConfig()
@@ -30,46 +31,6 @@ const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'
const CRITICAL_COVERAGE_DIRS = [
'src/base',
'src/composables',
'src/core',
'src/lib/litegraph/src/node',
'src/lib/litegraph/src/subgraph',
'src/lib/litegraph/src/utils',
'src/platform/assets/composables',
'src/platform/assets/mappings',
'src/platform/assets/schemas',
'src/platform/assets/services',
'src/platform/assets/utils',
'src/platform/errorCatalog',
'src/platform/keybindings',
'src/platform/missingMedia',
'src/platform/missingModel',
'src/platform/navigation',
'src/platform/nodeReplacement',
'src/platform/remote',
'src/platform/remoteConfig',
'src/platform/secrets',
'src/platform/settings',
'src/platform/workflow',
'src/platform/workspace/api',
'src/platform/workspace/auth',
'src/platform/workspace/composables',
'src/platform/workspace/stores',
'src/platform/workspace/utils',
'src/schemas',
'src/scripts',
'src/services',
'src/stores',
'src/utils',
'src/workbench/extensions/manager/composables',
'src/workbench/extensions/manager/services',
'src/workbench/extensions/manager/stores',
'src/workbench/extensions/manager/utils',
'src/workbench/utils'
]
// A single glob key so vitest aggregates all critical dirs into one
// thresholds bucket instead of one bucket per glob
const CRITICAL_COVERAGE_GLOB = `{${CRITICAL_COVERAGE_DIRS.join(',')}}/**/*.{ts,vue}`