Compare commits

..

3 Commits

Author SHA1 Message Date
Matt Miller
554fd45096 Merge branch 'main' into matt/be-2793-cloud-secrets-e2e-structure 2026-07-09 23:48:17 -04:00
Matt Miller
6d4327e907 test: address review feedback on secrets + dialog tests
- Remove leaked focus-target button after focus-outside tests via a
  returned cleanup callback (test isolation)
- Flush all pending microtasks instead of a hardcoded tick count so the
  focus-outside negative control is robust to handler await depth
- Assert the secret is never echoed into a field with a single immediate
  check rather than poll-until-false, which could mask a transient leak

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:42:02 -07:00
Matt Miller
195580630b test: restructure cloud secrets E2E scaffolding into fixtures
Move the in-memory /secrets backend and the open-panel helper out of
cloudSecrets.spec.ts into browser_tests/fixtures/utils/cloudSecretsMocks.ts,
alongside the sibling cloud mocks. openSecretsPanel now drives the shared
SettingDialog page object (.open()/.category()) instead of hand-rolling the
command dispatch and nav click.

Also assert the write-only secret never lands in any input/textarea value
(getByText only sees text nodes), and add a mounted GlobalDialog test that
exercises the @focus-outside -> dismissOnFocusOutside:false binding through
the real template wiring, with a positive control proving the path fires.
2026-07-09 19:29:41 -07:00
47 changed files with 751 additions and 2532 deletions

View File

@@ -8,10 +8,6 @@ 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
required: false
@@ -37,7 +33,6 @@ runs:
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
EVENT_NAME: ${{ inputs.event }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
with:
github-token: ${{ inputs.token }}
@@ -47,7 +42,6 @@ runs:
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
event: process.env.EVENT_NAME || undefined,
per_page: 1,
});

View File

@@ -38,18 +38,6 @@ jobs:
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Generate critical unit coverage artifact
run: pnpm coverage:critical:extract
- name: Upload critical unit coverage artifact
if: github.event_name != 'merge_group'
uses: actions/upload-artifact@v6
with:
name: critical-unit-coverage-${{ github.sha }}
path: coverage/critical-unit-coverage.json
retention-days: 30
if-no-files-found: error
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'
uses: actions/upload-artifact@v6
@@ -67,79 +55,3 @@ 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' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download head critical unit coverage
uses: actions/download-artifact@v7
with:
name: critical-unit-coverage-${{ github.sha }}
path: temp/head-critical-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: ${{ github.event.pull_request.base.sha }}
event: push
token: ${{ secrets.GITHUB_TOKEN }}
- name: Require base unit coverage run
if: steps.find-base-unit.outputs.status != 'ready'
run: |
echo "Critical unit baseline artifact unavailable for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
echo "Status: ${{ steps.find-base-unit.outputs.status }}" >> "$GITHUB_STEP_SUMMARY"
exit 1
- name: Download base critical unit coverage
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: critical-unit-coverage-${{ github.event.pull_request.base.sha }}
run_id: ${{ steps.find-base-unit.outputs.run-id }}
path: temp/base-critical-coverage
if_no_artifact_found: warn
- name: Download base unit LCOV fallback
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: unit-coverage
run_id: ${{ steps.find-base-unit.outputs.run-id }}
path: temp/base-unit-coverage
if_no_artifact_found: warn
- name: Prepare base critical unit coverage fallback
run: |
if [ -f temp/base-critical-coverage/critical-unit-coverage.json ]; then
exit 0
fi
if [ ! -f temp/base-unit-coverage/lcov.info ]; then
echo "Critical unit baseline artifact missing for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
pnpm coverage:critical:extract \
--input temp/base-unit-coverage/lcov.info \
--output temp/base-critical-coverage/critical-unit-coverage.json \
--sha=${{ github.event.pull_request.base.sha }}
- 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,11 +38,9 @@ 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 // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -278,49 +278,32 @@ jobs:
continue
fi
# 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
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
git push --force-with-lease origin "${BACKPORT_BRANCH}"
else
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
git push origin "${BACKPORT_BRANCH}"
fi
# 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
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main || git checkout -f main
git checkout main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort || true
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
git checkout main
git branch -D "${BACKPORT_BRANCH}"
fi
echo "::endgroup::"
@@ -401,10 +384,6 @@ 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>
@@ -437,37 +416,19 @@ 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
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
elif [ "${reason}" = "already-exists" ]; then
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."
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
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
@@ -483,10 +444,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
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")
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")
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -0,0 +1,144 @@
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* Shared scaffolding for the cloud user-secrets (API keys) E2E, alongside the
* sibling cloud mocks (`cloudBillingMocks`, `workspaceMocks`): the stateful
* in-memory `/secrets` backend and the open-the-Secrets-panel helper, so the
* spec files hold only the behavioral flow.
*/
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
export const SECRETS_BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
export const SECRETS_BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
export async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches. Returns the dialog root locator
* for scoping the caller's assertions.
*/
export async function openSecretsPanel(settingDialog: SettingDialog) {
const { page } = settingDialog
await settingDialog.open()
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingDialog.category('Secrets').click()
await Promise.all([providersResolved, listResolved])
return settingDialog.root
}

