mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-12 02:08:25 +00:00
Compare commits
3 Commits
codex/crit
...
matt/be-27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
554fd45096 | ||
|
|
6d4327e907 | ||
|
|
195580630b |
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
88
.github/workflows/ci-tests-unit.yaml
vendored
88
.github/workflows/ci-tests-unit.yaml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/cla.yml
vendored
4
.github/workflows/cla.yml
vendored
@@ -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"
|
||||
|
||||
75
.github/workflows/pr-backport.yaml
vendored
75
.github/workflows/pr-backport.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
144
browser_tests/fixtures/utils/cloudSecretsMocks.ts
Normal file
144
browser_tests/fixtures/utils/cloudSecretsMocks.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'] }, () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}/`)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -81,9 +81,6 @@ describe('Comfy.SaveImageExtraOutput', () => {
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
|
||||
@@ -16,9 +16,6 @@ const saveNodeTypes = new Set([
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user