View File

@@ -1,11 +1,13 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { ComfyPage, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import {
SECRETS_BOOT_FEATURES,
SECRETS_BOOT_SETTINGS,
mockSecretsBackend,
openSecretsPanel
} from '@e2e/fixtures/utils/cloudSecretsMocks'
/**
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
@@ -15,164 +17,28 @@ import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
*
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
* `/secrets` backend on top so the flow is deterministic and never touches a
* real server.
* `bootCloud` boot the app signed-in, and `mockSecretsBackend` layers a
* stateful in-memory `/secrets` backend on top so the flow is deterministic
* and never touches a real server. A bare `ComfyPage` (constructed, never
* `setup()`) supplies the shared `SettingDialog` page object without the
* backend-touching fixture setup.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
const BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
// The plaintext key a user types in. It must be sent on create but NEVER echoed
// back by the API or rendered anywhere in the UI.
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches.
*/
async function openSecretsPanel(page: Page) {
const settingsDialog = page.getByTestId('settings-dialog')
await page.evaluate(() => {
const app = window.app
if (!app) throw new Error('window.app is not available')
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
})
await settingsDialog.waitFor({ state: 'visible' })
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingsDialog
.locator('nav')
.getByRole('button', { name: 'Secrets' })
.click()
await Promise.all([providersResolved, listResolved])
return settingsDialog
}
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
test('an entitled account can add, list, and delete a provider key', async ({
page
page,
request
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
features: SECRETS_BOOT_FEATURES,
settings: SECRETS_BOOT_SETTINGS
})
await bootCloud(page)
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
@@ -182,7 +48,8 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
const comfyPage = new ComfyPage(page, request)
const settingsDialog = await openSecretsPanel(comfyPage.settingDialog)
// Empty state before anything is added.
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
@@ -219,6 +86,22 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
// ...but the value must never be echoed back into the list — the API
// response carries metadata only, so nothing should render it as text.
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
// `getByText` only sees text nodes; a value reflected into an `<input>` or
// masked field would slip past it, so assert no field carries it either.
// The list has already settled above, so this is a single immediate
// assertion — a poll-until-false could mask a value that briefly echoed
// into a field and then cleared within the polling window.
const secretEchoedInField = await page
.locator('input, textarea')
.evaluateAll(
(fields, value) =>
fields.some(
(field) =>
(field as HTMLInputElement | HTMLTextAreaElement).value === value
),
RUNWAY_KEY_VALUE
)
expect(secretEchoedInField).toBe(false)
// --- DELETE ----------------------------------------------------------
await settingsDialog
@@ -238,13 +121,14 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
})
test('a non-entitled account never sees the gated providers', async ({
page
page,
request
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
features: SECRETS_BOOT_FEATURES,
settings: SECRETS_BOOT_SETTINGS
})
await bootCloud(page)
// Non-entitled: the server omits runway/gemini from the allowlist.
@@ -255,7 +139,8 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
const comfyPage = new ComfyPage(page, request)
const settingsDialog = await openSecretsPanel(comfyPage.settingDialog)
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// The add form opens, but its provider dropdown is empty — the gated

View File

@@ -476,37 +476,6 @@ 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,8 +19,6 @@
"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

@@ -1,139 +0,0 @@
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtempSync, readFileSync, rmSync } 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 { 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'
)
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('dropped by 1 covered branches')
})
it('fails when the reports have no comparable branches', () => {
const directory = createTempDirectory()
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)
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'No comparable critical unit branches found'
)
})
})
function runComparison(
paths: { basePath: string; headPath: string },
env: NodeJS.ProcessEnv = {}
): ReturnType<typeof spawnSync> {
return spawnSync(
process.execPath,
[TSX_CLI, SCRIPT_PATH, '--base', paths.basePath, '--head', paths.headPath],
{
encoding: 'utf-8',
env: { ...process.env, ...env }
}
)
}
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[]
): CriticalCoverageReport {
return {
schemaVersion: 1,
source: 'lcov',
sha,
generatedAt: '2026-07-10T00:00:00.000Z',
inputPath: 'lcov.info',
criticalDirs: ['src/stores'],
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
}
}
function createTempDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), 'critical-coverage-cli-'))
onTestFinished(() => rmSync(directory, { recursive: true, force: true }))
return directory
}

View File

@@ -1,120 +0,0 @@
import { appendFileSync } from 'node:fs'
import {
compareCriticalCoverageReports,
readCriticalCoverageReport
} from './criticalCoverageReport'
import type { CriticalCoverageComparison } from './criticalCoverageReport'
interface Options {
base: string
head: string
}
const options = parseOptions(process.argv.slice(2))
const base = readCriticalCoverageReport(options.base)
const head = readCriticalCoverageReport(options.head)
const comparison = compareCriticalCoverageReports(base, head)
const summary = formatComparison(comparison)
process.stdout.write(`${summary}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
if (comparison.commonBranches === 0) {
process.stderr.write('No comparable critical unit branches found.\n')
process.exit(1)
}
if (comparison.coveredBranchDelta < 0) {
process.stderr.write(
`Critical unit coverage dropped by ${Math.abs(comparison.coveredBranchDelta)} covered branches on the shared branch set.\n`
)
process.exit(1)
}
function parseOptions(args: string[]): Options {
const options: Partial<Options> = {}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const next = args[i + 1]
if (arg === '--base' && next) {
options.base = next
i++
} else if (arg.startsWith('--base=')) {
options.base = arg.slice('--base='.length)
} else if (arg === '--head' && next) {
options.head = next
i++
} else if (arg.startsWith('--head=')) {
options.head = arg.slice('--head='.length)
}
}
if (!options.base || !options.head) {
throw new Error(
'Usage: compareCriticalCoverage --base <json> --head <json>'
)
}
return {
base: options.base,
head: options.head
}
}
function formatComparison(comparison: CriticalCoverageComparison): string {
const passed = comparison.coveredBranchDelta >= 0
const lines = [
`## Critical Unit Coverage Gate: ${passed ? 'PASS' : 'FAIL'}`,
'',
`Base tested commit: \`${comparison.baseSha}\``,
`PR tested commit: \`${comparison.headSha}\``,
'',
'| Metric | Count |',
'|---|--:|',
`| 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} |`,
''
]
if (passed) {
lines.push('PASS: Critical branch coverage did not decrease.')
return lines.join('\n')
}
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 ? '0' : String(value)
}
function formatSignedCount(value: number): string {
return value > 0 ? `+${value}` : String(value)
}

View File

@@ -1,45 +0,0 @@
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

@@ -1,157 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, onTestFinished } from 'vitest'
import {
compareCriticalCoverageReports,
createCriticalCoverageReport,
readCriticalCoverageReport
} from './criticalCoverageReport'
import type {
CriticalBranchCoverage,
CriticalCoverageReport
} from './criticalCoverageReport'
const GENERATED_AT = '2026-07-10T00:00:00.000Z'
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,-
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
}
])
})
})
describe('readCriticalCoverageReport', () => {
it('rejects malformed artifacts', () => {
const directory = createTempDirectory()
const inputPath = join(directory, 'coverage.json')
writeFileSync(
inputPath,
JSON.stringify({
...createReport('sha', []),
branches: [{ key: 'invalid' }]
})
)
expect(() => readCriticalCoverageReport(inputPath)).toThrow(
`Invalid critical coverage report: ${inputPath}`
)
})
})
describe('compareCriticalCoverageReports', () => {
it('compares only shared branches and reports branch-universe drift', () => {
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
})
})
})
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: ['src/stores'],
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

@@ -1,322 +0,0 @@
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
commonBranches: number
baseOnlyBranches: number
headOnlyBranches: number
commonCoveredBranchesInBase: number
commonCoveredBranchesInHead: number
coveredBranchDelta: number
regressions: CriticalCoverageRegression[]
}
interface CreateReportOptions {
inputPath: string
sha: string
generatedAt?: string
cwd?: string
}
export function createCriticalCoverageReport({
inputPath,
sha,
generatedAt = new Date().toISOString(),
cwd = process.cwd()
}: CreateReportOptions): CriticalCoverageReport {
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
): CriticalCoverageComparison {
const baseBranches = new Map(
base.branches.map((branch) => [branch.key, branch])
)
const headBranches = new Map(
head.branches.map((branch) => [branch.key, branch])
)
const regressions: CriticalCoverageRegression[] = []
let commonBranches = 0
let commonCoveredBranchesInBase = 0
let commonCoveredBranchesInHead = 0
let baseOnlyBranches = 0
for (const [key, baseBranch] of baseBranches) {
const headBranch = headBranches.get(key)
if (!headBranch) {
baseOnlyBranches++
continue
}
commonBranches++
if (baseBranch.covered) {
commonCoveredBranchesInBase++
}
if (headBranch.covered) {
commonCoveredBranchesInHead++
}
if (baseBranch.covered && !headBranch.covered) {
regressions.push({
...baseBranch,
baseTaken: baseBranch.taken,
headTaken: headBranch.taken
})
}
}
return {
baseSha: base.sha,
headSha: head.sha,
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)
if (
!Number.isInteger(line) ||
!block ||
!branch ||
takenValue === undefined
) {
return null
}
const taken = takenValue === '-' ? null : Number(takenValue)
const covered = taken !== null && Number.isFinite(taken) && taken > 0
const key = `${file}:${line}:${block}:${branch}`
return {
key,
file,
line,
block,
branch,
taken: Number.isFinite(taken) ? taken : null,
covered
}
}
function mergeBranchCoverage(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): CriticalBranchCoverage {
const taken =
left.taken === null || right.taken === null
? null
: left.taken + right.taken
return {
...left,
taken,
covered: left.covered || right.covered
}
}
function compareBranches(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): number {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.block.localeCompare(right.block) ||
left.branch.localeCompare(right.branch)
)
}
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
return (
value.schemaVersion === 1 &&
value.source === 'lcov' &&
typeof value.sha === 'string' &&
typeof value.generatedAt === 'string' &&
typeof value.inputPath === 'string' &&
Array.isArray(value.criticalDirs) &&
isRecord(totals) &&
typeof totals.files === 'number' &&
typeof totals.branches === 'number' &&
typeof totals.coveredBranches === 'number' &&
Array.isArray(value.branches) &&
value.branches.every(isCriticalBranchCoverage)
)
}
function isCriticalBranchCoverage(
value: unknown
): value is CriticalBranchCoverage {
if (!isRecord(value)) {
return false
}
return (
typeof value.key === 'string' &&
typeof value.file === 'string' &&
typeof value.line === 'number' &&
typeof value.block === 'string' &&
typeof value.branch === 'string' &&
(typeof value.taken === 'number' || value.taken === null) &&
typeof value.covered === 'boolean'
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

View File

@@ -1,58 +0,0 @@
import {
createCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
interface Options {
input: string
output: string
sha: string
}
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 options: Options = {
input: 'coverage/lcov.info',
output: 'coverage/critical-unit-coverage.json',
sha: process.env.GITHUB_SHA ?? 'unknown'
}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const next = args[i + 1]
if (arg === '--input' && next) {
options.input = next
i++
} else if (arg.startsWith('--input=')) {
options.input = arg.slice('--input='.length)
} else if (arg === '--output' && next) {
options.output = next
i++
} else if (arg.startsWith('--output=')) {
options.output = arg.slice('--output='.length)
} else if (arg === '--sha' && next) {
options.sha = next
i++
} else if (arg.startsWith('--sha=')) {
options.sha = arg.slice('--sha='.length)
}
}
return options
}

View File

@@ -4,67 +4,37 @@
data-testid="bounding-boxes"
@pointerdown.stop
>
<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
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>
<div
@@ -152,6 +122,16 @@
<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>
@@ -167,9 +147,6 @@ 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: () => [] })
@@ -195,8 +172,7 @@ const {
commitInlineEditor,
setActiveType,
clearAll,
syncState,
grid
syncState
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,

View File

@@ -32,6 +32,9 @@ const Body = defineComponent({
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
const ClosedNonModalDialog = defineComponent({
name: 'ClosedNonModalDialog',
setup: () => () =>
@@ -361,6 +364,82 @@ describe('GlobalDialog Reka overlay scrim', () => {
})
})
describe('GlobalDialog Reka focus-outside binding', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
// Reka's DismissableLayer fires focus-outside off a real focus transition
// (blur inside the layer, then focusin on the new target), so drive the
// mounted binding by moving focus to a fresh element outside the dialog
// rather than dispatching a synthetic event.
async function moveFocusToPlainElementOutside() {
const outside = document.createElement('button')
document.body.appendChild(outside)
outside.focus()
return () => outside.remove()
}
it('dismisses on focus-outside by default', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'focus-default',
title: 'Focus dismisses',
component: Body,
dialogComponentProps: { renderer: 'reka', modal: false }
})
await screen.findByRole('dialog')
const removeOutside = await moveFocusToPlainElementOutside()
try {
await waitFor(() =>
expect(store.isDialogOpen('focus-default')).toBe(false)
)
} finally {
removeOutside()
}
})
it('does not dismiss on focus-outside when dismissOnFocusOutside is false', async () => {
// Exercises GlobalDialog's own template wiring
// `@focus-outside="(e) => onRekaFocusOutside(e, item.dialogComponentProps)"`
// through a mounted dialog — the direct `onRekaFocusOutside` unit test can't
// catch a regression that drops the props argument here. The positive
// control above proves the focus-outside path really fires, so this staying
// open isolates the opt-out flag rather than a dead event.
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'focus-opted-out',
title: 'Focus blocked',
component: Body,
dialogComponentProps: {
renderer: 'reka',
modal: false,
dismissOnFocusOutside: false
}
})
await screen.findByRole('dialog')
const removeOutside = await moveFocusToPlainElementOutside()
try {
// Drain every pending microtask so a wrongful dismiss lands before we
// assert, regardless of how many awaits deep the handler chain runs.
await flushPromises()
expect(store.isDialogOpen('focus-opted-out')).toBe(true)
} finally {
removeOutside()
}
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false

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', { name: '+' }))
await userEvent.click(screen.getByRole('button'))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
@@ -44,14 +44,18 @@ describe('PaletteSwatchRow', () => {
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
expect(screen.queryByRole('button')).toBeNull()
})
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('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('starts a drag on pointer down without emitting', async () => {

View File

@@ -1,25 +1,17 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<ColorPicker
<div
v-for="(hex, i) in modelValue"
: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>
: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)"
/>
<button
v-if="modelValue.length < max"
type="button"
@@ -29,6 +21,12 @@
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
@@ -36,7 +34,6 @@
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 }>()
@@ -44,9 +41,8 @@ const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
modelValue,
container
})
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
</script>

View File

@@ -14,27 +14,20 @@ import { cn } from '@comfyorg/tailwind-utils'
import ColorPickerPanel from './ColorPickerPanel.vue'
const { alpha = true } = defineProps<{
defineProps<{
class?: string
disabled?: boolean
alpha?: boolean
}>()
const modelValue = defineModel<string>({ default: '#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 hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = readHsva(newVal)
hsva.value = hexToHsva(newVal || '#000000')
}
})
@@ -74,51 +67,49 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<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
)
"
<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"
>
<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 }"
/>
<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>
</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>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
@@ -132,7 +123,6 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
:alpha
/>
</PopoverContent>
</PopoverPortal>

View File

@@ -13,8 +13,6 @@ 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
@@ -39,7 +37,6 @@ const { t } = useI18n()
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-if="alpha"
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
@@ -75,7 +72,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 v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
<span 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: ['#ffffff'] }
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
@@ -167,31 +167,10 @@ describe('fromBoundingBoxes', () => {
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#ffffff']
palette: ['#fff']
})
})
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,22 +202,6 @@ 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,
@@ -235,7 +219,9 @@ export function fromBoundingBoxes(
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: normalizePalette(meta.palette)
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
}
})
}

View File

@@ -8,32 +8,14 @@ import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
const { appState, outputState } = vi.hoisted(() => ({
appState: { node: null as unknown },
outputState: {
outputs: undefined as unknown,
nodeOutputs: null as { value: Record<string, unknown> } | null
}
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
}))
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: () => {},
@@ -45,9 +27,6 @@ const ctx = {
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
arc: () => {},
fill: () => {},
rect: () => {},
clip: () => {},
font: '',
@@ -79,32 +58,17 @@ function makeCanvas(): HTMLCanvasElement {
return el
}
interface MockNode {
widgets: { name: string; value: unknown }[]
findInputSlot: (name: string) => number
getInputNode: () => null
isInputConnected?: () => boolean
}
function makeNode(): MockNode {
function makeNode() {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 },
{ name: 'last_incoming', value: [] }
{ name: 'height', value: 512 }
],
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,
@@ -132,8 +96,6 @@ 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({
@@ -166,19 +128,9 @@ 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
@@ -216,8 +168,8 @@ describe('useBoundingBoxes drawing', () => {
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
@@ -225,7 +177,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(modelBoxes(c)).toHaveLength(0)
expect(c.modelValue.value).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
@@ -233,7 +185,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(c.modelValue.value).toHaveLength(1)
})
})
@@ -242,7 +194,7 @@ describe('useBoundingBoxes region editing', () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(modelBoxes(c)[0].metadata.type).toBe('text')
expect(c.modelValue.value[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
@@ -253,18 +205,14 @@ describe('useBoundingBoxes region editing', () => {
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(modelBoxes(c)).toHaveLength(0)
expect(c.modelValue.value).toHaveLength(0)
})
it('clears all regions and invalidates the applied upstream input', async () => {
const node = makeNode()
setLastIncomingOf(node, [box()])
appState.node = node
it('clears all regions', async () => {
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(modelBoxes(c)).toHaveLength(0)
expect(lastIncomingOf(node)).toEqual([])
expect(c.modelValue.value).toHaveLength(0)
})
})
@@ -278,7 +226,7 @@ describe('useBoundingBoxes inline editor', () => {
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
@@ -291,168 +239,6 @@ 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,5 +1,4 @@
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'
@@ -16,7 +15,6 @@ 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'
@@ -27,10 +25,6 @@ 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
@@ -63,7 +57,6 @@ 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)
@@ -103,89 +96,6 @@ 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 }
@@ -236,8 +146,6 @@ 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
@@ -458,7 +366,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] = snapRegion(nb, dragMode.value)
state.value.regions[activeIndex.value] = nb
requestDraw()
}
@@ -467,7 +375,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)) {
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
removeRegion(activeIndex.value)
}
syncState()
@@ -602,7 +510,6 @@ export function useBoundingBoxes(
function clearAll() {
state.value.regions = []
activeIndex.value = -1
setLastIncoming([])
syncState()
}
@@ -623,23 +530,6 @@ 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
@@ -690,63 +580,10 @@ export function useBoundingBoxes(
}
img.src = url
}
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.nodeOutputs, updateBgImage, { deep: true })
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
applyIncomingBoxes(false)
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
@@ -771,7 +608,6 @@ export function useBoundingBoxes(
commitInlineEditor,
setActiveType,
clearAll,
syncState,
grid
syncState
}
}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
@@ -13,12 +13,17 @@ 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 }))!
return { modelValue, container, ...api }
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
@@ -32,17 +37,31 @@ describe('usePaletteSwatchRow', () => {
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('updates the color at an index', () => {
const { modelValue, updateAt } = setup(['#a', '#b'])
updateAt(1, '#123456')
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)
expect(modelValue.value).toEqual(['#a', '#123456'])
})
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('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('reorders via drag when the pointer crosses another swatch', () => {

View File

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

View File

@@ -77,14 +77,6 @@ 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()
}))
@@ -103,9 +95,6 @@ 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: {
@@ -367,20 +356,6 @@ 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')
@@ -408,37 +383,7 @@ describe('useLoad3d', () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
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)
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
})
it('passes getZoomScale callback to createLoad3d', async () => {

View File

@@ -8,7 +8,6 @@ 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
@@ -119,9 +118,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const sceneConfig = ref<SceneConfig>({
showGrid: true,
backgroundColor: getActivePinia()
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
: '#282828',
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
})
@@ -195,7 +192,6 @@ 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)
) {
@@ -252,8 +248,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeToLoad3dMap.set(node, load3d)
handleEvents('add')
const callbacks = pendingCallbacks.get(node)
if (callbacks && load3d) {
@@ -269,6 +263,8 @@ 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 { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
if (sceneConfig) {
backgroundColor.value =

View File

@@ -32,8 +32,7 @@ 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: 'last_incoming', hidden: false, options: {} }
{ name: 'other', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
@@ -74,15 +73,6 @@ 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,7 +3,6 @@ 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',
@@ -16,30 +15,20 @@ 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)) setWidgetHidden(widget, hidden)
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
}
}
for (const widget of node.widgets ?? []) {
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,

View File

@@ -143,23 +143,14 @@ 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: extByName('Comfy.Load3D'),
preview3DExt: extByName('Comfy.Preview3D'),
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
}
}
@@ -273,15 +264,14 @@ function setupBaseMocks() {
describe('load3d module registration', () => {
beforeEach(setupBaseMocks)
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
expect(load3DExt.name).toBe('Comfy.Load3D')
expect(preview3DExt.name).toBe('Comfy.Preview3D')
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
})
})
@@ -721,39 +711,6 @@ 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)
@@ -1075,50 +1032,6 @@ 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,10 +15,8 @@ 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,
@@ -50,7 +48,6 @@ 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 = {
@@ -290,11 +287,8 @@ useExtensionService().registerExtension({
getCustomWidgets() {
const VIEWPORT_STATE_NODES = new Set([
'Preview3DAdvanced',
'Save3DAdvanced',
'PreviewGaussianSplat',
'PreviewPointCloud',
'SaveGaussianSplat',
'SavePointCloud'
'PreviewPointCloud'
])
return {
LOAD_3D(node) {
@@ -685,215 +679,155 @@ useExtensionService().registerExtension({
}
})
function applyPreview3DAdvancedResult(
node: LGraphNode,
load3d: Load3d,
result: NonNullable<Preview3DAdvancedOutput['result']>,
loadFolder: LoadFolder,
comfyClass: string
): void {
const filePath = result[0]
if (!filePath) return
useExtensionService().registerExtension({
name: 'Comfy.Preview3DAdvanced',
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
const cameraState = result[1]
const modelTransform = result[2]?.[0]
if (!cameraState && !modelTransform) return
if (load3d.isSplatModel()) return []
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
)
})
}
return createExportMenuItems(load3d)
},
function createPreview3DAdvancedExtension(
comfyClass: string,
extensionName: string,
loadFolder: LoadFolder
): ComfyExtension {
return {
name: extensionName,
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
onNodeOutputsUpdated(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
) {
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
const result = (output as Preview3DAdvancedOutput).result
if (!result?.[0]) continue
const [oldWidth, oldHeight] = node.size
const node = getNodeByLocatorId(app.rootGraph, locatorId)
if (!node || node.constructor.comfyClass !== comfyClass) continue
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
useLoad3d(node).waitForLoad3d((load3d) => {
applyPreview3DAdvancedResult(
node,
load3d,
result,
loadFolder,
comfyClass
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) => {
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)
}
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== comfyClass) return []
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) 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
if (load3d.isSplatModel()) return []
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return createExportMenuItems(load3d)
},
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== comfyClass) return
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
const [oldWidth, oldHeight] = node.size
const result = output.result
const filePath = result?.[0]
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
await nextTick()
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = 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, {
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
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 ${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)
}
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
)
})
}
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,7 +14,6 @@ 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,24 +3,21 @@
* Adding a new node type that uses the viewer = one line change here.
*/
const LOAD3D_RESULT_VIEWER_NODES = new Set([
const LOAD3D_PREVIEW_NODES = new Set([
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_RESULT_VIEWER_NODES,
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
LOAD3D_RESULT_VIEWER_NODES.has(nodeType)
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)
export const isLoad3dNode = (nodeType: string): boolean =>
LOAD3D_ALL_NODES.has(nodeType)

View File

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

View File

@@ -76,24 +76,14 @@ 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 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
}
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
return {
splatExt: extByName('Comfy.PreviewGaussianSplat'),
pointCloudExt: extByName('Comfy.PreviewPointCloud'),
saveSplatExt: extByName('Comfy.SaveGaussianSplat'),
savePointCloudExt: extByName('Comfy.SavePointCloud')
splatExt: splatCall[0] as ExtCreated,
pointCloudExt: pointCloudCall[0] as ExtCreated
}
}
@@ -102,7 +92,6 @@ 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>
@@ -117,7 +106,6 @@ 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 } })),
@@ -163,59 +151,12 @@ function setupBaseMocks() {
describe('load3dPreviewExtensions module registration', () => {
beforeEach(setupBaseMocks)
it('registers preview and save extensions on import', async () => {
const { splatExt, pointCloudExt, saveSplatExt, savePointCloudExt } =
await loadExtensionsFresh()
it('registers both preview extensions on import', async () => {
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
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 })
)
})
})
@@ -273,44 +214,6 @@ 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,7 +5,6 @@ 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'
@@ -30,9 +29,7 @@ function applyResultToLoad3d(
node: LGraphNode,
load3d: Load3d,
filePath: string,
cameraState: CameraState | undefined,
modelTransform: Model3DInfo[number] | undefined,
loadFolder: LoadFolder
cameraState: CameraState | undefined
): void {
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
@@ -49,7 +46,7 @@ function applyResultToLoad3d(
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, normalizedPath, {
config.configureForSaveMesh('temp', normalizedPath, {
silentOnNotFound: true
})
@@ -57,15 +54,13 @@ 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,
loadFolder: LoadFolder
extensionName: string
): ComfyExtension {
const applyPreviewOutput = (
node: LGraphNode,
@@ -73,18 +68,10 @@ 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,
modelTransform,
loadFolder
)
applyResultToLoad3d(node, load3d, filePath, cameraState)
})
}
@@ -132,7 +119,7 @@ function createPreview3DExtension(
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -149,8 +136,6 @@ function createPreview3DExtension(
})
waitForLoad3d((load3d) => {
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
@@ -163,10 +148,10 @@ function createPreview3DExtension(
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
load3d.setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
load3d.setTargetSize(widthWidget.value as number, value)
}
}
@@ -214,14 +199,7 @@ function createPreview3DExtension(
return
}
applyResultToLoad3d(
node,
resolveLoad3d(),
filePath,
result?.[1],
result?.[2]?.[0],
loadFolder
)
applyResultToLoad3d(node, load3d, filePath, result?.[1])
}
})
}
@@ -229,26 +207,8 @@ function createPreview3DExtension(
}
useExtensionService().registerExtension(
createPreview3DExtension(
'PreviewGaussianSplat',
'Comfy.PreviewGaussianSplat',
'temp'
)
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
)
useExtensionService().registerExtension(
createPreview3DExtension(
'PreviewPointCloud',
'Comfy.PreviewPointCloud',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DExtension(
'SaveGaussianSplat',
'Comfy.SaveGaussianSplat',
'output'
)
)
useExtensionService().registerExtension(
createPreview3DExtension('SavePointCloud', 'Comfy.SavePointCloud', 'output')
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
)

View File

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

View File

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

View File

@@ -2170,8 +2170,7 @@
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette",
"grid": "Grid"
"colors": "color_palette"
},
"palette": {
"addColor": "Add a color",

View File

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

View File

@@ -3,8 +3,6 @@ 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'
@@ -47,7 +45,6 @@ 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()
@@ -111,6 +108,13 @@ 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,
@@ -156,96 +160,6 @@ 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,9 +2,11 @@ import { useThrottleFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
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 type {
LGraph,
LGraphNode,
LGraphTriggerEvent
} from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
import { toNodeId } from '@/types/nodeId'
@@ -17,6 +19,7 @@ interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
onTrigger?: (event: LGraphTriggerEvent) => void
}
export function useMinimapGraph(
@@ -36,17 +39,8 @@ export function useMinimapGraph(
// Track LayoutStore version for change detection
const layoutStoreVersion = layoutStore.getVersion()
// 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>()
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
const handleGraphChangedThrottled = useThrottleFn(() => {
onGraphChanged()
@@ -54,85 +48,71 @@ export function useMinimapGraph(
const setupEventListeners = () => {
const g = graph.value
if (!g || hooksMap.has(g.id)) return
if (!g) return
const originals: GraphCallbacks = {
// 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 = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
onConnectionChange: g.onConnectionChange,
onTrigger: g.onTrigger
}
const wrappers: GraphCallbacks = {}
originalCallbacksMap.set(g.id, originalCallbacks)
const onPropertyChanged = (
e: CustomEvent<LGraphEventMap['node:property:changed']>
) => {
const { property, nodeId } = e.detail
if (
property === 'mode' ||
property === 'bgcolor' ||
property === 'color'
) {
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
g.onNodeAdded = function (node: LGraphNode) {
originalCallbacks.onNodeAdded?.call(this, node)
void handleGraphChangedThrottled()
})
g.onNodeAdded = wrappers.onNodeAdded
}
wrappers.onNodeRemoved = useChainCallback(
originals.onNodeRemoved,
function (node: LGraphNode) {
if (!entry.live) return
nodeStatesCache.delete(node.id)
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
if (
event.type === 'node:property:changed' &&
(event.property === 'mode' ||
event.property === 'bgcolor' ||
event.property === 'color')
) {
// Invalidate cache for this node to force redraw
nodeStatesCache.delete(toNodeId(event.nodeId))
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
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
)
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
// Graph was never set up (e.g., minimap destroyed before init) - nothing to clean up
return
}
entry.live = false
hooksMap.delete(g.id)
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
g.onTrigger = originalCallbacks.onTrigger
originalCallbacksMap.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, hideInPanel: true }
const widgetOptions = { serialize: true, canvasOnly: false }
function mockNode() {
return { addWidget: vi.fn(() => ({})) } as unknown as LGraphNode & {

View File

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

View File

@@ -18,7 +18,6 @@ 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()
@@ -31,6 +30,46 @@ 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}`