Compare commits

..

10 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
e5074c999d test(website): expect 6 cloud model cards and refresh home product-cards baselines
The website e2e job has failed on every main push since #13431 and
#13445 landed:

- #13431 added the sixth AI model card (GPT Image 2) to /cloud but left
  cloud.spec.ts asserting toHaveCount(5).
- #13431 and #13445 were split from one branch and each regenerated the
  home-product-cards baselines against its own branch state (#13431
  without the button label centering, #13445 without the ProductCard
  Button CTA), so merged main matches neither.

Bump the model card count to 6 and regenerate the four
home-product-cards baselines against merged main inside the CI image
(mcr.microsoft.com/playwright:v1.58.1-noble, linux/amd64). The
regenerated PNGs are sha1-identical to the actual renders CI produced
at 747f76db7, and the full website e2e suite passes locally in that
container.
2026-07-07 15:28:58 -07:00
Wei Hai
747f76db76 ci: auto-merge approved, green backport PRs (#13390)
## Problem
`pr-backport.yaml` opens each backport PR (labelled `backport`) and
calls `gh pr merge --auto --squash`. GitHub's `--auto` only takes effect
when the repository's **"Allow auto-merge"** setting is enabled — it's
currently off, so that call is a silent no-op (swallowed by its `|| echo
"::warning::…"`). The result: every backport PR sits unmerged until
someone manually clicks merge, even when it's already approved with
green checks.

## What this does
Adds `.github/workflows/backport-auto-merge.yaml`, which completes the
merge directly — a plain `gh pr merge --squash` (which does **not**
depend on the "Allow auto-merge" setting) — once GitHub reports the PR
ready to merge.

Ready = `reviewDecision == APPROVED` **and** `mergeStateStatus` is
`CLEAN` or `UNSTABLE`. `UNSTABLE` means the required checks passed but a
*non-required* check is still pending/failing — GitHub still permits
that merge, and gating on `CLEAN` alone would leave backports stuck
behind slow/flaky non-required checks (Socket, codecov, perf, storybook,
etc.).

**Branch protection stays the real gate.** The `core/**` / `cloud/**`
ruleset unconditionally requires an approval + the required checks and
can't be bypassed, and GitHub's merge API re-enforces it at merge time —
so this workflow can only ever finish a merge that already satisfies
those rules. The eligibility check just avoids pointless attempts.

## Design notes
- **Merges with `PR_GH_TOKEN`, not the default token**, on purpose: a
merge by the default `GITHUB_TOKEN` does not emit the `pull_request:
closed` event, which would silently starve `cloud-backport-tag.yaml` (it
creates the `cloud/vX.Y.Z` tag on that event).
- **Triggers:** review submission + check-suite completion (low
latency), plus a 30-min sweep as a backstop for cases the events miss.
- **Never checks out PR code** (no untrusted-code path); only reads PR
metadata via the API. `permissions` on the default token are read-only.
- **Idempotent, bounded merge loop:** treats an already-merged PR (e.g.
a concurrent run or a human) as success, so it won't post a false
failure comment.
- Leaves the existing conflict path in `pr-backport.yaml` untouched
(conflicts never create a PR, so there's nothing here to act on).

## Validation
YAML parses; `actionlint` (with shellcheck) and `zizmor` both clean (0
findings).

## Before relying on it
- Confirm the org allows this workflow to run/merge (Actions policy) —
the merge uses a PAT so it shouldn't depend on the "Actions can approve
PRs" toggle, but worth verifying.
- First real backport: confirm it merges on ready and that
`cloud-backport-tag.yaml` then fires and creates the tag.
2026-07-07 16:50:23 +00:00
Mobeen Abdullah
386460afef fix(website): center button labels by tuning ppformula-text-center (#13445)
## Summary

Vertically center button/badge/nav labels by tuning the shared
`ppformula-text-center` utility from `top: 0.19em` to `top: 0.1em`.

## The alignment issue

PP Formula (our brand font) has asymmetric vertical metrics: its caps
sit high in the line box, so a naively centered label looks too high.
`ppformula-text-center` compensates by nudging the label down with
`position: relative; top: <em>` (a purely visual shift, it does not
change the element's box, so button/badge sizes are unaffected).

The value was `0.19em`, which **over-corrected**: the glyph ink ended up
~1.4px **below** center on every button, so labels read slightly low.
Measuring the actual glyph ink (canvas `measureText`
`actualBoundingBox*`) showed ~**0.09-0.10em** centers uppercase labels;
`0.1em` lands the ink within ~0.1px of center.

## Why it's safe (verified)

This utility is used site-wide (Button, Badge, ButtonPill, ButtonMask,
BrandButton, nav triggers, section labels). Because it's a
`position:relative` nudge, there is **no layout/box-size change**
anywhere. I measured glyph-ink centering across **12 pages** (home,
cloud, cloud/pricing, download, careers, customers, demos, enterprise,
api, mcp, gallery, learning):

- Buttons/badges/pills went from ~1.37px low to **~0.11px** (centered).
- **Nothing regressed** (no element pushed too high).
- The handful of numeric "outliers" were `text-transform: uppercase`
measurement artifacts (source text with descenders that don't render);
confirmed visually as centered.

## Changes

- **What**: `apps/website/src/styles/global.css` —
`ppformula-text-center` `top: 0.19em` → `0.1em` (one line).

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 20:37:32 +05:00
Mobeen Abdullah
5cf647d183 feat(website): add GPT Image 2 to cloud page model list (#13431)
## Summary

Add GPT Image 2 to the `/cloud` "AI models" section, and apply the
design review polish from Bert and June on the same section and the
neighbouring cloud-page cards.

## Changes

- **GPT Image 2 card**: 6th card in `AIModelsSection` (workflow video on
`media.comfy.org`, OpenAI badge reused from `packages/design-system`),
new `cloud.aiModels.card.gptImage2` i18n key (en + zh-CN), and GPT Image
2 added to the `cloud.reason.2.description` partner-model list.
- **AI models layout**: the six cards are now equal 1:1 squares in one
shared grey container (was a per-card treatment, then simplified to a
single container per design), with a corner arrow affordance on each.
- **Audience cards** (`AudienceSection`): the creators / teams cards now
link to `cloud.comfy.org` with the same corner arrow (highlights on card
hover).
- **Reusable `CardArrow`**: extracted the corner arrow into a shared,
decorative (`aria-hidden`) component used by both sections;
`hover="group"` (card hover) for audience, self-hover for the model
cards so it doesn't double up with the provider badge.
- **`ProductCard` CTA**: swapped the hand-rolled pill `<span>` for the
shared `Button` (`as="span"`) so the label is vertically centered (fixes
Bert's off-centre text) without nesting an anchor inside the card link.

## Split out of this PR

- **Button label centering** (the global `ppformula-text-center` tweak)
→ separate PR #13445, so the site-wide change is reviewed in isolation.
- **Pricing banner frame fix** reverted here; it belongs in Michael's
upcoming pricing PR (team tier, edu billing, FAQ). `PricingSection.vue`
shows only an automatic Tailwind class-order reformat from the
pre-commit hook, no behaviour change.

## Review focus

- The single-container AI models layout and the `CardArrow` hover
behaviour (group vs self).
- `Button as="span"` inside the `ProductCard` link (avoids nested
`<a>`).

Linear: FE-423

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 20:19:54 +05:00
Mobeen Abdullah
fe1fc8baa6 fix(website): standardize favicon to square brand icon (#13467)
## Summary

Standardize the marketing-site favicons to the square, full-bleed brand
mark (ink background, yellow C) so each platform applies its own corner
mask instead of double-rounding a pre-rounded asset.

## Changes

- **What**: Replace three files in `apps/website/public/`:
- `favicon.svg` — was a 57 KB RealFaviconGenerator wrapper around an
embedded PNG; now a 1.2 KB vector of the square mark.
- `favicon-96x96.png` and `apple-touch-icon.png` — regenerated square.
The apple-touch icon previously had transparent rounded corners, which
iOS composites onto a white tile; it is now full-bleed.
- `favicon.ico` and the `web-app-manifest-*.png` files were already the
square mark, so they are left unchanged.

## Review Focus

- Favicons cache aggressively (browser + CDN, and these paths are marked
`immutable` in `vercel.json`), so verify the preview with a hard refresh
or a fresh profile.
- Part of the org-wide favicon standardization (FE-705). Companion PRs
update the workflows hub, docs, and registry favicons to the same mark.
Design direction (square, not rounded) confirmed by Bert.

## Screenshots

The before/after for each binary is visible inline in the Files changed
tab. Square ink + yellow C, no transparency, sharp corners.
2026-07-07 20:18:12 +05:00
Benjamin Lu
3e4dd59e5f feat(navigation): add opt-in strip-on-capture to preserved-query tracker (#13465)
## What

- `installPreservedQueryTracker` definitions accept an opt-in
`stripAfterCapture` flag: the marked keys are captured into the
sessionStorage stash and removed from the URL before the navigation
completes (single guard redirect at the decoded query-object level;
push/replace semantics are inherited from the original navigation, and
vue-router force-replaces the initial one).
- `preservedQueryManager` now captures the first non-empty string
element of repeated (array-valued) params instead of silently dropping
them.
- New real-router test suite for the tracker (createRouter +
createMemoryHistory, no router mocks), including a history-depth test
pinning the push/replace inheritance; manager tests extended for the
array/junk-value cases.

## Why

One-time secrets in query params (first consumer: desktop login codes,
GTM-93) must not linger in the visible URL, browser history,
`previousFullPath` redirects, or telemetry. Stripping after navigation —
what each loader does ad hoc today — leaves a window and forces
per-feature URL scrubbing; #13418 originally needed a hand-rolled
encoding-aware string parser in three places. Stripping at capture time,
at the decoded query-object level, makes the stash the only carrier and
lets vue-router round-trip the surviving params' encoding itself.

Capability only — no existing namespace opts in; behavior is unchanged
for all current definitions. `stripAfterCapture`'s contract is
documented on the option: strip-marked keys must never be read from
`route.query` by later guards or views; the stash is the only
post-capture source.

## Landing order

Independent of everything else; #13418 stacks on this branch.
2026-07-07 07:08:11 +00:00
Benjamin Lu
e25e0f2e16 refactor: simplify slot link drop finalization (#13471)
## Summary

Small cleanup in `useSlotLinkInteraction.ts`, no behavior change:

- Removed a duplicated `raf.flush()` in `finishInteraction` (it was
called twice back-to-back).
- Collapsed four single-line `attempt*` alias closures in
`connectByPriority` into a direct short-circuit chain, preserving the
same evaluation order:

  ```ts
  return (
    tryConnectToCandidate(snappedCandidate) ||
    tryConnectToCandidate(domSlotCandidate) ||
    tryConnectToCandidate(nodeSurfaceSlotCandidate) ||
    tryConnectViaRerouteAtPointer()
  )
  ```

The closures added no behavior beyond renaming the calls (AGENTS.md rule
26), and `||` gives the same first-truthy-wins semantics as the previous
`if (attempt()) return true` ladder.

Verification not rerun after rebasing onto `Comfy-Org/main`; original
branch reported `pnpm typecheck`, `pnpm lint`, `pnpm format:check`,
`pnpm knip`, and the existing `useSlotLinkInteraction` unit tests
passing.

Link to Devin session:
https://app.devin.ai/sessions/1351ff5174494106a7a688777554f387
Requested by: @benceruleanlu

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-07 07:05:36 +00:00
Benjamin Lu
2ee91c30ee fix: report CLA check in merge queue (#13477)
Report the CLA Assistant required check for merge queue commits so
queued PRs do not wait indefinitely on a check that ran on the PR head.
2026-07-07 07:57:02 +00:00
Alexis Rolland
854770d305 ci: Update CLA workflow to build author-only allowlist (#13378)
## Summary

Update CLA workflow to build a dynamic `allowlist` that includes
everyone except the author of the PR. This relaxes the CLA signature
requirement so that it is limited to the PR author only. By signing, the
author confirms he gots approval from other contributors.

## Changes

- **What**: `cla.yml`

## Screenshots (if applicable)

<img width="1831" height="756"
alt="{B6F6C23D-EC2E-4BB3-A288-99B6087F4CAC}"
src="https://github.com/user-attachments/assets/62c04465-d1a3-4ddb-bfe7-950a29a802c4"
/>
2026-07-06 19:29:59 -07:00
jaeone94
2e4c9c6fdc style: use neutral credits info button (#13461)
## Summary

Replaces the Additional credits tooltip trigger with the shared Button
component so the icon renders with the neutral muted treatment used by
the rest of the UI.

## Changes

- **What**: Uses the shared Button component for the Additional credits
info action while preserving the existing tooltip and aria label.
- **Dependencies**: None.

## Review Focus

Confirm this remains a visual-only change scoped to the Plan & Credits
credits tile.

## Screenshots (if applicable)

Before
<img width="397" height="368" alt="스크린샷 2026-07-06 오후 11 30 38"
src="https://github.com/user-attachments/assets/9cda1aee-4dc2-4ce1-b001-29d0e09fc07d"
/>

After
<img width="374" height="355" alt="스크린샷 2026-07-06 오후 11 31 00"
src="https://github.com/user-attachments/assets/64d0b726-6031-42d4-84d7-35405150698c"
/>


## Testing

- `pnpm format`
- `pnpm lint`
- pre-commit hook: stylelint, oxfmt, oxlint, eslint, typecheck
- `pnpm test:unit
src/platform/cloud/subscription/components/CreditsTile.test.ts`
2026-07-06 14:42:49 +00:00
322 changed files with 1689 additions and 57303 deletions

View File

@@ -0,0 +1,197 @@
---
name: Backport Auto-Merge
# Completes the merge of backport PRs once they are approved and their required
# checks pass.
#
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
# sit unmerged until a human clicks merge. This workflow performs the merge
# directly (a plain `gh pr merge --squash`, which does not depend on that
# setting) once GitHub itself reports the PR as ready to merge.
#
# Safety: branch protection on core/** and cloud/** is the hard gate — it
# unconditionally requires an approval + the required status checks and cannot
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
# workflow can only ever complete a merge that already satisfies those rules;
# the eligibility check below only avoids pointless merge attempts.
#
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
# performed by the default token does not emit events that trigger other
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
# backport PR's `pull_request: closed` event to create the release tag).
on:
# Fires when someone approves — if the required checks are already green, the
# PR merges immediately.
pull_request_review:
types: [submitted]
# Primary catch for the "approved first, checks went green later" case, plus a
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
# checks completing, but GitHub suppresses `check_suite` events for its own
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
# non-latency-critical task. Backports wait hours today, so a short sweep is a
# large improvement and needs neither.
schedule:
- cron: '*/15 * * * *'
# Only constrains the default github.token (used for read-only PR lookups below).
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
permissions:
contents: read # read-only; required for gh api / gh pr list to resolve candidates
pull-requests: read # read-only; required for gh pr view eligibility checks
# Serialize runs that act on the same PR (review events keyed by PR number; all
# scheduled sweeps share one key). Cross-key overlaps are still possible but
# harmless: the merge loop treats an already-merged PR as success (idempotent).
concurrency:
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
merge:
name: Merge eligible backport PRs
# Skip review events that can't possibly make a PR mergeable — non-approval
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
# spending any API call. Schedule sweeps always proceed. The per-PR
# eligibility checks in the job still re-verify the label and decision from
# live state.
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
runs-on: ubuntu-latest
permissions:
contents: read # read-only PR/commit lookups via the default token
pull-requests: read # read-only PR metadata via the default token
steps:
- name: Collect candidate backport PRs
id: candidates
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
numbers=""
case "$EVENT_NAME" in
pull_request_review)
numbers="$PR_FROM_REVIEW"
;;
schedule)
# Sweep every open backport PR.
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
--limit 100 --json number --jq '.[].number')
;;
esac
# De-duplicate and emit space-separated, digit-only tokens.
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
echo "Candidate PRs: '${numbers:-<none>}'"
- name: Merge eligible backport PRs
if: steps.candidates.outputs.numbers != ''
env:
GH_REPO: ${{ github.repository }}
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
READ_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
run: |
set -euo pipefail
is_merged() {
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
}
for pr in $CANDIDATES; do
echo "::group::PR #${pr}"
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
if [ -z "$info" ]; then
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
fi
state=$(echo "$info" | jq -r '.state')
is_draft=$(echo "$info" | jq -r '.isDraft')
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
base=$(echo "$info" | jq -r '.baseRefName')
review=$(echo "$info" | jq -r '.reviewDecision')
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
# Only ever act on open, non-draft, backport-labelled PRs targeting a
# protected release branch.
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
echo "::endgroup::"; continue
fi
case "$base" in
cloud/*|core/*) : ;;
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
esac
# Ready = approved AND GitHub says it's mergeable with required checks green.
# CLEAN = approved, all required checks green, mergeable, no conflict.
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
# still allows the merge, so we do too (matches what a human
# clicking "Squash and merge" can do; required checks are the
# only merge gate per the ruleset). Requiring CLEAN alone would
# stick forever behind flaky/slow non-required checks.
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
# by a later event or the next sweep.
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
echo "::endgroup::"; continue
fi
echo "PR #${pr} is ready — attempting squash merge."
attempt=0
max=3
merged=false
while [ "$attempt" -lt "$max" ]; do
attempt=$((attempt + 1))
# A concurrent run (or a human) may have merged it already.
if is_merged "$pr"; then merged=true; break; fi
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
merged=true; break
fi
echo "Merge attempt ${attempt}/${max} failed: ${out}"
# No sleep after the final attempt.
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
done
# Final reconciliation: a failed merge command may just mean a concurrent
# run won the race — don't post a false failure if the PR is in fact merged.
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
if [ "$merged" = "true" ]; then
echo "PR #${pr} merged."
else
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
# Avoid spamming a persistently-stuck PR: only re-warn if the last
# warning (identified by its marker) is more than an hour old.
marker='<!-- backport-auto-merge:merge-failed -->'
# `gh api --paginate` emits one JSON array per page; `--jq` would run
# per page (missing the true latest across pages), so slurp all pages
# into one array first and filter with a separate jq pass.
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
stale=true
if [ -n "$last_warned" ]; then
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
now_epoch=$(date -u +%s)
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
fi
if [ "$stale" = "true" ]; then
body=$(printf '%s\n\n%s' \
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
"$marker")
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
else
echo "Already warned within the last hour — skipping duplicate comment."
fi
fi
echo "::endgroup::"
done

View File

@@ -6,6 +6,7 @@ on:
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
@@ -17,13 +18,45 @@ jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA already verified before merge queue
if: github.event_name == 'merge_group'
run: echo "CLA is checked on the pull request before it enters merge queue."
# The CLA action normally requires every commit author in a PR to sign.
# We only want the PR author to sign, so we allowlist all other committers
# by computing them from the PR's commits and excluding the PR author.
- name: Build author-only allowlist
id: allowlist
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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]
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--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"
else
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
fi
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -39,9 +72,10 @@ jobs:
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# Only the PR author must sign: bots plus every non-author committer
# are allowlisted via the "Build author-only allowlist" step above.
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
allowlist: ${{ steps.allowlist.outputs.allowlist }}
# Custom PR comment messages
custom-notsigned-prcomment: |

View File

@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
test('AIModelsSection heading and 6 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
await expect(modelCards).toHaveCount(6)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 938 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1483_15836)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1483_15836">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
const { hover = 'self', class: className } = defineProps<{
hover?: 'self' | 'group'
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
hover === 'group'
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
className
)
"
aria-hidden="true"
>
<ChevronRight class="size-5" :stroke-width="2" />
</div>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from '../ui/button/Button.vue'
const { title, description, cta, href, bg } = defineProps<{
title: string
description: string
@@ -28,11 +30,9 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
>
<Button as="span" variant="default" size="sm" class="mt-4">
{{ cta }}
</span>
</Button>
</div>
</a>
</template>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import CardArrow from '../../common/CardArrow.vue'
import GlassCard from '../../common/GlassCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -27,7 +29,7 @@ const cards = [
<template>
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
>
{{ headingParts[0]
}}<span class="text-white">{{
@@ -37,10 +39,11 @@ const cards = [
</h2>
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
<div
<a
v-for="card in cards"
:key="card.labelKey"
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
:href="externalLinks.cloud"
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
>
<img
:src="card.image"
@@ -51,23 +54,27 @@ const cards = [
/>
<div class="mt-8 p-6">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<div class="flex items-center justify-between gap-4">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<CardArrow hover="group" class="shrink-0" />
</div>
<h3
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ t(card.titleKey, locale) }}
</h3>
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
{{ t(card.descriptionKey, locale) }}
</p>
</div>
</div>
</a>
</GlassCard>
</section>
</template>

View File

@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
>
<div class="max-w-2xl">
<h2
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
>
{{ t('cloud.pricing.title', locale) }}
</h2>
<p class="text-primary-comfy-ink mt-4 text-base">
<p class="mt-4 text-base text-primary-comfy-ink">
{{ t('cloud.pricing.description', locale) }}
</p>
<p
v-if="SHOW_FREE_TIER"
class="text-primary-comfy-ink mt-4 text-base font-bold"
class="mt-4 text-base font-bold text-primary-comfy-ink"
>
{{ t('cloud.pricing.tagline', locale) }}
</p>
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<a
:href="getRoutes(locale).cloudPricing"
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
>
{{ t('cloud.pricing.cta', locale) }}
</a>

View File

@@ -6,6 +6,7 @@ import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import BrandButton from '../../common/BrandButton.vue'
import CardArrow from '../../common/CardArrow.vue'
type ModelCard = {
titleKey:
@@ -14,11 +15,10 @@ type ModelCard = {
| 'cloud.aiModels.card.seedance20'
| 'cloud.aiModels.card.qwenImageEdit'
| 'cloud.aiModels.card.wan22TextToVideo'
| 'cloud.aiModels.card.gptImage2'
imageSrc: string
badgeIcon: string
badgeClass: string
layoutClass: string
objectPosition?: string
}
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -32,48 +32,45 @@ const modelCards: ModelCard[] = [
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
badgeIcon: '/icons/ai-models/bytedance.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.nanoBananaPro',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
badgeIcon: '/icons/ai-models/gemini.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
objectPosition: 'center 20%'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.grokImagine',
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
badgeIcon: '/icons/ai-models/grok.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.qwenImageEdit',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
badgeIcon: '/icons/ai-models/qwen.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
badgeIcon: '/icons/ai-models/wan.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.gptImage2',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
badgeIcon: '/icons/ai-models/openai.svg',
badgeClass: `${badgeBase} rounded-2xl`
}
]
function getCardClass(layoutClass: string): string {
return cn(
layoutClass,
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
)
}
const cardClass =
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
</script>
<template>
@@ -100,23 +97,18 @@ function getCardClass(layoutClass: string): string {
</p>
<div class="mt-16 w-full lg:mt-24">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
<a
v-for="card in modelCards"
:key="card.titleKey"
:href="externalLinks.workflows"
:class="getCardClass(card.layoutClass)"
:class="cardClass"
>
<video
v-if="card.imageSrc.endsWith('.webm')"
:src="card.imageSrc"
:aria-label="t(card.titleKey, locale)"
:style="
card.objectPosition
? { objectPosition: card.objectPosition }
: undefined
"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
autoplay
loop
@@ -134,11 +126,6 @@ function getCardClass(layoutClass: string): string {
v-else
:src="card.imageSrc"
:alt="t(card.titleKey, locale)"
:style="
card.objectPosition
? { objectPosition: card.objectPosition }
: undefined
"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
@@ -168,10 +155,14 @@ function getCardClass(layoutClass: string): string {
</div>
<p
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
>
{{ t(card.titleKey, locale) }}
</p>
<CardArrow
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
/>
</a>
</div>
</div>

View File

@@ -932,9 +932,9 @@ const translations = {
'zh-CN': '所有模型。\n商业许可保证。'
},
'cloud.reason.2.description': {
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
'zh-CN':
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
},
'cloud.reason.2.badge.onlyOn': {
en: 'ONLY ON',
@@ -996,6 +996,10 @@ const translations = {
en: 'Wan 2.2',
'zh-CN': 'Wan 2.2'
},
'cloud.aiModels.card.gptImage2': {
en: 'GPT Image 2',
'zh-CN': 'GPT Image 2'
},
'cloud.aiModels.ctaDesktop': {
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
'zh-CN': '探索最新模型工作流'

View File

@@ -248,7 +248,7 @@
@utility ppformula-text-center {
display: inline-block;
position: relative;
top: 0.19em;
top: 0.1em;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked

View File

@@ -1,75 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('runWhenGlobalIdle', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('falls back to a timeout when idle callbacks are unavailable', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner)
await vi.runAllTimersAsync()
expect(runner).toHaveBeenCalledOnce()
const deadline = runner.mock.calls[0][0]
expect(deadline.didTimeout).toBe(true)
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
disposable.dispose()
disposable.dispose()
})
it('cancels fallback idle work before it runs', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner).dispose()
await vi.runAllTimersAsync()
expect(runner).not.toHaveBeenCalled()
})
it('uses native idle callbacks when available', async () => {
const requestIdleCallback = vi.fn(() => 42)
const cancelIdleCallback = vi.fn()
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner, 250)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
disposable.dispose()
disposable.dispose()
expect(cancelIdleCallback).toHaveBeenCalledOnce()
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
})
it('omits native idle timeout options when no timeout is supplied', async () => {
const requestIdleCallback = vi.fn(() => 7)
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', vi.fn())
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, undefined)
})
})

View File

@@ -1,4 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -122,22 +122,6 @@ describe('downloadUtil', () => {
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('throws for an empty URL', () => {
expect(() => downloadFile('')).toThrow(
'Invalid URL provided for download'
)
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('throws for a whitespace URL', () => {
expect(() => downloadFile(' ')).toThrow(
'Invalid URL provided for download'
)
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should prefer custom filename over extracted filename', () => {
const testUrl =
'https://example.com/api/file?filename=extracted-image.jpg'
@@ -355,7 +339,7 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
@@ -375,7 +359,7 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
@@ -395,7 +379,7 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({ ok: false, status: 404 })
)
@@ -411,7 +395,7 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,

View File

@@ -4,7 +4,6 @@ import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
clampUsd,
creditsToCents,
creditsToUsd,
formatCredits,
@@ -44,21 +43,4 @@ describe('comfyCredits helpers', () => {
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
test('formats with compatible fraction digit bounds', () => {
expect(
formatCredits({
value: 12.345,
locale: 'en-US',
numberOptions: { minimumFractionDigits: 4, maximumFractionDigits: 2 }
})
).toBe('12.35')
})
test('clamps USD purchase values into the supported range', () => {
expect(clampUsd(Number.NaN)).toBe(0)
expect(clampUsd(-5)).toBe(1)
expect(clampUsd(42)).toBe(42)
expect(clampUsd(5000)).toBe(1000)
})
})

View File

@@ -8,16 +8,7 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
logout: vi.fn().mockResolvedValue(undefined),
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
initiateCreditPurchase: vi.fn(),
accessBillingPortal: vi.fn(),
fetchBalance: vi.fn(),
loginWithGoogle: vi.fn(),
loginWithGithub: vi.fn(),
login: vi.fn(),
register: vi.fn(),
updatePassword: vi.fn().mockResolvedValue(undefined)
logout: vi.fn().mockResolvedValue(undefined)
}))
const mockToastStore = vi.hoisted(() => ({
@@ -38,16 +29,6 @@ const mockDialogService = vi.hoisted(() => ({
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockBillingContext = vi.hoisted(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
const mockTelemetry = vi.hoisted(() => ({
startTopupTracking: vi.fn()
}))
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
@@ -67,7 +48,7 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
useTelemetry: vi.fn(() => undefined)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
@@ -91,7 +72,11 @@ vi.mock('@/stores/authStore', () => ({
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => mockBillingContext)
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -112,7 +97,6 @@ describe('useAuthActions.logout', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
mockBillingContext.isActiveSubscription.value = false
})
it('logs out without prompting when no workflows are modified', async () => {
@@ -297,158 +281,4 @@ describe('useAuthActions.reportError', () => {
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
})
it('shows the unauthorized-domain access error message', () => {
const { reportError, accessError } = useAuthActions()
reportError(new FirebaseError('auth/unauthorized-domain', 'blocked'))
expect(accessError.value).toBe(true)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'toastMessages.unauthorizedDomain'
})
})
})
describe('useAuthActions account actions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockBillingContext.isActiveSubscription.value = false
vi.stubGlobal(
'open',
vi.fn(() => ({}))
)
})
it('sends password reset emails and shows success toast', async () => {
const { sendPasswordReset } = useAuthActions()
await sendPasswordReset('user@example.com')
expect(mockAuthStore.sendPasswordReset).toHaveBeenCalledWith(
'user@example.com'
)
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'auth.login.passwordResetSent'
})
)
})
it('does not purchase credits without an active subscription', async () => {
const { purchaseCredits } = useAuthActions()
await purchaseCredits(25)
expect(mockAuthStore.initiateCreditPurchase).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
})
it('opens checkout and tracks top-up starts for credit purchases', async () => {
mockBillingContext.isActiveSubscription.value = true
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({
checkout_url: 'https://checkout.example.test'
})
const { purchaseCredits } = useAuthActions()
await purchaseCredits(25)
expect(mockAuthStore.initiateCreditPurchase).toHaveBeenCalledWith({
amount_micros: 25000000,
currency: 'usd'
})
expect(mockTelemetry.startTopupTracking).toHaveBeenCalledOnce()
expect(window.open).toHaveBeenCalledWith(
'https://checkout.example.test',
'_blank'
)
})
it('throws when credit checkout URL is missing', async () => {
mockBillingContext.isActiveSubscription.value = true
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({})
const { purchaseCredits } = useAuthActions()
await expect(purchaseCredits(10)).rejects.toThrow(
'toastMessages.failedToPurchaseCredits'
)
})
it('opens the billing portal in a new tab by default', async () => {
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({
billing_portal_url: 'https://billing.example.test'
})
const { accessBillingPortal } = useAuthActions()
await expect(accessBillingPortal('pro')).resolves.toBe(true)
expect(mockAuthStore.accessBillingPortal).toHaveBeenCalledWith('pro')
expect(window.open).toHaveBeenCalledWith(
'https://billing.example.test',
'_blank'
)
})
it('throws when billing portal URL is missing', async () => {
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({})
const { accessBillingPortal } = useAuthActions()
await expect(accessBillingPortal()).rejects.toThrow(
'toastMessages.failedToAccessBillingPortal'
)
})
it('delegates balance and sign-in methods to the auth store', async () => {
mockAuthStore.fetchBalance.mockResolvedValueOnce({ balance: 12 })
mockAuthStore.loginWithGoogle.mockResolvedValueOnce('google')
mockAuthStore.loginWithGithub.mockResolvedValueOnce('github')
mockAuthStore.login.mockResolvedValueOnce('email')
mockAuthStore.register.mockResolvedValueOnce('registered')
const actions = useAuthActions()
await expect(actions.fetchBalance()).resolves.toEqual({ balance: 12 })
await expect(actions.signInWithGoogle({ isNewUser: true })).resolves.toBe(
'google'
)
await expect(actions.signInWithGithub({ isNewUser: false })).resolves.toBe(
'github'
)
await expect(actions.signInWithEmail('u@example.com', 'pw')).resolves.toBe(
'email'
)
await expect(
actions.signUpWithEmail('u@example.com', 'pw', 'turnstile')
).resolves.toBe('registered')
expect(mockAuthStore.loginWithGoogle).toHaveBeenCalledWith({
isNewUser: true
})
expect(mockAuthStore.loginWithGithub).toHaveBeenCalledWith({
isNewUser: false
})
expect(mockAuthStore.login).toHaveBeenCalledWith('u@example.com', 'pw')
expect(mockAuthStore.register).toHaveBeenCalledWith(
'u@example.com',
'pw',
'turnstile'
)
})
it('updates passwords and shows success toast', async () => {
const { updatePassword } = useAuthActions()
await updatePassword('new-password')
expect(mockAuthStore.updatePassword).toHaveBeenCalledWith('new-password')
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'auth.passwordUpdate.success'
})
)
})
})

View File

@@ -1,213 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import type { User as FirebaseUser } from 'firebase/auth'
import type { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
type FirebaseUserMock = Pick<
FirebaseUser,
'uid' | 'displayName' | 'email' | 'photoURL'
> & {
providerData: Array<Pick<FirebaseUser['providerData'][number], 'providerId'>>
}
type ApiKeyUser = NonNullable<
ReturnType<typeof useApiKeyAuthStore>['currentUser']
>
const mockStores = vi.hoisted(() => ({
authStore: undefined as
| undefined
| {
currentUser: FirebaseUserMock | null
loading: boolean
tokenRefreshTrigger: number
},
apiKeyStore: undefined as
| undefined
| {
isAuthenticated: boolean
currentUser: ApiKeyUser | null
clearStoredApiKey: ReturnType<typeof vi.fn>
},
commandStore: undefined as
| undefined
| {
execute: ReturnType<typeof vi.fn>
}
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => mockStores.authStore
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockStores.apiKeyStore
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => mockStores.commandStore
}))
async function setup() {
vi.resetModules()
const authStore = reactive({
currentUser: null as FirebaseUserMock | null,
loading: false,
tokenRefreshTrigger: 0
})
const apiKeyStore = reactive({
isAuthenticated: false,
currentUser: null as ApiKeyUser | null,
clearStoredApiKey: vi.fn()
})
const commandStore = {
execute: vi.fn()
}
mockStores.authStore = authStore
mockStores.apiKeyStore = apiKeyStore
mockStores.commandStore = commandStore
const { useCurrentUser } = await import('./useCurrentUser')
return {
currentUser: useCurrentUser(),
authStore,
apiKeyStore,
commandStore
}
}
function firebaseUser(
providerId: string,
overrides: Partial<FirebaseUserMock> = {}
): FirebaseUserMock {
return {
uid: 'firebase-user',
displayName: 'Firebase User',
email: 'firebase@example.com',
photoURL: 'https://example.com/photo.png',
providerData: [{ providerId }],
...overrides
}
}
describe('useCurrentUser', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('reports logged-out state when no auth source is active', async () => {
const { currentUser } = await setup()
expect(currentUser.loading).toBe(false)
expect(currentUser.isLoggedIn.value).toBe(false)
expect(currentUser.resolvedUserInfo.value).toBeNull()
expect(currentUser.userDisplayName.value).toBeUndefined()
expect(currentUser.userEmail.value).toBeUndefined()
expect(currentUser.userPhotoUrl.value).toBeUndefined()
expect(currentUser.providerName.value).toBeUndefined()
expect(currentUser.providerIcon.value).toBe('pi pi-user')
expect(currentUser.isEmailProvider.value).toBe(false)
})
it('uses API key user identity before firebase identity', async () => {
const { currentUser, authStore, apiKeyStore } = await setup()
authStore.currentUser = firebaseUser('google.com')
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = {
id: 'api-user',
name: 'API User',
email: 'api@example.com'
}
expect(currentUser.isLoggedIn.value).toBe(true)
expect(currentUser.isApiKeyLogin.value).toBe(true)
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'api-user' })
expect(currentUser.userDisplayName.value).toBe('API User')
expect(currentUser.userEmail.value).toBe('api@example.com')
expect(currentUser.userPhotoUrl.value).toBeNull()
expect(currentUser.providerName.value).toBe('Comfy API Key')
expect(currentUser.providerIcon.value).toBe('pi pi-key')
expect(currentUser.isEmailProvider.value).toBe(false)
})
it('maps firebase provider metadata to display fields', async () => {
const { currentUser, authStore } = await setup()
authStore.currentUser = firebaseUser('google.com')
expect(currentUser.providerName.value).toBe('Google')
expect(currentUser.providerIcon.value).toBe('pi pi-google')
expect(currentUser.userDisplayName.value).toBe('Firebase User')
expect(currentUser.userEmail.value).toBe('firebase@example.com')
expect(currentUser.userPhotoUrl.value).toBe('https://example.com/photo.png')
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'firebase-user' })
authStore.currentUser = firebaseUser('github.com')
expect(currentUser.providerName.value).toBe('GitHub')
expect(currentUser.providerIcon.value).toBe('pi pi-github')
authStore.currentUser = firebaseUser('password')
expect(currentUser.providerName.value).toBe('password')
expect(currentUser.providerIcon.value).toBe('pi pi-user')
expect(currentUser.isEmailProvider.value).toBe(true)
})
it('routes sign out through the active auth source', async () => {
const { currentUser, apiKeyStore, commandStore } = await setup()
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = { id: 'api-user' }
await currentUser.handleSignOut()
expect(apiKeyStore.clearStoredApiKey).toHaveBeenCalledOnce()
apiKeyStore.isAuthenticated = false
await currentUser.handleSignOut()
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.User.SignOut')
})
it('opens the sign-in dialog through the command store', async () => {
const { currentUser, commandStore } = await setup()
await currentUser.handleSignIn()
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.User.OpenSignInDialog'
)
})
it('runs user lifecycle callbacks for resolve, token refresh, and logout', async () => {
const { currentUser, authStore } = await setup()
const resolved = vi.fn()
const tokenRefreshed = vi.fn()
const logout = vi.fn()
currentUser.onUserResolved(resolved)
currentUser.onTokenRefreshed(tokenRefreshed)
currentUser.onUserLogout(logout)
authStore.currentUser = firebaseUser('google.com')
await nextTick()
expect(resolved.mock.calls[0][0]).toEqual({ id: 'firebase-user' })
authStore.tokenRefreshTrigger += 1
await nextTick()
expect(tokenRefreshed).toHaveBeenCalledOnce()
authStore.currentUser = null
await nextTick()
expect(logout).toHaveBeenCalledOnce()
})
it('runs onUserResolved immediately when a user already exists', async () => {
const { currentUser, apiKeyStore } = await setup()
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = { id: 'api-user' }
const resolved = vi.fn()
currentUser.onUserResolved(resolved)
expect(resolved.mock.calls[0][0]).toEqual({ id: 'api-user' })
})
})

View File

@@ -1,256 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLegacyBilling } from './useLegacyBilling'
const mocks = vi.hoisted(() => ({
isActiveSubscription: { value: false },
subscriptionTier: { value: null as string | null },
subscriptionDuration: { value: null as string | null },
subscriptionStatus: {
value: null as null | {
renewal_date?: string | null
end_date?: string | null
}
},
isCancelled: { value: false },
fetchStatus: vi.fn(),
manageSubscription: vi.fn(),
subscribe: vi.fn(),
showSubscriptionDialog: vi.fn(),
balance: {
value: null as null | {
amount_micros?: number
currency?: string
effective_balance_micros?: number
prepaid_balance_micros?: number
cloud_credit_balance_micros?: number
}
},
fetchBalance: vi.fn(),
purchaseCredits: vi.fn()
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: mocks.isActiveSubscription,
subscriptionTier: mocks.subscriptionTier,
subscriptionDuration: mocks.subscriptionDuration,
subscriptionStatus: mocks.subscriptionStatus,
isCancelled: mocks.isCancelled,
fetchStatus: mocks.fetchStatus,
manageSubscription: mocks.manageSubscription,
subscribe: mocks.subscribe,
showSubscriptionDialog: mocks.showSubscriptionDialog
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
get balance() {
return mocks.balance.value
},
fetchBalance: mocks.fetchBalance
})
}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
purchaseCredits: mocks.purchaseCredits
})
}))
describe('useLegacyBilling', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mocks.isActiveSubscription.value = false
mocks.subscriptionTier.value = null
mocks.subscriptionDuration.value = null
mocks.subscriptionStatus.value = null
mocks.isCancelled.value = false
mocks.balance.value = null
mocks.fetchStatus.mockResolvedValue(undefined)
mocks.manageSubscription.mockResolvedValue(undefined)
mocks.subscribe.mockResolvedValue(undefined)
mocks.fetchBalance.mockResolvedValue(undefined)
mocks.purchaseCredits.mockResolvedValue(undefined)
})
it('returns empty subscription and balance state without legacy data', () => {
const billing = useLegacyBilling()
expect(billing.subscription.value).toBeNull()
expect(billing.balance.value).toBeNull()
expect(billing.subscriptionStatus.value).toBeNull()
expect(billing.renewalDate.value).toBeNull()
expect(billing.isFreeTier.value).toBe(false)
})
it('maps active subscription and explicit balance fields', () => {
mocks.isActiveSubscription.value = true
mocks.subscriptionTier.value = 'PRO'
mocks.subscriptionDuration.value = 'MONTHLY'
mocks.subscriptionStatus.value = {
renewal_date: '2026-01-01T00:00:00Z',
end_date: '2026-02-01T00:00:00Z'
}
mocks.balance.value = {
amount_micros: 500,
currency: 'eur',
effective_balance_micros: 400,
prepaid_balance_micros: 300,
cloud_credit_balance_micros: 200
}
const billing = useLegacyBilling()
expect(billing.subscription.value).toEqual({
isActive: true,
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: '2026-01-01T00:00:00Z',
endDate: '2026-02-01T00:00:00Z',
isCancelled: false,
hasFunds: true
})
expect(billing.balance.value).toEqual({
amountMicros: 500,
currency: 'eur',
effectiveBalanceMicros: 400,
prepaidBalanceMicros: 300,
cloudCreditBalanceMicros: 200
})
expect(billing.subscriptionStatus.value).toBe('active')
})
it('uses legacy balance defaults when optional fields are absent', () => {
mocks.subscriptionTier.value = 'FREE'
mocks.balance.value = {}
const billing = useLegacyBilling()
expect(billing.balance.value).toEqual({
amountMicros: 0,
currency: 'usd',
effectiveBalanceMicros: 0,
prepaidBalanceMicros: 0,
cloudCreditBalanceMicros: 0
})
expect(billing.subscription.value?.hasFunds).toBe(false)
})
it('uses amount as effective balance when only amount is present', () => {
mocks.balance.value = { amount_micros: 250 }
const billing = useLegacyBilling()
expect(billing.balance.value?.effectiveBalanceMicros).toBe(250)
})
it('reports canceled status before active status', () => {
mocks.isActiveSubscription.value = true
mocks.isCancelled.value = true
const billing = useLegacyBilling()
expect(billing.subscriptionStatus.value).toBe('canceled')
})
it('initializes once and re-fetches zero free-tier balance', async () => {
mocks.subscriptionTier.value = 'FREE'
mocks.balance.value = { amount_micros: 0 }
const billing = useLegacyBilling()
await billing.initialize()
await billing.initialize()
expect(billing.isInitialized.value).toBe(true)
expect(mocks.fetchStatus).toHaveBeenCalledTimes(1)
expect(mocks.fetchBalance).toHaveBeenCalledTimes(2)
})
it('stores initialization error messages from Error failures', async () => {
mocks.fetchStatus.mockRejectedValue(new Error('status failed'))
const billing = useLegacyBilling()
await expect(billing.initialize()).rejects.toThrow('status failed')
expect(billing.error.value).toBe('status failed')
expect(billing.isLoading.value).toBe(false)
})
it('stores fallback initialization error messages for non-Error failures', async () => {
mocks.fetchStatus.mockRejectedValue('status failed')
const billing = useLegacyBilling()
await expect(billing.initialize()).rejects.toBe('status failed')
expect(billing.error.value).toBe('Failed to initialize billing')
})
it('stores subscription fetch fallback errors', async () => {
mocks.fetchStatus.mockRejectedValue('status failed')
const billing = useLegacyBilling()
await expect(billing.fetchStatus()).rejects.toBe('status failed')
expect(billing.error.value).toBe('Failed to fetch subscription')
expect(billing.isLoading.value).toBe(false)
})
it('stores balance fetch errors', async () => {
mocks.fetchBalance.mockRejectedValue(new Error('balance failed'))
const billing = useLegacyBilling()
await expect(billing.fetchBalance()).rejects.toThrow('balance failed')
expect(billing.error.value).toBe('balance failed')
expect(billing.isLoading.value).toBe(false)
})
it('stores balance fetch fallback errors', async () => {
mocks.fetchBalance.mockRejectedValue('balance failed')
const billing = useLegacyBilling()
await expect(billing.fetchBalance()).rejects.toBe('balance failed')
expect(billing.error.value).toBe('Failed to fetch balance')
})
it('delegates legacy billing actions', async () => {
const billing = useLegacyBilling()
await expect(billing.subscribe('pro-monthly')).resolves.toBeUndefined()
await expect(billing.previewSubscribe('pro-monthly')).resolves.toBeNull()
await billing.manageSubscription()
await billing.cancelSubscription()
await billing.resubscribe()
await billing.topup(750)
await expect(billing.fetchPlans()).resolves.toBeUndefined()
billing.showSubscriptionDialog()
expect(mocks.subscribe).toHaveBeenCalledTimes(2)
expect(mocks.manageSubscription).toHaveBeenCalledTimes(2)
expect(mocks.purchaseCredits).toHaveBeenCalledWith(7.5)
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
})
it('shows the subscription dialog when active subscription is required', async () => {
const billing = useLegacyBilling()
await billing.requireActiveSubscription()
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
})
it('does not show the subscription dialog for active subscribers', async () => {
mocks.isActiveSubscription.value = true
const billing = useLegacyBilling()
await billing.requireActiveSubscription()
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
})
})

View File

@@ -1,235 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, ref } from 'vue'
interface MockTerminalInstance {
cols: number
rows: number
options: unknown
loadAddon: ReturnType<typeof vi.fn>
attachCustomKeyEventHandler: ReturnType<typeof vi.fn>
open: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
hasSelection: ReturnType<typeof vi.fn>
}
interface MockFitAddonInstance {
proposeDimensions: ReturnType<typeof vi.fn>
}
const mockXterm = vi.hoisted(() => {
const terminalInstances: MockTerminalInstance[] = []
const fitAddonInstances: MockFitAddonInstance[] = []
class Terminal {
cols = 80
rows = 24
loadAddon = vi.fn()
attachCustomKeyEventHandler = vi.fn()
open = vi.fn()
dispose = vi.fn()
resize = vi.fn((cols: number, rows: number) => {
this.cols = cols
this.rows = rows
})
hasSelection = vi.fn(() => false)
constructor(readonly options: unknown) {
terminalInstances.push(this)
}
}
class FitAddon {
proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
constructor() {
fitAddonInstances.push(this)
}
}
return {
Terminal,
FitAddon,
terminalInstances,
fitAddonInstances
}
})
const mockResizeObserverInstances = [] as MockResizeObserver[]
class MockResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
constructor(readonly callback: ResizeObserverCallback) {
mockResizeObserverInstances.push(this)
}
}
vi.mock('@xterm/xterm', () => ({
Terminal: mockXterm.Terminal
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: mockXterm.FitAddon
}))
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: () => void) => fn
}))
const mockDistribution = vi.hoisted(() => ({ isDesktop: true }))
vi.mock('@/platform/distribution/types', () => mockDistribution)
import { useTerminal } from './useTerminal'
function terminalElement() {
const element = document.createElement('div')
Object.defineProperty(element, 'clientWidth', { value: 160 })
Object.defineProperty(element, 'clientHeight', { value: 100 })
return element
}
function mountTerminal(
configure?: (
result: ReturnType<typeof useTerminal>,
root: ReturnType<typeof ref<HTMLElement | undefined>>
) => void
) {
let result: ReturnType<typeof useTerminal> | undefined
const root = ref<HTMLElement | undefined>(terminalElement())
const app = createApp(
defineComponent({
setup() {
result = useTerminal(root)
configure?.(result, root)
return () => null
}
})
)
app.mount(document.createElement('div'))
if (!result) throw new Error('Expected terminal composable to initialize')
return { app, result, root }
}
describe('useTerminal', () => {
beforeEach(() => {
mockXterm.terminalInstances.length = 0
mockXterm.fitAddonInstances.length = 0
mockResizeObserverInstances.length = 0
mockDistribution.isDesktop = true
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})
it('creates a desktop themed terminal and opens it on mount', () => {
const { app, root } = mountTerminal()
const terminal = mockXterm.terminalInstances[0]
const fitAddon = mockXterm.fitAddonInstances[0]
expect(terminal.options).toMatchObject({
convertEol: true,
theme: { background: '#171717' }
})
expect(terminal.loadAddon).toHaveBeenCalledWith(fitAddon)
expect(terminal.open).toHaveBeenCalledWith(root.value)
app.unmount()
expect(terminal.dispose).toHaveBeenCalledOnce()
})
it('omits theme configuration when not running on desktop', () => {
mockDistribution.isDesktop = false
mountTerminal()
const terminal = mockXterm.terminalInstances[0]
expect(terminal.options).toEqual({ convertEol: true })
})
it('lets browser copy and paste shortcuts pass through', () => {
mountTerminal()
const terminal = mockXterm.terminalInstances[0]
const handler = terminal.attachCustomKeyEventHandler.mock.calls[0][0] as (
event: KeyboardEvent
) => boolean
terminal.hasSelection.mockReturnValue(true)
expect(
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
).toBe(false)
expect(
handler(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
).toBe(false)
terminal.hasSelection.mockReturnValue(false)
expect(
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
).toBe(true)
expect(
handler(new KeyboardEvent('keyup', { key: 'v', ctrlKey: true }))
).toBe(true)
})
it('auto-sizes from fit dimensions and disconnects the observer on unmount', () => {
const onResize = vi.fn()
const { app, root } = mountTerminal((terminal, rootRef) => {
terminal.useAutoSize({
root: rootRef,
minCols: 100,
minRows: 20,
onResize
})
})
const terminal = mockXterm.terminalInstances[0]
const observer = mockResizeObserverInstances[0]
expect(observer.observe).toHaveBeenCalledWith(root.value)
expect(terminal.resize).toHaveBeenCalledWith(120, 40)
expect(onResize).toHaveBeenCalledOnce()
app.unmount()
expect(observer.disconnect).toHaveBeenCalledOnce()
})
it('estimates invalid fit dimensions from the root element', () => {
let resize: () => void = () => {}
mountTerminal((terminal, rootRef) => {
resize = terminal.useAutoSize({
root: rootRef,
minCols: 30,
minRows: 10
}).resize
})
const fitAddon = mockXterm.fitAddonInstances[0]
fitAddon.proposeDimensions.mockReturnValue({
cols: Number.NaN,
rows: undefined
})
const terminal = mockXterm.terminalInstances[0]
resize()
expect(terminal.resize).toHaveBeenLastCalledWith(30, 10)
})
it('keeps existing terminal dimensions when auto sizing is disabled', () => {
let resize: () => void = () => {}
mountTerminal((terminal, rootRef) => {
resize = terminal.useAutoSize({
root: rootRef,
autoCols: false,
autoRows: false,
minCols: 10,
minRows: 10
}).resize
})
const terminal = mockXterm.terminalInstances[0]
terminal.cols = 90
terminal.rows = 30
resize()
expect(terminal.resize).toHaveBeenLastCalledWith(90, 30)
})
})

View File

@@ -1,4 +1,3 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,7 +5,6 @@ import type { Ref, ShallowRef } from 'vue'
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
@@ -18,7 +16,7 @@ vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const ctxObj: unknown = {
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
@@ -35,28 +33,13 @@ const ctxObj: unknown = {
fillStyle: '',
strokeStyle: '',
lineWidth: 0
}
const ctx = ctxObj as CanvasRenderingContext2D
} as unknown as CanvasRenderingContext2D
function makeCanvas(
options: {
context?: CanvasRenderingContext2D | null
clientWidth?: number
clientHeight?: number
} = {}
): HTMLCanvasElement {
function makeCanvas(): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', {
value: options.clientWidth ?? 100,
configurable: true
})
Object.defineProperty(el, 'clientHeight', {
value: options.clientHeight ?? 100,
configurable: true
})
const getCtx: unknown = () =>
options.context === undefined ? ctx : options.context
el.getContext = getCtx as HTMLCanvasElement['getContext']
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
@@ -91,7 +74,7 @@ const pe = (
clientY: number,
over: Partial<PointerEvent> = {}
) =>
fromPartial<PointerEvent>({
({
button: 0,
clientX,
clientY,
@@ -100,7 +83,7 @@ const pe = (
preventDefault: () => {},
stopPropagation: () => {},
...over
})
}) as unknown as PointerEvent
const flush = async () => {
await Promise.resolve()
@@ -113,14 +96,14 @@ interface Captured extends Api {
modelValue: Ref<BoundingBox[]>
}
function setup(initial: BoundingBox[] | undefined = []) {
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
setup() {
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial as BoundingBox[])
const modelValue = ref(initial)
const api = useBoundingBoxes(toNodeId('1'), {
canvasEl,
canvasContainer,
@@ -176,43 +159,9 @@ describe('useBoundingBoxes initialization', () => {
expect(c.hasRegions.value).toBe(false)
expect(c.activeRegion.value).toBeNull()
})
it('falls back to default dimensions when the litegraph node is unavailable', () => {
appState.node = null
const c = setup([box()])
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
})
it('ignores non-positive dimension widgets', () => {
appState.node = {
widgets: [
{ name: 'width', value: 0 },
{ name: 'height', value: 'bad' }
],
findInputSlot: () => -1,
getInputNode: () => null
}
const c = setup()
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
})
it('treats an undefined model value as empty', () => {
const c = setup(undefined)
expect(c.hasRegions.value).toBe(false)
expect(c.modelValue.value).toEqual([])
})
})
describe('useBoundingBoxes drawing', () => {
it('ignores non-primary pointer buttons', async () => {
const c = setup()
c.onPointerDown(pe(10, 10, { button: 1 }))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('draws a new region and syncs it to the model value', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
@@ -238,102 +187,6 @@ describe('useBoundingBoxes drawing', () => {
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
it('moves an existing active region by dragging inside it', async () => {
const c = setup([box()])
c.onPointerDown(pe(30, 30))
c.onCanvasPointerMove(pe(45, 50))
c.onDocPointerUp(pe(45, 50))
await flush()
expect(c.modelValue.value[0].x).toBeGreaterThan(51)
expect(c.modelValue.value[0].y).toBeGreaterThan(51)
})
it('resizes an existing active region from its corner handle', async () => {
const c = setup([box()])
c.onPointerDown(pe(60, 60))
c.onCanvasPointerMove(pe(80, 80))
c.onDocPointerUp(pe(80, 80))
await flush()
expect(c.modelValue.value[0].width).toBeGreaterThan(256)
expect(c.modelValue.value[0].height).toBeGreaterThan(256)
})
it('keeps selection valid when Alt-clicking overlapping regions', async () => {
const c = setup([
box(),
box({
metadata: {
type: 'obj',
text: '',
desc: 'second',
palette: ['#ff0000']
}
})
])
c.onPointerDown(pe(30, 30, { altKey: true }))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.activeRegion.value).not.toBeNull()
expect(c.modelValue.value).toHaveLength(2)
})
it('ignores document movement and pointer up when no draw is active', async () => {
const c = setup([box()])
c.onCanvasPointerMove(pe(5, 95))
c.onDocPointerUp(pe(95, 95))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
it('uses zero pointer coordinates when the canvas is unavailable', async () => {
const c = setup()
c.canvasEl.value = null
c.onPointerDown(pe(50, 50))
c.onCanvasPointerMove(pe(80, 80))
c.onDocPointerUp(pe(80, 80))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('redraws active text regions with fallback palette color', async () => {
const c = setup([
box({
x: 10,
y: 10,
width: 30,
height: 30,
metadata: {
type: 'text',
text: 'hello',
desc: 'alpha beta\n\ncharlie',
palette: []
}
})
])
c.focused.value = true
c.syncState()
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
it('draws safely when the canvas context is unavailable', async () => {
const c = setup([box()])
c.canvasEl.value = makeCanvas({ context: null })
c.syncState()
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
})
describe('useBoundingBoxes region editing', () => {
@@ -346,13 +199,11 @@ describe('useBoundingBoxes region editing', () => {
it('deletes the active region on Delete', async () => {
const c = setup([box()])
c.onCanvasKeyDown(
fromPartial<KeyboardEvent>({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
})
)
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
@@ -363,74 +214,12 @@ describe('useBoundingBoxes region editing', () => {
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('does nothing when changing type without an active region', async () => {
const c = setup()
c.setActiveType('text')
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('deletes the active region on Backspace', async () => {
const c = setup([box()])
c.onCanvasKeyDown(
fromPartial<KeyboardEvent>({
key: 'Backspace',
preventDefault: () => {},
stopPropagation: () => {}
})
)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('ignores unrelated keys and key events while drawing', async () => {
const c = setup([box()])
c.onCanvasKeyDown(
fromPartial<KeyboardEvent>({
key: 'Enter',
preventDefault: () => {
throw new Error('should not prevent')
},
stopPropagation: () => {}
})
)
c.onPointerDown(pe(80, 80))
c.onCanvasKeyDown(
fromPartial<KeyboardEvent>({
key: 'Delete',
preventDefault: () => {
throw new Error('should not prevent while drawing')
},
stopPropagation: () => {}
})
)
c.onDocPointerUp(pe(80, 80))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
it('keeps a remaining region selected after deleting from a multi-region list', async () => {
const c = setup([box(), box({ x: 10 })])
c.onCanvasKeyDown(
fromPartial<KeyboardEvent>({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
})
)
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.activeRegion.value).not.toBeNull()
})
})
describe('useBoundingBoxes inline editor', () => {
it('opens on double click and commits the description', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as MouseEvent)
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
@@ -443,91 +232,11 @@ describe('useBoundingBoxes inline editor', () => {
it('closes the inline editor on Escape', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as MouseEvent)
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
expect(c.inlineEditor.value).toBeNull()
})
it('commits the inline editor on Ctrl+Enter', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as MouseEvent)
await flush()
c.inlineEditor.value!.value = 'committed'
c.onInlineKeyDown({
key: 'Enter',
ctrlKey: true,
metaKey: false
} as KeyboardEvent)
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('committed')
})
it('commits the inline editor on Meta+Enter', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as MouseEvent)
await flush()
c.inlineEditor.value!.value = 'meta committed'
c.onInlineKeyDown({
key: 'Enter',
ctrlKey: false,
metaKey: true
} as KeyboardEvent)
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('meta committed')
})
it('ignores Enter without a modifier in the inline editor', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as MouseEvent)
await flush()
c.inlineEditor.value!.value = 'not committed'
c.onInlineKeyDown({
key: 'Enter',
ctrlKey: false,
metaKey: false
} as KeyboardEvent)
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('')
})
it('leaves state unchanged when committing without an editor', async () => {
const c = setup([box()])
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('')
})
it('closes a stale inline editor after its region was removed', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as MouseEvent)
await flush()
c.inlineEditor.value!.value = 'stale'
c.clearAll()
c.commitInlineEditor()
await flush()
expect(c.inlineEditor.value).toBeNull()
expect(c.modelValue.value).toHaveLength(0)
})
it('does not open the inline editor when double-clicking empty space', async () => {
const c = setup([box({ x: 0, y: 0, width: 50, height: 50 })])
c.onDoubleClick(pe(95, 95) as MouseEvent)
await flush()
expect(c.inlineEditor.value).toBeNull()
})
it('uses zero mouse coordinates when double-clicking without a canvas', async () => {
const c = setup([box({ x: 0, y: 0, width: 512, height: 512 })])
c.canvasEl.value = null
c.onDoubleClick(pe(30, 30) as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
@@ -538,74 +247,4 @@ describe('useBoundingBoxes hover cursor', () => {
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
it('returns to the default cursor after leaving the canvas', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
c.onCanvasPointerMove(pe(15, 15))
await flush()
c.onPointerLeave()
await flush()
expect(c.canvasCursor.value).toBe('crosshair')
})
it('does nothing when leaving without hover state', async () => {
const c = setup([box()])
c.onPointerLeave()
await flush()
expect(c.canvasCursor.value).toBe('crosshair')
})
it('keeps cursor default when canvas context is unavailable for title hit testing', async () => {
const c = setup([box()])
c.canvasEl.value = makeCanvas({ context: null })
c.onCanvasPointerMove(pe(30, 30))
await flush()
expect(c.canvasCursor.value).toBe('crosshair')
})
it('keeps hover state unchanged when pointer movement hits the same tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
c.onCanvasPointerMove(pe(15, 15))
await flush()
c.onCanvasPointerMove(pe(15, 15))
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
})
describe('useBoundingBoxes background image', () => {
it('loads a background image and snaps node dimensions', async () => {
const widthCallback = vi.fn()
const heightCallback = vi.fn()
const inputNode = { id: 2 }
appState.node = {
widgets: [
{ name: 'width', value: 512, callback: widthCallback },
{ name: 'height', value: 512, callback: heightCallback }
],
findInputSlot: () => 0,
getInputNode: () => inputNode
}
const store = useNodeOutputStore()
vi.spyOn(store, 'getNodeImageUrls').mockReturnValue(['blob:bg'])
class FakeImage {
crossOrigin = ''
naturalWidth = 257
naturalHeight = 271
onload: (() => void) | null = null
set src(_value: string) {
this.onload?.()
}
}
vi.stubGlobal('Image', FakeImage)
setup([box()])
await flush()
expect(widthCallback).toHaveBeenCalledWith(256)
expect(heightCallback).toHaveBeenCalledWith(272)
})
})

View File

@@ -1,117 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
type Graph = {
isRootGraph: boolean
}
type FocusableNode = {
graph?: Graph
boundingRect: DOMRect
}
const { appState, canvasStore, getNodeByExecutionId } = vi.hoisted(() => ({
appState: {
rootGraph: { isRootGraph: true }
},
canvasStore: {
canvas: undefined as
| undefined
| {
graph: Graph
subgraph?: Graph
setGraph: ReturnType<typeof vi.fn>
animateToBounds: ReturnType<typeof vi.fn>
}
},
getNodeByExecutionId: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasStore
}))
vi.mock('@/scripts/app', () => ({
app: appState
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId
}))
beforeEach(() => {
getNodeByExecutionId.mockReset()
vi.stubGlobal(
'requestAnimationFrame',
(callback: FrameRequestCallback): number => {
callback(0)
return 1
}
)
canvasStore.canvas = {
graph: appState.rootGraph,
setGraph: vi.fn(),
animateToBounds: vi.fn()
}
})
describe('useFocusNode', () => {
it('does nothing when there is no canvas or matching graph node', async () => {
canvasStore.canvas = undefined
await useFocusNode().focusNode('node-1')
expect(getNodeByExecutionId).not.toHaveBeenCalled()
canvasStore.canvas = {
graph: appState.rootGraph,
setGraph: vi.fn(),
animateToBounds: vi.fn()
}
getNodeByExecutionId.mockReturnValue({ boundingRect: new DOMRect() })
await useFocusNode().focusNode('node-1')
expect(canvasStore.canvas.animateToBounds).not.toHaveBeenCalled()
})
it('navigates to the node graph before focusing its bounds', async () => {
const subgraph = { isRootGraph: false }
const bounds = new DOMRect(1, 2, 3, 4)
getNodeByExecutionId.mockReturnValue({
graph: subgraph,
boundingRect: bounds
} satisfies FocusableNode)
await useFocusNode().focusNode('node-1')
expect(getNodeByExecutionId).toHaveBeenCalledWith(
appState.rootGraph,
'node-1'
)
expect(canvasStore.canvas?.subgraph).toBe(subgraph)
expect(canvasStore.canvas?.setGraph).toHaveBeenCalledWith(subgraph)
expect(canvasStore.canvas?.animateToBounds).toHaveBeenCalledWith(bounds)
})
it('uses an execution id map and skips graph navigation when already there', async () => {
const graph = { isRootGraph: true }
const bounds = new DOMRect(5, 6, 7, 8)
canvasStore.canvas = {
graph,
setGraph: vi.fn(),
animateToBounds: vi.fn()
}
const node = { graph, boundingRect: bounds } satisfies FocusableNode
await useFocusNode().focusNode(
'node-1',
new Map([['node-1', createMockLGraphNode(node)]])
)
expect(getNodeByExecutionId).not.toHaveBeenCalled()
expect(canvasStore.canvas.setGraph).not.toHaveBeenCalled()
expect(canvasStore.canvas.animateToBounds).toHaveBeenCalledWith(bounds)
})
})

View File

@@ -1,116 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
const mocks = vi.hoisted(() => ({
canvas: {
canvas: {},
ds: {
scale: 3
}
},
canvasPosToClientPos: vi.fn((pos: [number, number]) => [
pos[0] + 10,
pos[1] + 20
]),
getCanvas: vi.fn(),
getSetting: vi.fn(),
updateCanvasPosition: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: mocks.getCanvas
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mocks.getSetting
})
}))
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useCanvasPositionConversion: vi.fn(() => ({
canvasPosToClientPos: mocks.canvasPosToClientPos,
update: mocks.updateCanvasPosition
}))
}))
const { useAbsolutePosition } = await import('./useAbsolutePosition')
describe('useAbsolutePosition', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getCanvas.mockReturnValue(mocks.canvas)
mocks.canvas.ds.scale = 3
})
it('positions and scales an element with the canvas scale', () => {
const { style, updatePosition } = useAbsolutePosition()
updatePosition({
pos: [1, 2],
size: [4, 5]
})
expect(style.value).toMatchObject({
position: 'fixed',
left: '11px',
top: '22px',
width: '12px',
height: '15px'
})
})
it('uses an explicit scale when provided', () => {
const { style, updatePosition } = useAbsolutePosition()
updatePosition({
pos: [1, 2],
size: [4, 5],
scale: 2
})
expect(style.value).toMatchObject({
width: '8px',
height: '10px'
})
})
it('applies transform scaling without resizing the element bounds', () => {
const { style, updatePosition } = useAbsolutePosition({
useTransform: true
})
updatePosition({
pos: [1, 2],
size: [4, 5],
scale: 2
})
expect(style.value).toMatchObject({
position: 'fixed',
transformOrigin: '0 0',
transform: 'scale(2)',
left: '11px',
top: '22px',
width: '4px',
height: '5px'
})
})
it('recomputes the canvas position when layout settings change', async () => {
const sidebarLocation = ref('left')
mocks.getSetting.mockImplementation((key: string) =>
key === 'Comfy.Sidebar.Location' ? sidebarLocation.value : undefined
)
useAbsolutePosition()
expect(mocks.updateCanvasPosition).not.toHaveBeenCalled()
sidebarLocation.value = 'right'
await nextTick()
expect(mocks.updateCanvasPosition).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,87 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => {
const canvasObj: unknown = {
canvas: {},
ds: {
offset: [10, 20],
scale: 2
}
}
const canvas = canvasObj as LGraphCanvas
return {
bounds: {
left: { value: 4 },
top: { value: 6 }
},
canvas,
getCanvas: vi.fn(() => canvas),
update: vi.fn()
}
})
vi.mock('@vueuse/core', () => ({
useElementBounding: vi.fn(() => ({
left: mocks.bounds.left,
top: mocks.bounds.top,
update: mocks.update
}))
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: mocks.getCanvas
})
}))
const { useCanvasPositionConversion, useSharedCanvasPositionConversion } =
await import('./useCanvasPositionConversion')
describe('useCanvasPositionConversion', () => {
beforeEach(() => {
mocks.bounds.left.value = 4
mocks.bounds.top.value = 6
mocks.getCanvas.mockClear()
mocks.update.mockClear()
})
it('converts client positions into canvas coordinates', () => {
const { clientPosToCanvasPos } = useCanvasPositionConversion(
mocks.canvas.canvas,
mocks.canvas
)
expect(clientPosToCanvasPos([34, 66])).toEqual([5, 10])
})
it('converts canvas positions into client coordinates', () => {
const { canvasPosToClientPos } = useCanvasPositionConversion(
mocks.canvas.canvas,
mocks.canvas
)
expect(canvasPosToClientPos([5, 10])).toEqual([34, 66])
})
it('returns the element bounds update callback', () => {
const { update } = useCanvasPositionConversion(
mocks.canvas.canvas,
mocks.canvas
)
update()
expect(mocks.update).toHaveBeenCalledTimes(1)
})
it('reuses the shared converter instance', () => {
const first = useSharedCanvasPositionConversion()
const second = useSharedCanvasPositionConversion()
expect(second).toBe(first)
expect(mocks.getCanvas).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,82 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { useOverflowObserver } from './useOverflowObserver'
vi.mock('@vueuse/core', () => ({
useMutationObserver: vi.fn(() => ({ stop: vi.fn() })),
useResizeObserver: vi.fn(() => ({ stop: vi.fn() }))
}))
const useMutationObserverMock = vi.mocked(useMutationObserver)
const useResizeObserverMock = vi.mocked(useResizeObserver)
function setElementWidths(
element: HTMLElement,
widths: { scrollWidth: number; clientWidth: number }
) {
Object.defineProperty(element, 'scrollWidth', {
value: widths.scrollWidth,
configurable: true
})
Object.defineProperty(element, 'clientWidth', {
value: widths.clientWidth,
configurable: true
})
}
describe('useOverflowObserver', () => {
beforeEach(() => {
vi.clearAllMocks()
useMutationObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
useResizeObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
})
it('checks overflow immediately when debounce is disabled', () => {
const element = document.createElement('div')
const onCheck = vi.fn()
setElementWidths(element, { scrollWidth: 120, clientWidth: 100 })
const observer = useOverflowObserver(element, {
debounceTime: 0,
onCheck
})
observer.checkOverflow()
expect(observer.isOverflowing.value).toBe(true)
expect(onCheck).toHaveBeenCalledWith(true)
})
it('can skip observers and still dispose', () => {
const element = document.createElement('div')
const observer = useOverflowObserver(element, {
useMutationObserver: false,
useResizeObserver: false
})
observer.dispose()
expect(observer.disposed.value).toBe(true)
expect(useMutationObserverMock).not.toHaveBeenCalled()
expect(useResizeObserverMock).not.toHaveBeenCalled()
})
it('stops enabled observers on dispose', () => {
const element = document.createElement('div')
const stopMutation = vi.fn()
const stopResize = vi.fn()
useMutationObserverMock.mockReturnValue(fromPartial({ stop: stopMutation }))
useResizeObserverMock.mockReturnValue(fromPartial({ stop: stopResize }))
const observer = useOverflowObserver(element)
observer.dispose()
expect(stopMutation).toHaveBeenCalledOnce()
expect(stopResize).toHaveBeenCalledOnce()
})
})

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { MenuOption } from './useMoreOptionsMenu'
import {
@@ -360,203 +360,5 @@ describe('contextMenuConverter', () => {
)
expect(hasExtensionsCategory).toBe(true)
})
it('skips items without content and duplicate equivalents', () => {
const result = convertContextMenuToOptions(
[
{ content: '', callback: () => {} },
{ content: 'Duplicate', callback: () => {} },
{ content: 'Clone', callback: () => {} }
],
undefined,
false
)
expect(result.map((option) => option.label)).toEqual(['Duplicate'])
})
it('wraps callbacks and reports callback errors', () => {
const callback = vi.fn()
const error = new Error('callback failed')
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = convertContextMenuToOptions(
[
{ content: 'Run', value: 'run-value', callback },
{
content: 'Broken',
callback: () => {
throw error
}
},
{ content: 'Disabled', disabled: true, callback: () => {} }
],
undefined,
false
)
result[0].action?.()
result[1].action?.()
expect(callback).toHaveBeenCalledWith(
'run-value',
{},
undefined,
undefined,
expect.objectContaining({ content: 'Run' })
)
expect(errorSpy).toHaveBeenCalledWith(
'Error executing context menu callback:',
error
)
expect(result[2].action).toBeUndefined()
errorSpy.mockRestore()
})
it('converts static submenus and submenu callbacks', () => {
const submenuCallback = vi.fn()
const error = new Error('submenu failed')
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = convertContextMenuToOptions(
[
{
content: 'Static Submenu',
has_submenu: true,
submenu: {
options: [
'<b>ignored string without callback</b>',
null,
{
content: '<b>Choice</b>',
value: 'choice',
callback: submenuCallback
},
{
content: '<i>Disabled</i>',
disabled: true
},
{
content: '<span>Broken</span>',
callback: () => {
throw error
}
},
{ content: '' }
]
}
}
],
undefined,
false
)
const submenu = result[0].submenu ?? []
expect(result[0].hasSubmenu).toBe(true)
expect(submenu.map((option) => option.label)).toEqual([
'<b>ignored string without callback</b>',
'Choice',
'Disabled',
'Broken'
])
expect(submenu[2].disabled).toBe(true)
submenu[1].action?.()
submenu[3].action?.()
expect(submenuCallback).toHaveBeenCalledWith(
'choice',
{},
undefined,
undefined,
expect.objectContaining({ content: '<b>Choice</b>' })
)
expect(errorSpy).toHaveBeenCalledWith(
'Error executing submenu callback:',
error
)
errorSpy.mockRestore()
})
it('captures dynamic submenus created by callbacks', () => {
const stringCallback = vi.fn()
const objectCallback = vi.fn()
const result = convertContextMenuToOptions(
[
{
content: 'Dynamic Submenu',
has_submenu: true,
callback: () => {
new LiteGraph.ContextMenu(
[
'Auto',
{
content: '<b>Object choice</b>',
value: 'object',
callback: objectCallback
}
],
{ callback: stringCallback, extra: { source: 'test' } }
)
}
}
],
undefined,
false
)
const submenu = result[0].submenu ?? []
expect(result[0].hasSubmenu).toBe(true)
expect(submenu.map((option) => option.label)).toEqual([
'Auto',
'Object choice'
])
submenu[0].action?.()
submenu[1].action?.()
expect(stringCallback).toHaveBeenCalledWith(
'Auto',
expect.objectContaining({ extra: { source: 'test' } }),
undefined,
undefined,
{ source: 'test' }
)
expect(objectCallback).toHaveBeenCalledWith(
'object',
{},
undefined,
undefined,
expect.objectContaining({ content: '<b>Object choice</b>' })
)
})
it('warns when dynamic submenu callbacks fail to provide items', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = convertContextMenuToOptions(
[
{
content: 'Empty Dynamic Submenu',
has_submenu: true,
callback: () => {}
}
],
undefined,
false
)
expect(result[0].hasSubmenu).toBe(true)
expect(result[0].submenu).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'[ContextMenuConverter] No items captured for:',
'Empty Dynamic Submenu'
)
expect(warnSpy).toHaveBeenCalledWith(
'[ContextMenuConverter] Failed to capture submenu for:',
'Empty Dynamic Submenu'
)
warnSpy.mockRestore()
})
})
})

View File

@@ -1,12 +1,11 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -21,7 +20,6 @@ import * as missingModelScan from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
@@ -132,46 +130,6 @@ describe('Connection error clearing via onConnectionsChange', () => {
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors when a connected input has no root graph', () => {
const { graph, node } = createGraphWithInput()
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors when a connected input has no slot name', () => {
const { graph, node } = createGraphWithInput()
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
const nullSlot: unknown = null
node.onConnectionsChange!(
NodeSlotType.INPUT,
12,
true,
null,
nullSlot as INodeInputSlot
)
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears errors for pure input slots without widget property', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
@@ -271,38 +229,9 @@ describe('Widget change error clearing via onWidgetChanged', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const noGraph: unknown = undefined
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(noGraph as LGraph)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors when the host execution id is unavailable', () => {
const graph = new LGraph()
const otherGraph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(otherGraph)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
fromAny<LGraph, unknown>(undefined)
)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
@@ -462,124 +391,6 @@ describe('installErrorClearingHooks lifecycle', () => {
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
it('removes unhooked nodes without restoring callbacks', () => {
const graph = new LGraph()
installErrorClearingHooks(graph)
const node = new LGraphNode('late')
expect(() => graph.onNodeRemoved!(node)).not.toThrow()
expect(node.onConnectionsChange).toBeUndefined()
expect(node.onWidgetChanged).toBeUndefined()
})
it('restores recursively installed callbacks on subgraph cleanup', () => {
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('inner')
const originalOnConnectionsChange = vi.fn()
const originalOnWidgetChanged = vi.fn()
innerNode.onConnectionsChange = originalOnConnectionsChange
innerNode.onWidgetChanged = originalOnWidgetChanged
subgraph.add(innerNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraph.rootGraph
graph.add(subgraphNode)
const cleanup = installErrorClearingHooks(graph)
expect(innerNode.onConnectionsChange).not.toBe(originalOnConnectionsChange)
expect(innerNode.onWidgetChanged).not.toBe(originalOnWidgetChanged)
cleanup()
expect(innerNode.onConnectionsChange).toBe(originalOnConnectionsChange)
expect(innerNode.onWidgetChanged).toBe(originalOnWidgetChanged)
})
it('restores undefined graph hooks when cleanup is called', () => {
const graph = new LGraph()
const cleanup = installErrorClearingHooks(graph)
cleanup()
expect(graph.onNodeAdded).toBeUndefined()
expect(graph.onNodeRemoved).toBeUndefined()
expect(graph.onTrigger).toBeUndefined()
})
it('calls original graph hooks for added, removed, and trigger events', () => {
const graph = new LGraph()
const onNodeAdded = vi.fn()
const onNodeRemoved = vi.fn()
const onTrigger = vi.fn()
graph.onNodeAdded = onNodeAdded
graph.onNodeRemoved = onNodeRemoved
graph.onTrigger = onTrigger
installErrorClearingHooks(graph)
const node = new LGraphNode('test')
graph.onNodeAdded!(node)
graph.onNodeRemoved!(node)
graph.onTrigger!({
type: 'node:property:changed',
nodeId: node.id,
property: 'title',
oldValue: 'old',
newValue: 'new'
})
expect(onNodeAdded).toHaveBeenCalledWith(node)
expect(onNodeRemoved).toHaveBeenCalledWith(node)
expect(onTrigger).toHaveBeenCalledWith(
expect.objectContaining({ property: 'title' })
)
})
it('skips scanning added nodes while graph loading is in progress', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
vi.spyOn(ChangeTracker, 'isLoadingGraph', 'get').mockReturnValue(true)
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
await Promise.resolve()
await Promise.resolve()
expect(modelScan).not.toHaveBeenCalled()
})
it('skips scanning added nodes when root graph is unavailable', async () => {
const graph = new LGraph()
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
installErrorClearingHooks(graph)
graph.add(new LGraphNode('CheckpointLoaderSimple'))
await Promise.resolve()
await Promise.resolve()
expect(modelScan).not.toHaveBeenCalled()
expect(mediaScan).not.toHaveBeenCalled()
})
it('skips scanning added inactive nodes', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.mode = LGraphEventMode.BYPASS
graph.add(node)
await Promise.resolve()
await Promise.resolve()
expect(modelScan).not.toHaveBeenCalled()
})
it('scans added-node missing models after widget values are restored', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
@@ -681,7 +492,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromPartial<MissingModelCandidate>({
fromAny<
Parameters<typeof modelStore.setMissingModels>[0][number],
unknown
>({
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
@@ -718,7 +532,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromPartial<MissingModelCandidate>({
fromAny<
Parameters<typeof modelStore.setMissingModels>[0][number],
unknown
>({
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
@@ -749,7 +566,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
const mediaStore = useMissingMediaStore()
mediaStore.setMissingMedia([
fromPartial<MissingMediaCandidate>({
fromAny<
Parameters<typeof mediaStore.setMissingMedia>[0][number],
unknown
>({
nodeId: interiorExecId,
nodeType: 'LoadImage',
widgetName: 'image',
@@ -914,84 +734,6 @@ describe('realtime scan verifies pending cloud candidates', () => {
await new Promise((r) => setTimeout(r, 0))
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('logs pending model verification failures without surfacing candidates', async () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'broken.safetensors',
isMissing: undefined
}
])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
const verifySpy = vi
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
.mockRejectedValue(new Error('nope'))
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined)
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('logs pending media verification failures without surfacing candidates', async () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'broken.png',
isMissing: undefined
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockRejectedValue(new Error('nope'))
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined)
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
})
})
describe('realtime verification staleness guards', () => {
@@ -1151,54 +893,6 @@ describe('realtime verification staleness guards', () => {
// result must not be added to the store.
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('skips adding verified media when rootGraph switched before verification resolved', async () => {
const graphA = new LGraph()
const nodeA = new LGraphNode('LoadImage')
graphA.add(nodeA)
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(nodeA.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'stale_from_A.png',
isMissing: undefined
}
])
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
})
installErrorClearingHooks(graphA)
nodeA.mode = LGraphEventMode.ALWAYS
graphA.onTrigger?.({
type: 'node:property:changed',
nodeId: nodeA.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
const graphB = new LGraph()
graphB.add(new LGraphNode('LoadImage'))
rootSpy.mockReturnValue(graphB)
resolveVerify!()
await new Promise((r) => setTimeout(r, 0))
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
})
})
describe('scan skips interior of bypassed subgraph containers', () => {
@@ -1310,168 +1004,6 @@ describe('scan skips interior of bypassed subgraph containers', () => {
)
})
it('skips inactive descendants during subgraph replay scans', async () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph({ rootGraph })
const activeNode = new LGraphNode('UNETLoader')
const bypassedNode = new LGraphNode('CheckpointLoaderSimple')
bypassedNode.mode = LGraphEventMode.BYPASS
subgraph.add(activeNode)
subgraph.add(bypassedNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph,
id: 205
})
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const modelScanSpy = vi
.spyOn(missingModelScan, 'scanNodeModelCandidates')
.mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(rootGraph)
rootGraph.onNodeAdded?.(subgraphNode)
await Promise.resolve()
expect(modelScanSpy).toHaveBeenCalledWith(
rootGraph,
activeNode,
expect.any(Function),
expect.any(Function)
)
expect(modelScanSpy).not.toHaveBeenCalledWith(
rootGraph,
bypassedNode,
expect.any(Function),
expect.any(Function)
)
})
it('surfaces missing node errors from the Unknown fallback type', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const noType: unknown = undefined
node.type = noType as LGraphNode['type']
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(useMissingNodesErrorStore().missingNodesError?.nodeTypes).toEqual([
expect.objectContaining({ type: 'Unknown', nodeId: String(node.id) })
])
})
it('does not show the overlay when un-bypass finds no missing errors', () => {
const subgraph = createTestSubgraph()
const node = createTestSubgraphNode(subgraph)
const graph = subgraph.rootGraph
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
const showOverlay = vi.spyOn(useExecutionErrorStore(), 'showErrorOverlay')
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(showOverlay).not.toHaveBeenCalled()
})
it('ignores mode changes that do not change active state', () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.ALWAYS,
newValue: LGraphEventMode.ON_EVENT
})
expect(modelScan).not.toHaveBeenCalled()
})
it('ignores mode changes for missing local nodes', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: 999,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(modelScan).not.toHaveBeenCalled()
})
it('ignores mode changes when root graph is unavailable', () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(modelScan).not.toHaveBeenCalled()
})
it('ignores mode changes when the local node has no root execution id', () => {
const graph = new LGraph()
const rootGraph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(modelScan).not.toHaveBeenCalled()
})
it('removes host-keyed promoted missing models when a source ancestor is bypassed', () => {
const { rootGraph, outerSubgraph, innerSubgraphNode } =
createNestedSubgraphRuntime()
@@ -1480,7 +1012,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromPartial<MissingModelCandidate>({
fromAny<MissingModelCandidate, unknown>({
nodeId: '65',
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
nodeType: 'CheckpointLoaderSimple',
@@ -1507,7 +1039,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
const { rootGraph, outerSubgraph, innerSubgraphNode, outerSubgraphNode } =
createNestedSubgraphRuntime()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const hostCandidate = fromPartial<MissingModelCandidate>({
const hostCandidate = fromAny<MissingModelCandidate, unknown>({
nodeId: '65',
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
nodeType: 'CheckpointLoaderSimple',

View File

@@ -1,124 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockSelectionState = vi.hoisted(() => ({
refs: null as null | {
hasMultipleSelection: { value: boolean }
}
}))
const mockSettingStore = vi.hoisted(() => ({
get: vi.fn()
}))
const mockTitleEditorStore = vi.hoisted(() => ({
titleEditorTarget: null as null | object
}))
const mockApp = vi.hoisted(() => ({
canvas: {
selectedItems: new Set<object>(),
graph: {
add: vi.fn()
}
}
}))
const mockGroups = vi.hoisted(() => ({
instances: [] as Array<{
resizeTo: ReturnType<typeof vi.fn>
}>
}))
vi.mock('@/composables/graph/useSelectionState', async () => {
const { ref } = await import('vue')
const hasMultipleSelection = ref(false)
mockSelectionState.refs = {
hasMultipleSelection
}
return {
useSelectionState: () => ({
hasMultipleSelection
})
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useTitleEditorStore: () => mockTitleEditorStore
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LGraphGroup: class MockLGraphGroup {
resizeTo = vi.fn()
constructor() {
mockGroups.instances.push(this)
}
}
}))
describe('useFrameNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
if (mockSelectionState.refs) {
mockSelectionState.refs.hasMultipleSelection.value = false
}
mockSettingStore.get.mockReturnValue(24)
mockTitleEditorStore.titleEditorTarget = null
mockApp.canvas.selectedItems = new Set()
mockApp.canvas.graph = {
add: vi.fn()
}
mockGroups.instances = []
})
it('exposes whether selected nodes can be framed', async () => {
const { useFrameNodes } = await import('./useFrameNodes')
const { canFrame } = useFrameNodes()
expect(canFrame.value).toBe(false)
if (!mockSelectionState.refs) {
throw new Error('selection refs were not initialized')
}
mockSelectionState.refs.hasMultipleSelection.value = true
expect(canFrame.value).toBe(true)
})
it('does nothing when no items are selected', async () => {
const { useFrameNodes } = await import('./useFrameNodes')
const { frameNodes } = useFrameNodes()
frameNodes()
expect(mockGroups.instances).toHaveLength(0)
expect(mockApp.canvas.graph.add).not.toHaveBeenCalled()
})
it('frames selected items and opens the title editor on the new group', async () => {
const selectedNode = {}
mockApp.canvas.selectedItems = new Set([selectedNode])
const { useFrameNodes } = await import('./useFrameNodes')
const { frameNodes } = useFrameNodes()
frameNodes()
const group = mockGroups.instances[0]
expect(group.resizeTo).toHaveBeenCalledWith(
mockApp.canvas.selectedItems,
24
)
expect(mockApp.canvas.graph.add).toHaveBeenCalledWith(group)
expect(mockTitleEditorStore.titleEditorTarget).toBe(group)
})
})

View File

@@ -1,14 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import {
extractVueNodeData,
getControlWidget,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import {
@@ -19,10 +14,8 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
describe('Node Reactivity', () => {
beforeEach(() => {
@@ -270,26 +263,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData.slotMetadata).toBeUndefined()
})
it('maps widget slot metadata even when the input slot name is empty', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('', 'STRING')
input.widget = { name: 'prompt' }
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(node.id)
?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toMatchObject({
index: 0,
linked: false,
type: 'STRING'
})
})
})
describe('Subgraph output slot label reactivity', () => {
@@ -783,534 +756,3 @@ describe('Pre-remove vueNodeData drain', () => {
).toBe(0)
})
})
describe('Graph node manager property triggers', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates Vue node data for LiteGraph property change events', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'title',
newValue: 'Renamed'
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'has_errors',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'flags.collapsed',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'flags.ghost',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'flags.pinned',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'mode',
newValue: 4
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'color',
newValue: '#123456'
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'bgcolor',
newValue: '#abcdef'
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'shape',
newValue: 2
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'showAdvanced',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'badges',
newValue: [{ text: 'hot' }]
})
expect(vueNodeData.get(node.id)).toMatchObject({
title: 'Renamed',
hasErrors: true,
flags: {
collapsed: true,
ghost: true,
pinned: true
},
mode: 4,
color: '#123456',
bgcolor: '#abcdef',
shape: 2,
showAdvanced: true,
badges: [{ text: 'hot' }]
})
})
it('normalizes invalid property payloads to safe Vue node data', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'mode',
newValue: 'invalid'
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'color',
newValue: false
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'bgcolor',
newValue: 123
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'shape',
newValue: 'round'
})
expect(vueNodeData.get(node.id)).toMatchObject({
mode: 0,
color: undefined,
bgcolor: undefined,
shape: undefined
})
})
it('ignores property events for nodes the manager does not track', () => {
const graph = new LGraph()
useGraphNodeManager(graph)
expect(() =>
graph.trigger('node:property:changed', {
nodeId: 'missing',
property: 'title',
newValue: 'ignored'
})
).not.toThrow()
})
it('ignores non-input slot link events and refreshes slot error metadata', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined)
const input = node.addInput('prompt', 'STRING')
input.widget = { name: 'prompt' }
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(node.id)
?.widgets?.find((w) => w.name === 'prompt')
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.OUTPUT
})
expect(widgetData?.slotMetadata?.linked).toBe(false)
const linkId: unknown = 123
input.link = linkId as typeof input.link
graph.trigger('node:slot-errors:changed', {
nodeId: node.id
})
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
})
describe('extractVueNodeData widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('normalizes widget callback values and redraws sibling widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const callback = vi.fn()
const siblingTriggerDraw = vi.fn()
node.addWidget('string', 'prompt', 'hello', callback)
node.addCustomWidget(
fromPartial<IBaseWidget>({
name: 'sibling',
type: 'text',
value: '',
options: {},
triggerDraw: siblingTriggerDraw
})
)
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(node.id)
?.widgets?.find((widget) => widget.name === 'prompt')
if (!widgetData?.callback) throw new Error('Missing widget callback')
widgetData.callback(null)
expect(node.widgets![0].value).toBeUndefined()
widgetData.callback('text')
expect(node.widgets![0].value).toBe('text')
widgetData.callback(3)
expect(node.widgets![0].value).toBe(3)
widgetData.callback(true)
expect(node.widgets![0].value).toBe(true)
const objectValue = { nested: true }
widgetData.callback(objectValue)
expect(node.widgets![0].value).toStrictEqual(objectValue)
const fileValues = [new File(['x'], 'x.txt')]
widgetData.callback(fileValues)
expect(node.widgets![0].value).toHaveLength(1)
expect((node.widgets![0].value as File[])[0]).toBeInstanceOf(File)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
widgetData.callback(Symbol('invalid'))
expect(node.widgets![0].value).toBeUndefined()
expect(callback).toHaveBeenLastCalledWith(undefined, app.canvas, node)
expect(siblingTriggerDraw).toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith(
'Invalid widget value type: symbol',
expect.any(Symbol)
)
warnSpy.mockRestore()
})
it('extracts display, DOM, layout, tooltip, and duplicate widget metadata', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addCustomWidget({
name: 'plain',
type: 'text',
value: 'a'
} as IBaseWidget)
const domWidget: unknown = {
name: 'plain',
type: 'text',
value: 'b',
advanced: true,
element: document.createElement('input'),
computeLayoutSize: () => ({ minWidth: 1, minHeight: 1 }),
options: {
canvasOnly: true,
hidden: true,
read_only: true
},
tooltip: 'Details'
}
node.addCustomWidget(domWidget as IBaseWidget)
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgets = vueNodeData.get(node.id)?.widgets
expect(widgets?.[0]?.options).toBeUndefined()
expect(widgets?.[1]).toMatchObject({
name: 'plain',
type: 'text',
hasLayoutSize: true,
isDOMWidget: true,
tooltip: 'Details',
options: {
canvasOnly: true,
advanced: true,
hidden: true,
read_only: true
}
})
expect(widgets?.[0]?.widgetId).toBeDefined()
expect(widgets?.[1]?.widgetId).toBeDefined()
})
it('falls back to safe widget data when a widget mapper throws', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = new LGraphNode('test')
const badWidgetObj: unknown = {
name: 'broken',
type: 'custom',
value: 'x',
get options() {
throw new Error('bad options')
}
}
node.widgets = [badWidgetObj as IBaseWidget]
const data = extractVueNodeData(node)
expect(data.widgets?.[0]).toEqual({ name: 'broken', type: 'custom' })
expect(warnSpy).toHaveBeenCalledWith(
'[safeWidgetMapper] Failed to map widget:',
'broken',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('falls back to unknown widget data when a broken widget has no name or type', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = new LGraphNode('test')
const badWidgetObj2: unknown = {
value: 'x',
get options() {
throw new Error('bad options')
}
}
node.widgets = [badWidgetObj2 as IBaseWidget]
const data = extractVueNodeData(node)
expect(data.widgets?.[0]).toEqual({ name: 'unknown', type: 'text' })
warnSpy.mockRestore()
})
it('keeps custom widgets getter results in sync', () => {
const node = new LGraphNode('test')
let widgets = [
{
name: 'first',
type: 'text',
value: 'one',
options: {}
} as IBaseWidget
]
Object.defineProperty(node, 'widgets', {
get() {
return widgets
},
configurable: true
})
const data = extractVueNodeData(node)
expect(data.widgets?.map((widget) => widget.name)).toEqual(['first'])
widgets = [
{
name: 'second',
type: 'text',
value: 'two',
options: {}
} as IBaseWidget
]
expect(node.widgets?.map((widget) => widget.name)).toEqual(['second'])
expect(data.widgets?.map((widget) => widget.name)).toEqual(['second'])
})
it('treats undefined custom widget getter results as an empty widget list', () => {
const node = new LGraphNode('test')
Object.defineProperty(node, 'widgets', {
get() {
return undefined
},
configurable: true
})
const data = extractVueNodeData(node)
expect(data.widgets?.length).toBe(0)
})
it('derives node type fallbacks and subgraph id from graph context', () => {
const node = new LGraphNode('')
node.type = ''
Object.defineProperty(node, 'constructor', {
value: { title: 'FallbackTitle', nodeData: { api_node: true } },
configurable: true
})
node.graph = {
id: 'subgraph-id',
rootGraph: new LGraph()
} as LGraph
const data = extractVueNodeData(node)
expect(data.type).toBe('FallbackTitle')
expect(data.subgraphId).toBe('subgraph-id')
expect(data.apiNode).toBe(true)
})
it('preserves flags when extracting Vue node data', () => {
const node = new LGraphNode('test')
node.flags = { collapsed: true, pinned: true }
const data = extractVueNodeData(node)
expect(data.flags).toEqual({ collapsed: true, pinned: true })
})
it('keeps existing promoted widget state when mapping host widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'source.safetensors',
() => undefined,
{
values: ['source.safetensors']
}
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const id = subgraphNode.inputs[0].widgetId
if (!id) throw new Error('Expected promoted input to have widgetId')
const widgetStore = useWidgetValueStore()
if (widgetStore.getWidget(id)) {
widgetStore.setValue(id, 'existing.safetensors')
} else {
widgetStore.registerWidget(id, {
type: 'combo',
value: 'existing.safetensors',
options: {},
label: 'Existing'
})
}
useGraphNodeManager(graph)
expect(widgetStore.getWidget(id)?.value).toBe('existing.safetensors')
})
})
describe('Graph node manager lifecycle hooks', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('defers layout extraction until graph configuration completes', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.title = 'Before'
const originalOnNodeAdded = vi.fn()
const originalAfterConfigured = vi.fn()
graph.onNodeAdded = originalOnNodeAdded
node.onAfterGraphConfigured = originalAfterConfigured
const originalWindowApp = window.app
window.app = { configuringGraph: true } as Window['app']
try {
const { vueNodeData } = useGraphNodeManager(graph)
graph.add(node)
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
expect(vueNodeData.get(node.id)?.title).toBe('Before')
node.title = 'After'
node.onAfterGraphConfigured?.()
expect(originalAfterConfigured).toHaveBeenCalled()
expect(vueNodeData.get(node.id)?.title).toBe('After')
} finally {
window.app = originalWindowApp
}
})
it('chains original remove and trigger handlers, then restores them on cleanup', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnTrigger = vi.fn()
graph.onNodeAdded = originalOnNodeAdded
graph.onNodeRemoved = originalOnNodeRemoved
graph.onTrigger = originalOnTrigger
const manager = useGraphNodeManager(graph)
graph.add(node)
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'title',
newValue: 'Renamed'
})
graph.remove(node)
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
expect(originalOnTrigger).toHaveBeenCalledWith(
expect.objectContaining({ type: 'node:property:changed' })
)
expect(originalOnNodeRemoved).toHaveBeenCalledWith(node)
expect(manager.vueNodeData.size).toBe(0)
manager.cleanup()
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
expect(graph.onNodeRemoved).toBe(originalOnNodeRemoved)
expect(graph.onTrigger).toBe(originalOnTrigger)
})
it('cleans up to undefined when no original callbacks existed', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const manager = useGraphNodeManager(graph)
expect(manager.vueNodeData.has(node.id)).toBe(true)
manager.cleanup()
expect(graph.onNodeAdded).toBeUndefined()
expect(graph.onNodeRemoved).toBeUndefined()
expect(graph.onTrigger).toBeUndefined()
expect(manager.vueNodeData.size).toBe(0)
})
})
describe('getControlWidget', () => {
it('normalizes linked control widget values and updates the source widget', () => {
const linkedControl = {
[IS_CONTROL_WIDGET]: true,
value: 'fixed'
}
const widgetObj: unknown = { linkedWidgets: [linkedControl] }
const widget = widgetObj as IBaseWidget
const control = getControlWidget(widget)
expect(control?.value).toBe('fixed')
control?.update('unexpected')
expect(linkedControl.value).toBe('randomize')
})
})

View File

@@ -1,223 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
vi.hoisted(() => ({
canvas: { setDirty: vi.fn() },
captureCanvasState: vi.fn(),
isLightTheme: { value: false },
refreshCanvas: vi.fn(),
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
string,
unknown
>
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (k: string) => settings[k] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas })
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [{ value: 1, localizedName: 'Box' }],
colorOptions: [
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
],
isLightTheme
})
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function withI18n<T>(fn: () => T): T {
let result!: T
const app = createApp(
defineComponent({
setup() {
result = fn()
return () => null
}
})
)
app.use(i18n)
app.mount(document.createElement('div'))
return result
}
function setup() {
return withI18n(() => useGroupMenuOptions())
}
function group(over: Record<string, unknown> = {}): LGraphGroup {
return fromPartial<LGraphGroup>({
recomputeInsideNodes: vi.fn(),
resizeTo: vi.fn(),
children: [],
graph: { change: vi.fn() },
nodes: [],
...over
})
}
beforeEach(() => {
canvas.setDirty.mockReset()
captureCanvasState.mockReset()
isLightTheme.value = false
refreshCanvas.mockReset()
})
describe('useGroupMenuOptions', () => {
it('fits a group to its nodes, resizing with the configured padding', () => {
const g = group()
setup().getFitGroupToNodesOption(g).action?.()
expect(g.recomputeInsideNodes).toHaveBeenCalled()
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('aborts the fit action when recompute throws', () => {
const g = group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
})
setup().getFitGroupToNodesOption(g).action?.()
expect(g.resizeTo).not.toHaveBeenCalled()
})
it('applies a shape to all group nodes via the shape submenu', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const option = setup().getGroupShapeOptions(group({ nodes: [node] }), bump)
option.submenu?.[0].action?.()
expect(node.shape).toBe(1)
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('handles shape actions when a group has no nodes array', () => {
const bump = vi.fn()
setup()
.getGroupShapeOptions(group({ nodes: undefined }), bump)
.submenu?.[0].action?.()
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('applies a color to the group via the color submenu (dark theme)', () => {
const g = group()
const bump = vi.fn()
setup().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect(g.color).toBe('#111')
expect(bump).toHaveBeenCalled()
})
it('applies a light-theme color to the group via the color submenu', () => {
const g = group()
const bump = vi.fn()
isLightTheme.value = true
setup().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect(g.color).toBe('#eee')
expect(bump).toHaveBeenCalled()
})
it('returns no mode options for an empty group', () => {
expect(setup().getGroupModeOptions(group(), vi.fn())).toEqual([])
})
it('returns no mode options when a group has no nodes array', () => {
expect(
setup().getGroupModeOptions(group({ nodes: undefined }), vi.fn())
).toEqual([])
})
it('returns no mode options when recomputing group nodes fails', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const options = setup().getGroupModeOptions(
group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
}),
vi.fn()
)
expect(options).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Failed to recompute nodes in group for mode options:',
expect.any(Error)
)
})
it('builds mode options for uniform nodes and applies the new mode', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const options = setup().getGroupModeOptions(group({ nodes: [node] }), bump)
expect(options.length).toBeGreaterThan(0)
options[0].action?.()
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(bump).toHaveBeenCalled()
})
it('offers two alternate modes when all nodes are NEVER', () => {
const options = setup().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers two alternate modes when all nodes are BYPASS', () => {
const options = setup().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers all three modes when nodes have mixed modes', () => {
const options = setup().getGroupModeOptions(
group({
nodes: [
{ mode: LGraphEventMode.ALWAYS },
{ mode: LGraphEventMode.NEVER }
]
}),
vi.fn()
)
expect(options).toHaveLength(3)
})
it('offers all three modes when the uniform mode is unknown', () => {
const options = setup().getGroupModeOptions(
group({ nodes: [{ mode: 999 }] }),
vi.fn()
)
expect(options).toHaveLength(3)
})
})

View File

@@ -1,55 +1,24 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import enMessages from '@/locales/en/main.json'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function withI18n<T>(fn: () => T): T {
let result!: T
const app = createApp(
defineComponent({
setup() {
result = fn()
return () => null
}
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useI18n: () => ({
t: (key: string) => key.split('.').pop() ?? key
})
)
app.use(i18n)
app.mount(document.createElement('div'))
return result
}
function setup() {
return withI18n(() => useImageMenuOptions())
}
function mockGetContext(
ctx: Partial<CanvasRenderingContext2D>
): HTMLCanvasElement['getContext'] {
const fn: unknown = () => fromPartial<CanvasRenderingContext2D>(ctx)
return fn as HTMLCanvasElement['getContext']
}
}
})
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
Object.defineProperty(navigator, 'clipboard', {
value: clipboard,
@@ -58,15 +27,6 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
})
}
function stubClipboardItem() {
vi.stubGlobal(
'ClipboardItem',
class ClipboardItemStub {
constructor(public readonly items: Record<string, Blob>) {}
}
)
}
function createImageNode(
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
): LGraphNode {
@@ -85,19 +45,14 @@ function createImageNode(
}
describe('useImageMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('getImageMenuOptions', () => {
it('includes Paste Image option when node supports paste', () => {
const node = createImageNode()
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const labels = options.map((o) => o.label)
@@ -106,7 +61,7 @@ describe('useImageMenuOptions', () => {
it('excludes Paste Image option when node does not support paste', () => {
const node = createImageNode({ pasteFiles: undefined })
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const labels = options.map((o) => o.label)
@@ -115,24 +70,18 @@ describe('useImageMenuOptions', () => {
it('returns empty array when node has no images and no pasteFiles', () => {
const node = createMockLGraphNode({ imgs: [] })
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
expect(getImageMenuOptions(node)).toEqual([])
})
it('returns empty array when node image capabilities are absent', () => {
const { getImageMenuOptions } = setup()
expect(getImageMenuOptions(fromPartial<LGraphNode>({}))).toEqual([])
})
it('returns only Paste Image when node has no images but supports paste', () => {
const node = createMockLGraphNode({
imgs: [],
pasteFile: vi.fn(),
pasteFiles: vi.fn()
})
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const labels = options.map((o) => o.label)
@@ -141,7 +90,7 @@ describe('useImageMenuOptions', () => {
it('places Paste Image between Copy Image and Save Image', () => {
const node = createImageNode()
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const labels = options.map((o) => o.label)
@@ -155,7 +104,7 @@ describe('useImageMenuOptions', () => {
it('gives the Open in Mask Editor option the mask icon', () => {
const node = createImageNode()
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const maskOption = options.find((o) => o.label === 'Open in Mask Editor')
@@ -164,7 +113,7 @@ describe('useImageMenuOptions', () => {
it('gives every image action option an icon so labels stay aligned', () => {
const node = createImageNode()
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
expect(options.every((o) => !!o.icon)).toBe(true)
@@ -186,7 +135,7 @@ describe('useImageMenuOptions', () => {
})
)
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const pasteOption = options.find((o) => o.label === 'Paste Image')
@@ -203,7 +152,7 @@ describe('useImageMenuOptions', () => {
const node = createImageNode()
mockClipboard(fromPartial<Clipboard>({ read: undefined }))
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const pasteOption = options.find((o) => o.label === 'Paste Image')
@@ -224,7 +173,7 @@ describe('useImageMenuOptions', () => {
})
)
const { getImageMenuOptions } = setup()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const pasteOption = options.find((o) => o.label === 'Paste Image')
@@ -233,213 +182,4 @@ describe('useImageMenuOptions', () => {
expect(node.pasteFiles).not.toHaveBeenCalled()
})
})
describe('image actions', () => {
it('opens the selected image without preview query params', () => {
const node = createImageNode()
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
const { getImageMenuOptions } = setup()
const openOption = getImageMenuOptions(node).find(
(o) => o.label === 'Open Image'
)
openOption?.action?.()
expect(openFileInNewTab).toHaveBeenCalledWith(
'http://localhost/test.png?foo=bar'
)
})
it('saves the selected image without preview query params', () => {
const node = createImageNode()
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
const { getImageMenuOptions } = setup()
const saveOption = getImageMenuOptions(node).find(
(o) => o.label === 'Save Image'
)
saveOption?.action?.()
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost/test.png?foo=bar'
)
})
it('does not open or save when the active image is missing', () => {
const node = createImageNode({ imageIndex: 1 })
const { getImageMenuOptions } = setup()
const options = getImageMenuOptions(node)
options.find((o) => o.label === 'Open Image')?.action?.()
options.find((o) => o.label === 'Save Image')?.action?.()
expect(openFileInNewTab).not.toHaveBeenCalled()
expect(downloadFile).not.toHaveBeenCalled()
})
it('does not run image actions when images are cleared after menu creation', async () => {
const node = createImageNode()
const { getImageMenuOptions } = setup()
const options = getImageMenuOptions(node)
node.imgs = []
options.find((o) => o.label === 'Open Image')?.action?.()
await options.find((o) => o.label === 'Copy Image')?.action?.()
options.find((o) => o.label === 'Save Image')?.action?.()
expect(openFileInNewTab).not.toHaveBeenCalled()
expect(downloadFile).not.toHaveBeenCalled()
})
it('does not copy when the active image is missing', async () => {
const node = createImageNode({ imageIndex: 1 })
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
const { getImageMenuOptions } = setup()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(write).not.toHaveBeenCalled()
})
it('logs save failures for invalid image URLs', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = createImageNode()
Object.defineProperty(node.imgs![0], 'src', {
value: 'http://[',
configurable: true
})
const { getImageMenuOptions } = setup()
getImageMenuOptions(node)
.find((o) => o.label === 'Save Image')
?.action?.()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to save image:',
expect.any(TypeError)
)
expect(downloadFile).not.toHaveBeenCalled()
})
it('copies the selected image to clipboard', async () => {
const node = createImageNode()
const drawImage = vi.fn()
const write = vi.fn().mockResolvedValue(undefined)
stubClipboardItem()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
mockGetContext({ drawImage })
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = setup()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
expect(write).toHaveBeenCalledWith([
expect.objectContaining({
items: { 'image/png': expect.any(Blob) }
})
])
})
it('does not copy when canvas context is unavailable', async () => {
const node = createImageNode()
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() => null) as HTMLCanvasElement['getContext']
)
const { getImageMenuOptions } = setup()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(write).not.toHaveBeenCalled()
})
it('does not copy when canvas blob creation fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = createImageNode()
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
mockGetContext({ drawImage: vi.fn() })
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(null)
}
)
const { getImageMenuOptions } = setup()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
expect(write).not.toHaveBeenCalled()
})
it('does not copy when clipboard write is unavailable', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = createImageNode()
mockClipboard(fromPartial<Clipboard>({ write: undefined }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
mockGetContext({ drawImage: vi.fn() })
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = setup()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(warnSpy).toHaveBeenCalledWith('Clipboard API not available')
})
it('logs clipboard copy failures', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = createImageNode()
stubClipboardItem()
mockClipboard(
fromPartial<Clipboard>({
write: vi.fn().mockRejectedValue(new Error('blocked'))
})
)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
mockGetContext({ drawImage: vi.fn() })
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = setup()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to copy image to clipboard:',
expect.any(Error)
)
})
})
})

View File

@@ -1,292 +0,0 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import {
isNodeOptionsOpen,
registerNodeOptionsInstance,
showNodeOptions,
toggleNodeOptions,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
const {
canvasState,
extraWidgetOptions,
imageOptions,
nodeMenu,
selectionMenu,
selectionState
} = vi.hoisted(() => ({
canvasState: {
canvas: undefined as
| undefined
| {
getNodeMenuOptions: ReturnType<typeof vi.fn>
}
},
extraWidgetOptions: {
value: [] as Array<{ content: string; callback?: () => void }>
},
imageOptions: {
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
},
nodeMenu: {
visualOptions: {
value: [] as Array<{
label: string
hasSubmenu?: boolean
submenu?: Array<{ label: string; action: () => void }>
}>
}
},
selectionMenu: {
basicOptions: { value: [{ label: 'Copy' }] },
multipleOptions: { value: [{ label: 'Align' }] },
subgraphOptions: { value: [] as Array<{ label: string }> }
},
selectionState: {
selectedItems: { value: [] as unknown[] },
selectedNodes: { value: [] as unknown[] },
canOpenNodeInfo: { value: false },
openNodeInfo: vi.fn(() => true),
hasSubgraphs: { value: false },
hasImageNode: { value: false },
hasOutputNodesSelected: { value: false },
hasMultipleSelection: { value: false },
computeSelectionFlags: vi.fn(() => ({
collapsed: false,
pinned: false
}))
}
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => selectionState
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasState
}))
vi.mock('@/services/litegraphService', () => ({
getExtraOptionsForWidget: () => extraWidgetOptions.value
}))
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
useImageMenuOptions: () => ({
getImageMenuOptions: () => imageOptions.value
})
}))
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
useNodeMenuOptions: () => ({
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
label: 'Node Info',
action: openNodeInfo
}),
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
getPinOption: () => ({ label: 'Pin' }),
getBypassOption: () => ({ label: 'Bypass' }),
getRunBranchOption: () => ({ label: 'Run Branch' })
})
}))
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
useGroupMenuOptions: () => ({
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
getGroupColorOptions: () => ({ label: 'Group Color' }),
getGroupModeOptions: () => [{ label: 'Group Mode' }]
})
}))
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
useSelectionMenuOptions: () => ({
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
})
}))
beforeEach(() => {
vi.clearAllMocks()
registerNodeOptionsInstance(null)
canvasState.canvas = undefined
extraWidgetOptions.value = []
imageOptions.value = []
nodeMenu.visualOptions.value = []
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
selectionMenu.subgraphOptions.value = []
selectionState.selectedItems.value = []
selectionState.selectedNodes.value = []
selectionState.canOpenNodeInfo.value = false
selectionState.hasSubgraphs.value = false
selectionState.hasImageNode.value = false
selectionState.hasOutputNodesSelected.value = false
selectionState.hasMultipleSelection.value = false
selectionState.computeSelectionFlags.mockReturnValue({
collapsed: false,
pinned: false
})
})
function labels() {
return useMoreOptionsMenu()
.menuOptions.value.map((o) => o.label)
.filter(Boolean)
}
describe('node options popover instance', () => {
it('reports closed when no instance is registered', () => {
expect(isNodeOptionsOpen()).toBe(false)
})
it('reflects the registered instance open state and forwards toggle/show', () => {
const toggle = vi.fn()
const show = vi.fn()
registerNodeOptionsInstance({
toggle,
show,
hide: vi.fn(),
isOpen: ref(true)
})
expect(isNodeOptionsOpen()).toBe(true)
toggleNodeOptions(new Event('click'))
showNodeOptions(new MouseEvent('contextmenu'))
expect(toggle).toHaveBeenCalled()
expect(show).toHaveBeenCalled()
})
})
describe('useMoreOptionsMenu', () => {
it('assembles a non-empty menu for a single selected node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
expect(labels()).toContain('Copy')
expect(labels()).toContain('Pin')
})
it('includes run-branch and multiple-node options for output selections', () => {
const nodes = [
{ id: 1, widgets: [] },
{ id: 2, widgets: [] }
]
selectionState.selectedItems.value = nodes
selectionState.selectedNodes.value = nodes
selectionState.hasOutputNodesSelected.value = true
selectionState.hasMultipleSelection.value = true
const menuLabels = labels()
expect(menuLabels).toContain('Run Branch')
expect(menuLabels).toContain('Align')
})
it('recomputes menu flags after a manual bump', () => {
const { bump, menuOptions } = useMoreOptionsMenu()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
bump()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
})
it('assembles group-context options for a single selected group', () => {
const group = new LGraphGroup('Group')
selectionState.selectedItems.value = [group]
selectionState.selectedNodes.value = []
const menuLabels = labels()
expect(menuLabels).toContain('Group Mode')
expect(menuLabels).toContain('Fit')
expect(menuLabels).toContain('Group Color')
})
it('includes node info and visual options for a single node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.canOpenNodeInfo.value = true
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{ label: 'Shape', hasSubmenu: true, submenu: [] },
{ label: 'Color', hasSubmenu: true, submenu: [] }
]
const menu = useMoreOptionsMenu().menuOptions.value
expect(menu.map((o) => o.label)).toEqual(
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
)
menu.find((o) => o.label === 'Node Info')?.action?.()
expect(selectionState.openNodeInfo).toHaveBeenCalled()
})
it('returns only entries that have populated submenus', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{
label: 'Shape',
hasSubmenu: true,
submenu: [{ label: 'Box', action: vi.fn() }]
},
{ label: 'Color', hasSubmenu: true }
]
expect(
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
).toEqual(['Shape'])
})
it('includes image menu options for a selected image node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.hasImageNode.value = true
imageOptions.value = [{ label: 'Open Image' }]
expect(labels()).toContain('Open Image')
})
it('merges LiteGraph menu options for a single selected node', () => {
const node = { id: 1, widgets: [] }
const getNodeMenuOptions = vi.fn(() => [
{ content: 'Extension Action', callback: vi.fn() }
])
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = { getNodeMenuOptions }
expect(labels()).toContain('Extension Action')
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
})
it('keeps Vue options when LiteGraph menu construction throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = {
getNodeMenuOptions: vi.fn(() => {
throw new Error('boom')
})
}
expect(labels()).toContain('Copy')
expect(errorSpy).toHaveBeenCalledWith(
'Error getting LiteGraph menu items:',
expect.any(Error)
)
})
it('adds hovered widget options to the selected node menu', () => {
const node = { id: 1, widgets: [{ name: 'image' }] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
showNodeOptions(new MouseEvent('contextmenu'), 'image')
expect(labels()).toContain('Widget Extra')
})
})

View File

@@ -1,198 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
selection: { items: [] as unknown[] },
refreshCanvas: vi.fn(),
palette: { light_theme: false }
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get selectedItems() {
return selection.items
}
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
get completedActivePalette() {
return { light_theme: palette.light_theme }
}
})
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function withI18n<T>(fn: () => T): T {
let result!: T
const app = createApp(
defineComponent({
setup() {
result = fn()
return () => null
}
})
)
app.use(i18n)
app.mount(document.createElement('div'))
return result
}
function colorable(bgcolor?: string) {
return {
setColorOption: vi.fn(),
getColorOption: () => (bgcolor ? { bgcolor } : null)
}
}
beforeEach(() => {
selection.items = []
refreshCanvas.mockReset()
palette.light_theme = false
})
describe('useNodeCustomization', () => {
it('exposes color and shape option lists', () => {
const { colorOptions, shapeOptions } = withI18n(() =>
useNodeCustomization()
)
expect(colorOptions.length).toBeGreaterThan(1)
expect(shapeOptions.length).toBeGreaterThan(0)
})
it('reflects the active palette light-theme flag', () => {
palette.light_theme = true
expect(withI18n(() => useNodeCustomization()).isLightTheme.value).toBe(true)
})
it('clears color on all colorable items for the no-color option', () => {
const item = colorable()
selection.items = [item]
withI18n(() => useNodeCustomization()).applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('applies a named color option to colorable items', () => {
const item = colorable()
selection.items = [item]
const { colorOptions, applyColor } = withI18n(() => useNodeCustomization())
const named = colorOptions.at(-1)!
applyColor(named)
expect(item.setColorOption).toHaveBeenCalledTimes(1)
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
})
it('skips non-colorable items when applying colors', () => {
const item = colorable()
selection.items = [{}, item]
withI18n(() => useNodeCustomization()).applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('returns null current color for an empty selection', () => {
expect(withI18n(() => useNodeCustomization()).getCurrentColor()).toBeNull()
})
it('returns null current color when no selected item is colorable', () => {
selection.items = [{}]
expect(withI18n(() => useNodeCustomization()).getCurrentColor()).toBeNull()
})
it('reports a recognized current color', () => {
const { colorOptions, getCurrentColor } = withI18n(() =>
useNodeCustomization()
)
const named = colorOptions.at(-1)!
selection.items = [colorable(named.value.dark)]
expect(getCurrentColor()?.name).toBe(named.name)
})
it('falls back to the no-color option for an unrecognized current color', () => {
selection.items = [colorable('#not-a-known-color')]
const result = withI18n(() => useNodeCustomization()).getCurrentColor()
expect(result?.name).toBe('noColor')
})
it('no-ops shape changes when no graph nodes are selected', () => {
selection.items = [colorable()]
const { applyShape, shapeOptions } = withI18n(() => useNodeCustomization())
applyShape(shapeOptions[0])
expect(refreshCanvas).not.toHaveBeenCalled()
})
it('returns null current shape with no nodes selected', () => {
expect(withI18n(() => useNodeCustomization()).getCurrentShape()).toBeNull()
})
it('applies a shape to selected graph nodes and refreshes', () => {
const node = new LGraphNode('Test')
selection.items = [node]
const { applyShape, shapeOptions } = withI18n(() => useNodeCustomization())
const target = shapeOptions[0]
applyShape(target)
expect(node.shape).toBe(target.value)
expect(refreshCanvas).toHaveBeenCalled()
})
it('reports the current shape of a selected node', () => {
const node = new LGraphNode('Test')
const { shapeOptions, getCurrentShape } = withI18n(() =>
useNodeCustomization()
)
node.shape = shapeOptions[0].value
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('uses the default shape when a selected node has no shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: undefined,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = withI18n(() =>
useNodeCustomization()
)
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('falls back to the default shape for an unknown node shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: 999,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = withI18n(() =>
useNodeCustomization()
)
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
})

View File

@@ -1,221 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
const {
canvas,
toastAdd,
captureCanvasState,
updateSelectedItems,
prompt,
titleEditor,
store
} = vi.hoisted(() => ({
canvas: {
selectedItems: new Set<unknown>(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
deleteSelected: vi.fn(),
setDirty: vi.fn()
},
toastAdd: vi.fn(),
captureCanvasState: vi.fn(),
updateSelectedItems: vi.fn(),
prompt: vi.fn(),
titleEditor: { titleEditorTarget: null as unknown },
store: { selectedItems: [] as unknown[] }
}))
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
updateSelectedItems,
get selectedItems() {
return store.selectedItems
}
}),
useTitleEditorStore: () => titleEditor
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ prompt })
}))
beforeEach(() => {
canvas.selectedItems = new Set()
canvas.copyToClipboard.mockReset()
canvas.pasteFromClipboard.mockReset()
canvas.deleteSelected.mockReset()
canvas.setDirty.mockReset()
toastAdd.mockReset()
captureCanvasState.mockReset()
updateSelectedItems.mockReset()
prompt.mockReset()
titleEditor.titleEditorTarget = null
store.selectedItems = []
})
describe('useSelectionOperations', () => {
it('warns and does nothing when copying an empty selection', () => {
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('copies a non-empty selection and reports success', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('pastes from clipboard and captures canvas state', () => {
useSelectionOperations().pasteSelection()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('duplicates by copy, clear, paste', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(canvas.selectedItems.size).toBe(0)
expect(updateSelectedItems).toHaveBeenCalled()
expect(canvas.pasteFromClipboard).toHaveBeenCalled()
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when duplicating nothing', () => {
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('deletes a non-empty selection and marks the canvas dirty', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when deleting nothing', () => {
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('routes a single node rename to the title editor', async () => {
const node = new LGraphNode('Test')
store.selectedItems = [node]
await useSelectionOperations().renameSelection()
expect(titleEditor.titleEditorTarget).toBe(node)
expect(prompt).not.toHaveBeenCalled()
})
it('renames a single non-node item via the prompt dialog', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('New')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('Old')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('Old')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('does not assign a title to a selected item without a title property', async () => {
const item = {}
store.selectedItems = [item]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(item).toEqual({})
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('batch-renames multiple items with an indexed base name', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b.title).toBe('Item 2')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('skips untitled items during batch rename', async () => {
const a = { title: 'a' }
const b = {}
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b).toEqual({})
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('a')
expect(b.title).toBe('b')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('warns when renaming an empty selection', async () => {
await useSelectionOperations().renameSelection()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
})

View File

@@ -8,12 +8,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
@@ -22,9 +17,7 @@ import {
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn(),
isLGraphGroup: vi.fn(),
isLoad3dNode: vi.fn()
isImageNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
@@ -103,14 +96,6 @@ describe('useSelectionState', () => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
const typedItem = item as { isGroup?: boolean }
return typedItem?.isGroup === true
})
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return typedNode?.type === 'Load3D'
})
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
nodes.filter((n) => n.type === 'OutputNode')
)
@@ -150,21 +135,6 @@ describe('useSelectionState', () => {
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 2 })
const group = createMockPositionable({ id: 2000 })
Object.assign(group, {
isGroup: true,
isNode: false,
children: new Set([graphNode])
})
canvasStore.$state.selectedItems = [group]
const { hasGroupedNodesSelection } = useSelectionState()
expect(hasGroupedNodesSelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
@@ -245,13 +215,6 @@ describe('useSelectionState', () => {
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
test('should compute default flags for an empty node selection', () => {
expect(useSelectionState().computeSelectionFlags()).toEqual({
collapsed: false,
pinned: false
})
})
})
describe('Node Info', () => {

View File

@@ -1,342 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueUse from '@vueuse/core'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
type MockReroute = {
id: string
pos: [number, number]
parentId?: string | null
linkIds: Set<string>
}
type MockLink = {
id: string
origin_id: string
origin_slot: number
target_id: string
target_slot: number
}
type MockGraph = {
_nodes: LGraphNode[]
reroutes: Map<string, MockReroute>
_links: Map<string, MockLink>
onNodeAdded?: (node: LGraphNode) => void
}
type MockCanvas = {
graph?: MockGraph
setDirty: ReturnType<typeof vi.fn>
}
const mockWheneverCallbacks = vi.hoisted(() => ({
values: [] as Array<() => void>
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUse>()
return {
...actual,
createSharedComposable: <Args extends unknown[], Return>(
fn: (...args: Args) => Return
) => fn,
whenever: (_source: () => boolean, callback: () => void) => {
mockWheneverCallbacks.values.push(callback)
return vi.fn()
}
}
})
const mockUseGraphNodeManager = vi.hoisted(() => vi.fn())
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
useGraphNodeManager: mockUseGraphNodeManager
}))
const mockShouldRenderVueNodes = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: mockShouldRenderVueNodes
})
}))
const mockCanvasStoreCanvas = vi.hoisted(() => ({
value: undefined as MockCanvas | undefined
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: mockCanvasStoreCanvas.value
})
}))
const mockCreateReroute = vi.hoisted(() => vi.fn())
const mockCreateLink = vi.hoisted(() => vi.fn())
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: () => ({
createReroute: mockCreateReroute,
createLink: mockCreateLink
})
}))
const mockInitializeFromLiteGraph = vi.hoisted(() => vi.fn())
const mockClearAllSlotLayouts = vi.hoisted(() => vi.fn())
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
initializeFromLiteGraph: mockInitializeFromLiteGraph,
clearAllSlotLayouts: mockClearAllSlotLayouts
}
}))
const mockStartSync = vi.hoisted(() => vi.fn())
const mockStopSync = vi.hoisted(() => vi.fn())
vi.mock('@/renderer/core/layout/sync/useLayoutSync', () => ({
useLayoutSync: () => ({
startSync: mockStartSync,
stopSync: mockStopSync
})
}))
const mockComfyCanvas = vi.hoisted(() => ({
value: undefined as MockCanvas | undefined
}))
vi.mock('@/scripts/app', () => ({
app: {
get canvas() {
return mockComfyCanvas.value
}
}
}))
const mockManagerCleanup = vi.hoisted(() => vi.fn())
function createNode(id: number, overrides: object = {}): LGraphNode {
return createMockLGraphNode({
id: toNodeId(id),
pos: [id * 10, id * 20],
size: [100 + id, 200 + id],
flags: { collapsed: false },
arrange: vi.fn(),
...overrides
})
}
function createGraph(overrides: Partial<MockGraph> = {}): MockGraph {
return {
_nodes: [],
reroutes: new Map(),
_links: new Map(),
...overrides
}
}
async function loadLifecycle() {
vi.resetModules()
const { useVueNodeLifecycle } = await import('./useVueNodeLifecycle')
return useVueNodeLifecycle()
}
describe('useVueNodeLifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWheneverCallbacks.values = []
mockShouldRenderVueNodes.value = false
mockCanvasStoreCanvas.value = undefined
mockComfyCanvas.value = undefined
mockManagerCleanup.mockReset()
mockUseGraphNodeManager.mockReset()
mockUseGraphNodeManager.mockReturnValue({ cleanup: mockManagerCleanup })
})
it('initializes the node manager from the active graph', async () => {
const node = createNode(1)
const graph = createGraph({
_nodes: [node],
reroutes: new Map([
[
'reroute-1',
{
id: 'reroute-1',
pos: [12, 34],
parentId: null,
linkIds: new Set(['link-1'])
}
]
]),
_links: new Map([
[
'link-1',
{
id: 'link-1',
origin_id: toNodeId(1),
origin_slot: 0,
target_id: toNodeId(2),
target_slot: 1
}
],
[
'link-2',
{
id: 'link-2',
origin_id: UNASSIGNED_NODE_ID,
origin_slot: 0,
target_id: toNodeId(2),
target_slot: 1
}
],
[
'link-3',
{
id: 'link-3',
origin_id: toNodeId(1),
origin_slot: 0,
target_id: UNASSIGNED_NODE_ID,
target_slot: 1
}
]
])
})
const canvas = { graph, setDirty: vi.fn() }
mockComfyCanvas.value = canvas
mockCanvasStoreCanvas.value = canvas
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
expect(lifecycle.nodeManager.value).toEqual({
cleanup: mockManagerCleanup
})
expect(mockInitializeFromLiteGraph).toHaveBeenCalledWith([
{
id: toNodeId(1),
pos: [10, 20],
size: [101, 201]
}
])
expect(mockCreateReroute).toHaveBeenCalledWith(
'reroute-1',
{ x: 12, y: 34 },
undefined,
['link-1']
)
expect(mockCreateLink).toHaveBeenCalledOnce()
expect(mockCreateLink).toHaveBeenCalledWith(
'link-1',
toNodeId(1),
0,
toNodeId(2),
1
)
expect(mockStartSync).toHaveBeenCalledWith(canvas)
})
it('does not initialize without an active graph', async () => {
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
lifecycle.initializeNodeManager()
expect(mockUseGraphNodeManager).not.toHaveBeenCalled()
expect(mockStartSync).not.toHaveBeenCalled()
})
it('stops sync and tolerates manager cleanup errors', async () => {
mockManagerCleanup.mockImplementation(() => {
throw new Error('cleanup failed')
})
mockComfyCanvas.value = {
graph: createGraph(),
setDirty: vi.fn()
}
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
expect(() => lifecycle.disposeNodeManagerAndSyncs()).not.toThrow()
expect(mockStopSync).toHaveBeenCalled()
expect(lifecycle.nodeManager.value).toBeNull()
})
it('arranges legacy nodes when the Vue node mode is disabled', async () => {
const arrangeVisible = vi.fn()
const arrangeThrowing = vi.fn(() => {
throw new Error('not ready')
})
const graph = createGraph({
_nodes: [
createNode(1, { arrange: arrangeVisible }),
createNode(2, { flags: { collapsed: true }, arrange: vi.fn() }),
createNode(3, { arrange: arrangeThrowing })
]
})
const canvas = { graph, setDirty: vi.fn() }
mockComfyCanvas.value = canvas
mockShouldRenderVueNodes.value = true
await loadLifecycle()
mockWheneverCallbacks.values[0]()
expect(arrangeVisible).toHaveBeenCalled()
expect(arrangeThrowing).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('marks the canvas dirty when disabling without a graph', async () => {
const canvas = { setDirty: vi.fn() }
mockComfyCanvas.value = canvas
await loadLifecycle()
mockWheneverCallbacks.values[0]()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('initializes on the first node added to an empty graph', async () => {
mockShouldRenderVueNodes.value = true
const originalOnNodeAdded = vi.fn()
const graph = createGraph({ onNodeAdded: originalOnNodeAdded })
const canvas = { graph, setDirty: vi.fn() }
const node = createNode(1)
const lifecycle = await loadLifecycle()
mockComfyCanvas.value = canvas
lifecycle.setupEmptyGraphListener()
graph.onNodeAdded?.(node)
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
})
it('does not replace onNodeAdded when the empty-graph guard fails', async () => {
const originalOnNodeAdded = vi.fn()
const graph = createGraph({
_nodes: [createNode(1)],
onNodeAdded: originalOnNodeAdded
})
mockComfyCanvas.value = { graph, setDirty: vi.fn() }
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
lifecycle.setupEmptyGraphListener()
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
})
it('cleans up the node manager on unmount', async () => {
mockComfyCanvas.value = {
graph: createGraph(),
setDirty: vi.fn()
}
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
lifecycle.cleanup()
lifecycle.cleanup()
expect(mockManagerCleanup).toHaveBeenCalledOnce()
expect(lifecycle.nodeManager.value).toBeNull()
})
})

View File

@@ -23,8 +23,8 @@ const mockStore = reactive({
gpuTexturesNeedRecreation: false,
gpuTextureWidth: 0,
gpuTextureHeight: 0,
pendingGPUMaskData: null as Uint8Array | null,
pendingGPURgbData: null as Uint8Array | null,
pendingGPUMaskData: null as null,
pendingGPURgbData: null as null,
brushSettings: {
size: 20,
hardness: 0.9,
@@ -42,9 +42,6 @@ vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
import { tgpu } from 'typegpu'
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
import { resetDirtyRect } from './brushDrawingUtils'
import { useGPUResources } from './useGPUResources'
@@ -55,128 +52,8 @@ function setup() {
return scope.run(() => useGPUResources())!
}
class TestImageData {
data: Uint8ClampedArray
width: number
height: number
constructor(data: Uint8ClampedArray, width: number, height: number) {
this.data = data
this.width = width
this.height = height
}
}
function createMockTexture(): GPUTexture {
const obj: unknown = {
createView: vi.fn(() => ({})),
destroy: vi.fn()
}
return obj as GPUTexture
}
function createMockBuffer(byteLength = 16): GPUBuffer {
const obj: unknown = {
destroy: vi.fn(),
mapAsync: vi.fn().mockResolvedValue(undefined),
getMappedRange: vi.fn(() => new Uint8Array(byteLength).buffer),
unmap: vi.fn()
}
return obj as GPUBuffer
}
function createMockDevice(): GPUDevice {
const obj: unknown = {
limits: {},
queue: {
writeTexture: vi.fn(),
submit: vi.fn()
},
createTexture: vi.fn(() => createMockTexture()),
createBuffer: vi.fn(() => createMockBuffer()),
createCommandEncoder: vi.fn(() => ({
copyBufferToBuffer: vi.fn(),
finish: vi.fn(() => ({}))
}))
}
return obj as GPUDevice
}
function createMockRenderer() {
return {
destroy: vi.fn(),
prepareStroke: vi.fn(),
clearPreview: vi.fn(),
compositeStroke: vi.fn(),
prepareReadback: vi.fn(),
renderStrokeToAccumulator: vi.fn(),
blitToCanvas: vi.fn()
}
}
function mockGpuBrushRenderer(renderer: ReturnType<typeof createMockRenderer>) {
vi.mocked(GPUBrushRenderer).mockImplementation(
function GPUBrushRendererMock() {
const r: unknown = renderer
return r as GPUBrushRenderer
}
)
}
function createCanvasContext(
width: number,
height: number
): CanvasRenderingContext2D {
const obj: unknown = {
globalCompositeOperation: 'source-over',
getImageData: vi.fn(
() =>
new ImageData(new Uint8ClampedArray(width * height * 4), width, height)
),
putImageData: vi.fn()
}
return obj as CanvasRenderingContext2D
}
function setReadyCanvases(width = 2, height = 2) {
const maskCanvas = document.createElement('canvas')
maskCanvas.width = width
maskCanvas.height = height
const rgbCanvas = document.createElement('canvas')
rgbCanvas.width = width
rgbCanvas.height = height
mockStore.maskCanvas = maskCanvas
mockStore.rgbCanvas = rgbCanvas
mockStore.maskCtx = createCanvasContext(width, height)
mockStore.rgbCtx = createCanvasContext(width, height)
}
function installGpuGlobals() {
vi.stubGlobal('GPUTextureUsage', {
TEXTURE_BINDING: 1,
STORAGE_BINDING: 2,
RENDER_ATTACHMENT: 4,
COPY_DST: 8,
COPY_SRC: 16
})
vi.stubGlobal('GPUBufferUsage', {
STORAGE: 1,
COPY_SRC: 2,
COPY_DST: 4,
MAP_READ: 8
})
vi.stubGlobal('GPUMapMode', { READ: 1 })
vi.stubGlobal('ImageData', TestImageData)
Object.defineProperty(navigator, 'gpu', {
value: { getPreferredCanvasFormat: vi.fn(() => 'rgba8unorm') },
configurable: true
})
}
beforeEach(() => {
vi.clearAllMocks()
installGpuGlobals()
mockStore.tgpuRoot = null
mockStore.maskCanvas = null
mockStore.rgbCanvas = null
@@ -185,28 +62,11 @@ beforeEach(() => {
mockStore.clearTrigger = 0
mockStore.canvasHistory.currentStateIndex = 0
mockStore.gpuTexturesNeedRecreation = false
mockStore.gpuTextureWidth = 0
mockStore.gpuTextureHeight = 0
mockStore.pendingGPUMaskData = null
mockStore.pendingGPURgbData = null
mockStore.activeLayer = 'mask'
mockStore.currentTool = 'pen'
mockStore.maskColor = { r: 0, g: 0, b: 0 }
mockStore.rgbColor = '#FF0000'
mockStore.brushSettings = {
size: 20,
hardness: 0.9,
opacity: 1,
stepSize: 5,
type: 'arc'
}
vi.mocked(tgpu.init).mockRejectedValue(new Error('WebGPU not supported'))
})
afterEach(() => {
scope?.stop()
scope = null
vi.unstubAllGlobals()
})
describe('initial reactive state', () => {
@@ -271,34 +131,6 @@ describe('initGPUResources', () => {
await initGPUResources()
expect(hasRenderer.value).toBe(false)
})
it('handles non-error TypeGPU initialisation failures', async () => {
vi.mocked(tgpu.init).mockRejectedValueOnce('WebGPU unavailable')
const { initGPUResources, hasRenderer } = setup()
await initGPUResources()
expect(hasRenderer.value).toBe(false)
})
it('initializes renderer when a root and canvas contexts are ready', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
mockStore.tgpuRoot = {
device,
destroy: vi.fn()
}
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const { initGPUResources, hasRenderer } = setup()
await initGPUResources()
expect(hasRenderer.value).toBe(true)
expect(device.createTexture).toHaveBeenCalledTimes(2)
expect(device.queue.writeTexture).toHaveBeenCalledTimes(2)
expect(GPUBrushRenderer).toHaveBeenCalledWith(device, 'rgba8unorm')
})
})
describe('copyGpuToCanvas', () => {
@@ -342,18 +174,6 @@ describe('initGPUResources with pre-existing tgpuRoot', () => {
await initGPUResources()
expect(hasRenderer.value).toBe(false)
})
it('texture recreation watcher returns early when mask canvas is missing', async () => {
const device = createMockDevice()
const { initGPUResources } = setup()
mockStore.tgpuRoot = { device, destroy: vi.fn() }
await initGPUResources()
mockStore.gpuTexturesNeedRecreation = true
await nextTick()
expect(device.createTexture).not.toHaveBeenCalled()
})
})
describe('initPreviewCanvas', () => {
@@ -362,47 +182,6 @@ describe('initPreviewCanvas', () => {
const canvas = document.createElement('canvas')
expect(() => initPreviewCanvas(canvas)).not.toThrow()
})
it('returns early when a WebGPU canvas context is unavailable', async () => {
const device = createMockDevice()
mockStore.tgpuRoot = { device, destroy: vi.fn() }
setReadyCanvases()
mockGpuBrushRenderer(createMockRenderer())
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
await initGPUResources()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'getContext', { value: vi.fn(() => null) })
initPreviewCanvas(canvas)
expect(previewCanvas.value).toBeNull()
})
it('stores the preview canvas when a WebGPU context is available', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
const previewContext = { configure: vi.fn() }
mockStore.tgpuRoot = { device, destroy: vi.fn() }
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
await initGPUResources()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'getContext', {
value: vi.fn(() => previewContext)
})
initPreviewCanvas(canvas)
expect(previewContext.configure).toHaveBeenCalledWith({
device,
format: 'rgba8unorm',
alphaMode: 'premultiplied'
})
expect(previewCanvas.value).toBe(canvas)
})
})
describe('gpuDrawPoint', () => {
@@ -410,70 +189,4 @@ describe('gpuDrawPoint', () => {
const { gpuDrawPoint } = setup()
await expect(gpuDrawPoint({ x: 10, y: 20 })).resolves.toBeUndefined()
})
it('delegates renderer operations when GPU resources are initialized', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
const previewContext = { configure: vi.fn() }
mockStore.tgpuRoot = { device, destroy: vi.fn() }
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const resources = setup()
await resources.initGPUResources()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'getContext', {
value: vi.fn(() => previewContext)
})
resources.initPreviewCanvas(canvas)
resources.prepareStroke()
resources.clearPreview()
resources.compositeStroke(false, false)
resources.gpuRender([{ x: 1, y: 1 }])
await resources.gpuDrawPoint({ x: 1, y: 1 })
expect(renderer.prepareStroke).toHaveBeenCalledWith(2, 2)
expect(renderer.clearPreview).toHaveBeenCalledWith(previewContext)
expect(renderer.compositeStroke).toHaveBeenCalled()
expect(renderer.renderStrokeToAccumulator).toHaveBeenCalled()
expect(renderer.blitToCanvas).toHaveBeenCalled()
})
it('copies initialized GPU readback data to canvases', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
mockStore.tgpuRoot = { device, destroy: vi.fn() }
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const resources = setup()
await resources.initGPUResources()
const result = await resources.copyGpuToCanvas()
expect(result.maskData.width).toBe(2)
expect(result.rgbData.height).toBe(2)
expect(renderer.prepareReadback).toHaveBeenCalledTimes(2)
expect(mockStore.maskCtx?.putImageData).toHaveBeenCalled()
expect(mockStore.rgbCtx?.putImageData).toHaveBeenCalled()
})
it('destroys initialized GPU resources and root state', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
const root = { device, destroy: vi.fn() }
mockStore.tgpuRoot = root
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const { initGPUResources, destroy, hasRenderer } = setup()
await initGPUResources()
destroy()
expect(renderer.destroy).toHaveBeenCalled()
expect(root.destroy).toHaveBeenCalled()
expect(mockStore.tgpuRoot).toBeNull()
expect(hasRenderer.value).toBe(false)
})
})

View File

@@ -1,415 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
extractWidgetStringValue,
useMaskEditorLoader
} from '@/composables/maskeditor/useMaskEditorLoader'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
interface MockInputData {
baseLayer: { url: string }
maskLayer: { url: string }
paintLayer?: { url: string }
sourceRef: { filename: string; subfolder?: string; type?: string }
nodeId: unknown
}
const mockDataStore = vi.hoisted(() => ({
inputData: undefined as unknown,
sourceNode: undefined as unknown,
setLoading: vi.fn()
}))
const mockNodeOutputStore = vi.hoisted(() => ({
getNodeOutputs: vi.fn()
}))
const mockCloudState = vi.hoisted(() => ({
isCloud: false
}))
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: () => mockDataStore
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => mockNodeOutputStore
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockCloudState.isCloud
}
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `http://comfy.test${path}`),
fetchApi: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&preview=png'),
getRandParam: vi.fn(() => '&rand=1')
}
}))
function createMockImageClass(handler: 'onload' | 'onerror') {
return class {
crossOrigin = ''
onerror: (() => void) | null = null
onload: (() => void) | null = null
private imageSrc = ''
get src() {
return this.imageSrc
}
set src(value: string) {
this.imageSrc = value
queueMicrotask(() => this[handler]?.())
}
}
}
const MockImage = createMockImageClass('onload')
function makeNode(overrides: object = {}): LGraphNode {
const imgObj: unknown = {
src: 'http://images.test/render.png?filename=render.png'
}
const node: unknown = {
id: 42,
imgs: [imgObj as HTMLImageElement],
imageIndex: 0,
...overrides
}
return node as LGraphNode
}
function getInputData(): MockInputData {
return mockDataStore.inputData as MockInputData
}
describe('extractWidgetStringValue', () => {
it('extracts strings and filename objects', () => {
expect(extractWidgetStringValue('image.png')).toBe('image.png')
expect(extractWidgetStringValue({ filename: 'object.png' })).toBe(
'object.png'
)
expect(extractWidgetStringValue({ filename: 123 })).toBeUndefined()
expect(extractWidgetStringValue(null)).toBeUndefined()
})
})
describe('useMaskEditorLoader', () => {
beforeEach(() => {
vi.stubGlobal('Image', MockImage)
vi.spyOn(console, 'error').mockImplementation(() => undefined)
vi.mocked(api.apiURL).mockClear()
vi.mocked(api.fetchApi).mockReset()
mockDataStore.inputData = undefined
mockDataStore.sourceNode = undefined
mockDataStore.setLoading.mockClear()
mockNodeOutputStore.getNodeOutputs.mockReset()
mockCloudState.isCloud = false
})
it('loads base and mask layers from a node image reference', async () => {
const node = makeNode({
images: [
{
filename: 'node-output.png',
subfolder: 'outputs',
type: 'output'
}
]
})
await useMaskEditorLoader().loadFromNode(node)
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(false)
expect(mockDataStore.sourceNode).toBe(node)
expect(mockDataStore.inputData).toMatchObject({
nodeId: 42,
sourceRef: {
filename: 'node-output.png',
subfolder: 'outputs',
type: 'output'
},
paintLayer: undefined
})
expect(getInputData().baseLayer.url).toContain('channel=rgb')
expect(getInputData().maskLayer.url).toContain('channel=a')
})
it('uses a concrete image widget value instead of a stale node image', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'stale.png', type: 'output', subfolder: '' }],
widgets: [
{
name: 'image',
value: 'clipspace/current.png [input]'
}
]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'current.png',
subfolder: 'clipspace',
type: 'input'
})
expect(getInputData().baseLayer.url).toContain('filename=current.png')
})
it('keeps internal widget references from replacing the node image', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'real-output.png', type: 'output' }],
widgets: [{ name: 'image', value: '$35-0' }]
})
)
expect(getInputData().sourceRef.filename).toBe('real-output.png')
})
it('loads image references from node output store data', async () => {
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
images: [
{
filename: 'store-output.png',
subfolder: 'store',
type: 'temp'
}
]
})
await useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imgs: [{ src: 'data:image/png;base64,abc' }]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'store-output.png',
subfolder: 'store',
type: 'temp'
})
})
it('uses the current non-data preview image when no image reference exists', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imageIndex: 1,
imgs: [
{ src: 'http://images.test/first.png?filename=first.png' },
{ src: '/view?filename=second.png&type=input' }
]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'second.png',
type: 'input'
})
})
it('uses cloud mask layer metadata when available', async () => {
mockCloudState.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
ok: true,
json: vi.fn().mockResolvedValue({
painted_masked: 'painted-masked.png',
painted: 'painted.png',
paint: 'paint.png'
})
})
)
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'cloud.png', type: 'output' }]
})
)
expect(api.fetchApi).toHaveBeenCalledWith(
'/files/mask-layers?filename=cloud.png'
)
expect(getInputData().sourceRef.filename).toBe('painted-masked.png')
expect(getInputData().paintLayer?.url).toContain('filename=paint.png')
})
it('loads clipspace layer filenames from painted-masked images', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [
{
filename: 'clipspace-painted-masked-123.png',
subfolder: 'clipspace',
type: 'input'
}
]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'clipspace-mask-123.png',
subfolder: 'clipspace',
type: 'input'
})
expect(getInputData().paintLayer?.url).toContain(
'filename=clipspace-paint-123.png'
)
})
it('uses painted cloud metadata when painted-masked metadata is absent', async () => {
mockCloudState.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
ok: true,
json: vi.fn().mockResolvedValue({ painted: 'painted-only.png' })
})
)
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'cloud.png', type: 'output' }]
})
)
expect(getInputData().sourceRef.filename).toBe('painted-only.png')
expect(getInputData().paintLayer).toBeUndefined()
})
it('keeps the node image when cloud mask metadata is unavailable', async () => {
mockCloudState.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false
} as Response)
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'cloud.png', type: 'output' }]
})
)
expect(getInputData().sourceRef.filename).toBe('cloud.png')
expect(getInputData().paintLayer).toBeUndefined()
})
it('keeps the node image when cloud mask metadata lookup rejects', async () => {
mockCloudState.isCloud = true
vi.mocked(api.fetchApi).mockRejectedValue(new Error('offline'))
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'cloud.png', type: 'output' }]
})
)
expect(getInputData().sourceRef.filename).toBe('cloud.png')
})
it('loads widget filenames without explicit folder metadata as inputs', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'stale.png', type: 'output' }],
widgets: [
{
name: 'image',
value: 'plain.png'
}
]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'plain.png',
type: 'input'
})
expect(getInputData().sourceRef.subfolder).toBeUndefined()
})
it('surfaces validation failures and clears loading state', async () => {
await expect(
useMaskEditorLoader().loadFromNode(makeNode({ imgs: [], images: [] }))
).rejects.toThrow('Node has no images')
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
false,
'Node has no images'
)
})
it('surfaces null node validation failures', async () => {
const nullNode: unknown = null
await expect(
useMaskEditorLoader().loadFromNode(nullNode as LGraphNode)
).rejects.toThrow('Node is null or undefined')
})
it('surfaces missing output filenames', async () => {
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
images: [
{
filename: '',
type: 'output'
}
]
})
await expect(
useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imgs: [{ src: 'data:image/png;base64,abc' }]
})
)
).rejects.toThrow('nodeOutputStore image missing filename')
})
it('rejects data previews without output metadata', async () => {
await expect(
useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imgs: [{ src: 'data:image/png;base64,abc' }]
})
)
).rejects.toThrow('Unable to get image URL from node')
})
it('rejects preview URLs without filename metadata', async () => {
await expect(
useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imgs: [{ src: '/view?type=input' }]
})
)
).rejects.toThrow('Invalid image URL: /view?type=input')
})
it('propagates image load failures', async () => {
vi.stubGlobal('Image', createMockImageClass('onerror'))
await expect(
useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'broken.png', type: 'output' }]
})
)
).rejects.toThrow('Failed to load image:')
})
})

View File

@@ -1,10 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -98,7 +97,7 @@ describe('useMaskEditorSaver', () => {
app.nodeOutputs = {}
app.nodePreviewImages = {}
const nodeObj: unknown = {
mockNode = fromAny<LGraphNode, unknown>({
id: 42,
type: 'LoadImage',
images: [],
@@ -109,8 +108,7 @@ describe('useMaskEditorSaver', () => {
widgets_values: ['original.png [input]'],
properties: { image: 'original.png [input]' },
graph: { setDirtyCanvas: vi.fn() }
}
mockNode = nodeObj as LGraphNode
})
mockDataStore.sourceNode = mockNode
mockDataStore.inputData = {
@@ -137,7 +135,8 @@ describe('useMaskEditorSaver', () => {
vi.spyOn(document, 'createElement').mockImplementation(
(tagName: string, options?: ElementCreationOptions) => {
if (tagName === 'canvas') return createMockCanvas()
if (tagName === 'canvas')
return fromAny<HTMLCanvasElement, unknown>(createMockCanvas())
return originalCreateElement(tagName, options)
}
)
@@ -202,112 +201,4 @@ describe('useMaskEditorSaver', () => {
expect(body.get('type')).toBe('input')
expect(body.get('subfolder')).toBeNull()
})
it('throws before saving when the source node is missing', async () => {
mockDataStore.sourceNode = null
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow('No source node or input data')
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('throws before saving when the input data is missing', async () => {
mockDataStore.inputData = null
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow('No source node or input data')
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('fails when canvases are not initialized', async () => {
mockEditorStore.maskCanvas = null
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow('Canvas not initialized')
expect(errorSpy).toHaveBeenCalledWith(
'[MaskEditorSaver] Save failed:',
expect.any(Error)
)
})
it('reports upload failures with the response body', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false,
status: 413,
text: () => Promise.resolve('too large')
} as Response)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(
/Failed to upload clipspace-mask-.*: too large/
)
})
it('reports upload failures when the response body cannot be read', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.reject(new Error('body unavailable'))
} as Response)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(/Failed to upload .+ \(500\)/)
})
it('reports invalid upload JSON responses', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.reject(new Error('bad json'))
} as Response)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(/Invalid upload response.*bad json/)
})
it('reports upload responses without a name', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ subfolder: 'clipspace', type: 'input' })
} as Response)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(
"Upload response missing 'name' for clipspace-mask-"
)
})
it('defaults missing upload ref fields and skips missing image widget state', async () => {
mockNode.widgets = [
fromPartial<IBaseWidget>({ name: 'other', value: 'unchanged' })
]
mockNode.widgets_values = ['unchanged']
const noProperties: unknown = undefined
const noGraph: unknown = undefined
mockNode.properties = noProperties as LGraphNode['properties']
mockNode.graph = noGraph as LGraphNode['graph']
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: 'uploaded.png' })
} as Response)
const { save } = useMaskEditorSaver()
await save()
expect(mockNode.images).toEqual([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
expect(mockNode.widgets_values).toEqual(['unchanged'])
})
})

View File

@@ -1,4 +1,3 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -41,7 +40,7 @@ vi.mock('@/stores/maskEditorStore', () => ({
}))
function createMockElement(width = 1200, height = 800): HTMLElement {
return fromPartial<HTMLElement>({
return {
clientWidth: width,
clientHeight: height,
style: {} as CSSStyleDeclaration,
@@ -54,11 +53,11 @@ function createMockElement(width = 1200, height = 800): HTMLElement {
right: width,
bottom: height
}) as DOMRect
})
} as unknown as HTMLElement
}
function createMockCanvas(width: number, height: number): HTMLCanvasElement {
return fromPartial<HTMLCanvasElement>({
return {
width,
height,
clientWidth: width,
@@ -73,7 +72,7 @@ function createMockCanvas(width: number, height: number): HTMLCanvasElement {
right: width,
bottom: height
}) as DOMRect
})
} as unknown as HTMLCanvasElement
}
function createMockImage(width: number, height: number): HTMLImageElement {
@@ -82,15 +81,17 @@ function createMockImage(width: number, height: number): HTMLImageElement {
function createTouchList(...points: { x: number; y: number }[]): TouchList {
const touches = points.map((p) => ({ clientX: p.x, clientY: p.y }) as Touch)
const obj: unknown = Object.assign(touches, {
return Object.assign(touches, {
length: touches.length,
item: (i: number) => touches[i]
})
return obj as TouchList
}) as unknown as TouchList
}
function createTouchEvent(touches: TouchList): TouchEvent {
return fromPartial<TouchEvent>({ touches, preventDefault: vi.fn() })
return {
touches,
preventDefault: vi.fn()
} as unknown as TouchEvent
}
async function initComposable() {
@@ -99,7 +100,7 @@ async function initComposable() {
const root = createMockElement()
const container = createMockElement()
const canvas = createMockCanvas(800, 600)
mockStore.canvasContainer = container
mockStore.canvasContainer = container as unknown as HTMLElement
mockStore.maskCanvas = canvas
await pz.initializeCanvasPanZoom(img, root)
vi.clearAllMocks()
@@ -128,7 +129,7 @@ describe('usePanAndZoom', () => {
it('sets zoom and pan on the store', async () => {
const pz = usePanAndZoom()
const container = createMockElement()
mockStore.canvasContainer = container
mockStore.canvasContainer = container as unknown as HTMLElement
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
@@ -144,7 +145,7 @@ describe('usePanAndZoom', () => {
it('accounts for panel widths via setPanOffset', async () => {
const pz = usePanAndZoom()
mockStore.canvasContainer = createMockElement()
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
const toolPanel = createMockElement()
vi.spyOn(toolPanel, 'getBoundingClientRect').mockReturnValue({
@@ -169,7 +170,7 @@ describe('usePanAndZoom', () => {
it('syncs rgbCanvas dimensions when they differ', async () => {
const pz = usePanAndZoom()
const rgbCanvas = createMockCanvas(400, 300)
mockStore.canvasContainer = createMockElement()
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
mockStore.rgbCanvas = rgbCanvas
await pz.initializeCanvasPanZoom(
@@ -180,50 +181,6 @@ describe('usePanAndZoom', () => {
expect(rgbCanvas.width).toBe(800)
expect(rgbCanvas.height).toBe(600)
})
it('returns before publishing pan when the canvas container is unavailable', async () => {
const pz = usePanAndZoom()
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
})
it('keeps rgbCanvas dimensions when they already match the image', async () => {
const pz = usePanAndZoom()
const rgbCanvas = createMockCanvas(800, 600)
mockStore.canvasContainer = createMockElement()
mockStore.rgbCanvas = rgbCanvas
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
expect(rgbCanvas.width).toBe(800)
expect(rgbCanvas.height).toBe(600)
expect(rgbCanvas.style.width).not.toBe('')
})
it('can be initialized again without replacing the current image reference', async () => {
const pz = usePanAndZoom()
mockStore.canvasContainer = createMockElement()
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
await pz.initializeCanvasPanZoom(
createMockImage(400, 300),
createMockElement()
)
expect(mockStore.setZoomRatio).toHaveBeenCalledTimes(2)
})
})
describe('handlePanStart / handlePanMove', () => {
@@ -323,7 +280,7 @@ describe('usePanAndZoom', () => {
it('returns early when maskCanvas is null', async () => {
const pz = usePanAndZoom()
const container = createMockElement()
mockStore.canvasContainer = container
mockStore.canvasContainer = container as unknown as HTMLElement
mockStore.maskCanvas = null
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
@@ -388,13 +345,6 @@ describe('usePanAndZoom', () => {
expect(mockStore.brushVisible).toBe(false)
})
it('accepts touch starts without active touches', () => {
const pz = usePanAndZoom()
pz.handleTouchStart(createTouchEvent(createTouchList()))
expect(mockStore.brushVisible).toBe(false)
expect(mockStore.canvasHistory.undo).not.toHaveBeenCalled()
})
it('ignores touch when pen pointer is active', () => {
const pz = usePanAndZoom()
pz.addPenPointerId(1)
@@ -451,49 +401,6 @@ describe('usePanAndZoom', () => {
expect(mockStore.setZoomRatio).toHaveBeenCalled()
})
it('returns from pinch zoom when the mask canvas is unavailable', async () => {
const pz = usePanAndZoom()
mockStore.maskCanvas = null
pz.handleTouchStart(
createTouchEvent(
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
)
)
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
)
)
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
})
it('uses the cached mask canvas for consecutive pinch moves', async () => {
const { pz } = await initComposable()
pz.handleTouchStart(
createTouchEvent(
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
)
)
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
)
)
vi.clearAllMocks()
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 140, y: 300 }, { x: 460, y: 300 })
)
)
expect(mockStore.setZoomRatio).toHaveBeenCalled()
})
it('touch move is ignored when pen is active', async () => {
const pz = usePanAndZoom()
pz.addPenPointerId(1)
@@ -511,26 +418,6 @@ describe('usePanAndZoom', () => {
pz.handleTouchEnd(event)
expect(event.preventDefault).toHaveBeenCalled()
})
it('stops pinch zooming when all touches end', async () => {
const { pz } = await initComposable()
pz.handleTouchStart(
createTouchEvent(
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
)
)
pz.handleTouchEnd(createTouchEvent(createTouchList()))
vi.clearAllMocks()
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
)
)
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
})
})
describe('pen pointer management', () => {

View File

@@ -73,46 +73,4 @@ describe('useNodeAnimatedImage', () => {
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
})
it('does nothing when a node has no images or widgets', () => {
const { showAnimatedPreview, removeAnimatedPreview } =
useNodeAnimatedImage()
const noImageNode = createMockMediaNode({ imgs: [] })
const noWidgetNode = Object.assign(
createMockMediaNode({ imgs: [document.createElement('img')] }),
{ widgets: undefined }
)
showAnimatedPreview(noImageNode)
showAnimatedPreview(noWidgetNode)
removeAnimatedPreview(noWidgetNode)
expect(noImageNode.widgets).toHaveLength(0)
})
it('replaces the image in an existing preview widget', () => {
const { node, showAnimatedPreview } = setup()
const firstWidget = node.widgets[0]
const nextImage = document.createElement('img')
node.imgs = [nextImage]
showAnimatedPreview(node)
expect(node.widgets).toHaveLength(1)
expect(node.widgets[0]).toBe(firstWidget)
expect(firstWidget.element.firstChild).toBe(nextImage)
})
it('leaves an existing non-DOM preview widget untouched', () => {
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
const noElement: unknown = undefined
node.widgets.push({
name: '$$comfy_animation_preview',
element: noElement as HTMLElement
})
useNodeAnimatedImage().showAnimatedPreview(node)
expect(node.widgets).toHaveLength(1)
})
})

View File

@@ -1,431 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick } from 'vue'
import type { App as VueApp } from 'vue'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { ComfyExtension } from '@/types/comfy'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const {
settings,
appState,
extensionState,
nodeDefState,
pricingState,
setDirtyMock,
addEventListenerMock,
registerExtensionMock,
getCreditsBadgeMock,
updateSubgraphCreditsMock,
getNodePricingConfigMock,
getNodeDisplayPriceMock,
getRelevantWidgetNamesMock,
triggerPriceRecalculationMock,
useComputedWithWidgetWatchMock
} = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
appState: {
graph: {
nodes: [] as unknown[]
}
},
extensionState: {
installed: false,
registered: undefined as ComfyExtension | undefined
},
nodeDefState: {
value: null as Record<string, unknown> | null
},
pricingState: {
revision: { value: 0 },
config: undefined as
| {
depends_on?: {
widgets?: string[]
inputs?: string[]
input_groups?: string[]
}
}
| undefined,
label: '1 credit'
},
setDirtyMock: vi.fn(),
addEventListenerMock: vi.fn(),
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
extensionState.registered = extension
}),
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
updateSubgraphCreditsMock: vi.fn(),
getNodePricingConfigMock: vi.fn(() => pricingState.config),
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
triggerPriceRecalculationMock: vi.fn(),
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
setDirty: setDirtyMock,
canvas: {
addEventListener: addEventListenerMock
},
graph: appState.graph
}
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settings[key]
})
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({
isExtensionInstalled: () => extensionState.installed,
registerExtension: registerExtensionMock
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
fromLGraphNode: () => nodeDefState.value
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
colors: {
litegraph_base: {
BADGE_FG_COLOR: '#fff',
BADGE_BG_COLOR: '#000'
}
}
}
})
}))
vi.mock('@/composables/node/useNodePricing', async () => {
const { ref } = await import('vue')
const pricingRevision = ref(pricingState.revision.value)
Object.defineProperty(pricingState.revision, 'value', {
get: () => pricingRevision.value,
set: (value: number) => {
pricingRevision.value = value
}
})
return {
useNodePricing: () => ({
pricingRevision,
getNodePricingConfig: getNodePricingConfigMock,
getNodeDisplayPrice: getNodeDisplayPriceMock,
getRelevantWidgetNames: getRelevantWidgetNamesMock,
triggerPriceRecalculation: triggerPriceRecalculationMock
})
}
})
vi.mock('@/composables/node/usePriceBadge', () => ({
usePriceBadge: () => ({
getCreditsBadge: getCreditsBadgeMock,
updateSubgraphCredits: updateSubgraphCreditsMock
})
}))
vi.mock('@/composables/node/useWatchWidget', () => ({
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
}))
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
function mountBadge(): VueApp {
const app = createApp(
defineComponent({
setup() {
useNodeBadge()
return () => h('div')
}
})
)
app.mount(document.createElement('div'))
return app
}
function registeredExtension(): ComfyExtension {
if (!extensionState.registered)
throw new Error('Missing registered extension')
return extensionState.registered
}
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
}
function callNodeCreated(node: LGraphNode) {
registeredExtension().nodeCreated?.(node, comfyApp())
}
function inputSlot(name: string) {
return new LGraphNode('slot').addInput(name, '*')
}
function defaultSettings() {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.ShowApiPricing'] = false
}
describe('useNodeBadge', () => {
let mountedApp: VueApp | undefined
beforeEach(() => {
defaultSettings()
extensionState.installed = false
extensionState.registered = undefined
appState.graph.nodes = []
nodeDefState.value = null
pricingState.revision.value = 0
pricingState.config = undefined
pricingState.label = '1 credit'
setDirtyMock.mockClear()
addEventListenerMock.mockClear()
registerExtensionMock.mockClear()
getCreditsBadgeMock.mockClear()
updateSubgraphCreditsMock.mockClear()
getNodePricingConfigMock.mockClear()
getNodeDisplayPriceMock.mockClear()
getRelevantWidgetNamesMock.mockClear()
triggerPriceRecalculationMock.mockClear()
useComputedWithWidgetWatchMock.mockClear()
})
afterEach(() => {
mountedApp?.unmount()
mountedApp = undefined
})
it('does not register the badge extension twice', async () => {
extensionState.installed = true
mountedApp = mountBadge()
await nextTick()
expect(registerExtensionMock).not.toHaveBeenCalled()
})
it('adds the configured node identity badge', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
NodeBadgeMode.HideBuiltIn
nodeDefState.value = {
isCoreNode: false,
nodeLifeCycleBadgeText: 'Beta',
nodeSource: { badgeText: 'Pack' }
}
const node = new LGraphNode('Test')
node.id = toNodeId('7')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(node.badgePosition).toBe(BadgePosition.TopRight)
expect(badge().text).toBe('#7 Beta Pack')
})
it('hides built-in badge text when the mode excludes core nodes', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
NodeBadgeMode.HideBuiltIn
nodeDefState.value = {
isCoreNode: true,
nodeLifeCycleBadgeText: 'Core',
nodeSource: { badgeText: 'Built-in' }
}
const node = new LGraphNode('Core')
node.id = toNodeId('11')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(badge().text).toBe('#11')
})
it('keeps optional node definition badge text empty', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
nodeDefState.value = null
const node = new LGraphNode('NoDef')
node.id = toNodeId('13')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(badge().text).toBe('#13')
})
it('marks the canvas dirty when pricing changes while pricing badges are visible', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
mountedApp = mountBadge()
await nextTick()
pricingState.revision.value++
await nextTick()
expect(setDirtyMock).toHaveBeenCalledWith(true, true)
})
it('does not add API pricing badges when the pricing setting is disabled', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = false
const node = new ApiNode('API')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(node.badges).toHaveLength(1)
expect(getCreditsBadgeMock).not.toHaveBeenCalled()
})
it('adds static API pricing badges without widget watchers', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = undefined
const node = new ApiNode('API')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(node.badges).toHaveLength(2)
expect(useComputedWithWidgetWatchMock).not.toHaveBeenCalled()
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
})
it('adds dynamic widget pricing without connection hooks when no inputs matter', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = {
depends_on: {
widgets: ['seed']
}
}
const node = new ApiNode('API')
const originalOnConnectionsChange = node.onConnectionsChange
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(useComputedWithWidgetWatchMock).toHaveBeenCalled()
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
})
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = {
depends_on: {
widgets: ['seed'],
inputs: ['image'],
input_groups: ['lora']
}
}
const originalOnConnectionsChange = vi.fn()
const node = new ApiNode('API')
node.onConnectionsChange = originalOnConnectionsChange
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
widgetNames: ['seed'],
triggerCanvasRedraw: true
})
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
const priceBadge = node.badges[1] as () => { text: string }
expect(priceBadge().text).toBe('1 credit')
pricingState.label = '2 credits'
expect(priceBadge().text).toBe('2 credits')
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
})
it('refreshes dynamic pricing inputs without an existing connection hook', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = {
depends_on: {
inputs: ['image']
}
}
const node = new ApiNode('API')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
})
it('updates subgraph credit badges from registered extension hooks', async () => {
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
appState.graph.nodes = nodes
mountedApp = mountBadge()
await nextTick()
await registeredExtension().init?.(comfyApp())
await registeredExtension().afterConfigureGraph?.([], comfyApp())
const setGraphHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'litegraph:set-graph'
)?.[1]
const convertedHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'subgraph-converted'
)?.[1]
setGraphHandler?.()
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
})
it('handles empty graph nodes during registered extension hooks', async () => {
const noNodes: unknown = undefined
appState.graph.nodes = noNodes as LGraphNode[]
mountedApp = mountBadge()
await nextTick()
await registeredExtension().init?.(comfyApp())
await registeredExtension().afterConfigureGraph?.([], comfyApp())
const setGraphHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'litegraph:set-graph'
)?.[1]
setGraphHandler?.()
expect(updateSubgraphCreditsMock).not.toHaveBeenCalled()
})
})

View File

@@ -65,29 +65,4 @@ describe('useNodeCanvasImagePreview', () => {
expect(imagePreviewWidget).not.toHaveBeenCalled()
})
it('does nothing when removing from a node without widgets', () => {
const node = Object.assign(new LGraphNode('test'), { widgets: undefined })
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
expect(imagePreviewWidget).not.toHaveBeenCalled()
})
it('removes an existing preview widget and calls its cleanup', () => {
const node = new LGraphNode('test')
const widget = node.addWidget(
'text',
'$$canvas-image-preview',
'',
() => undefined,
{}
)
widget.onRemove = vi.fn()
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
expect(widget.onRemove).toHaveBeenCalledOnce()
expect(node.widgets).toHaveLength(0)
})
})

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeVideo } from '@/composables/node/useNodeImage'
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
@@ -28,25 +28,8 @@ describe('useNodeVideo', () => {
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function installMockImage() {
const images: HTMLImageElement[] = []
class MockImage {
onload: ((event: Event) => void) | null = null
onerror: ((event: Event) => void) | null = null
src = ''
constructor() {
const self: unknown = this
images.push(self as HTMLImageElement)
}
}
vi.stubGlobal('Image', MockImage)
return images
}
async function setup() {
vi.clearAllMocks()
vi.useFakeTimers()
@@ -110,103 +93,4 @@ describe('useNodeVideo', () => {
expect(canvasInteractionsMock.handlePointerMove).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
})
it('loads image previews and marks the graph dirty', async () => {
vi.clearAllMocks()
vi.useFakeTimers()
const images = installMockImage()
const graph = { setDirtyCanvas: vi.fn() }
const node = createMockMediaNode({ graph })
const callback = vi.fn()
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://image/1.png'])
const { showPreview } = useNodeImage(node, callback)
showPreview({ block: true })
images[0].onload?.(new Event('load'))
await vi.runAllTimersAsync()
expect(node.previewMediaType).toBe('image')
expect(node.imageIndex).toBeNull()
expect(node.imgs).toEqual([images[0]])
expect(node.isLoading).toBe(false)
expect(callback).toHaveBeenCalledTimes(1)
expect(graph.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('does not start image loads while already loading or without output URLs', () => {
vi.clearAllMocks()
const images = installMockImage()
const node = createMockMediaNode()
const { showPreview } = useNodeImage(node)
node.isLoading = true
showPreview()
node.isLoading = false
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(undefined)
showPreview()
expect(images).toHaveLength(0)
})
it('retries image loading once when the first load fails', async () => {
vi.clearAllMocks()
vi.useFakeTimers()
const images = installMockImage()
const graph = { setDirtyCanvas: vi.fn() }
const node = createMockMediaNode({ graph })
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue([
'http://image/missing.png'
])
const staleImgs = [document.createElement('img')]
node.imgs = staleImgs
const { showPreview } = useNodeImage(node)
showPreview()
images[0].onerror?.(new Event('error'))
await Promise.resolve()
await Promise.resolve()
images[1].onerror?.(new Event('error'))
await vi.runAllTimersAsync()
expect(images).toHaveLength(2)
// Failed loads never resolve to elements, so existing previews are untouched
expect(node.imgs).toBe(staleImgs)
expect(graph.setDirtyCanvas).not.toHaveBeenCalled()
expect(node.isLoading).toBe(false)
})
it('reuses an existing video-preview widget when loading a video', async () => {
vi.clearAllMocks()
vi.useFakeTimers()
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4'])
const node = createMockMediaNode({
graph: { setDirtyCanvas: vi.fn() }
})
node.widgets.push({
name: 'video-preview',
element: document.createElement('div')
})
const callback = vi.fn()
const createdVideos: HTMLVideoElement[] = []
const realCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation(
(tag: string, opts?: ElementCreationOptions) => {
const el = realCreateElement(tag, opts)
if (tag === 'video') createdVideos.push(el as HTMLVideoElement)
return el
}
)
const { showPreview } = useNodeVideo(node, callback)
showPreview()
const video = createdVideos[0]
video.onloadeddata?.(new Event('loadeddata'))
await vi.runAllTimersAsync()
expect(node.addDOMWidget).not.toHaveBeenCalled()
expect(node.videoContainer?.firstChild).toBe(video)
expect(callback).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,6 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import {
@@ -14,7 +12,6 @@ import {
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
@@ -126,35 +123,6 @@ function createMockNode(
})
}
async function resolveDisplayPrice(
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): Promise<string> {
const { getNodeDisplayPrice } = useNodePricing()
getNodeDisplayPrice(node, widgetOverrides)
await new Promise((resolve) => setTimeout(resolve, 50))
return getNodeDisplayPrice(node, widgetOverrides)
}
function createStoredNodeDef(
name: string,
price_badge?: PriceBadge
): ComfyNodeDef {
return {
name,
display_name: name,
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
price_badge
} as ComfyNodeDef
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
@@ -221,32 +189,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.5))
})
it('should parse numeric strings and reject blank or invalid numbers', async () => {
const expression =
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
const parsedNode = createMockNodeWithPriceBadge(
'TestNumericStringNode',
badge,
[{ name: 'count', value: ' 5 ' }]
)
const blankNode = createMockNodeWithPriceBadge(
'TestBlankNumericStringNode',
badge,
[{ name: 'count', value: ' ' }]
)
const invalidNode = createMockNodeWithPriceBadge(
'TestInvalidNumericStringNode',
badge,
[{ name: 'count', value: 'five' }]
)
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
})
it('should handle COMBO widget with numeric value', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -280,19 +222,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should preserve boolean combo values', async () => {
const node = createMockNodeWithPriceBadge(
'TestComboBooleanNode',
priceBadge(
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
[{ name: 'enabled', type: 'COMBO' }]
),
[{ name: 'enabled', value: false }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
})
it('should handle BOOLEAN widget', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -309,64 +238,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should parse BOOLEAN widget string values', async () => {
const badge = priceBadge(
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
[{ name: 'premium', type: 'BOOLEAN' }]
)
const enabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringTrueNode',
badge,
[{ name: 'premium', value: ' TRUE ' }]
)
const disabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringFalseNode',
badge,
[{ name: 'premium', value: 'false' }]
)
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
})
it('should reject invalid BOOLEAN strings', async () => {
const node = createMockNodeWithPriceBadge(
'TestInvalidBooleanStringNode',
priceBadge(
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
[{ name: 'premium', type: 'BOOLEAN' }]
),
[{ name: 'premium', value: 'sometimes' }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should reject non-boolean values for BOOLEAN widgets', async () => {
const node = createMockNodeWithPriceBadge(
'TestInvalidBooleanNumberNode',
priceBadge(
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
[{ name: 'premium', type: 'BOOLEAN' }]
),
[{ name: 'premium', value: 1 }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should reject object values for numeric widgets', async () => {
const node = createMockNodeWithPriceBadge(
'TestObjectNumericNode',
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: { count: 5 } }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should handle STRING widget (lowercased)', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -597,42 +468,6 @@ describe('useNodePricing', () => {
})
})
describe('dependency context', () => {
it('should prefer widget overrides over node widget values', async () => {
const node = createMockNodeWithPriceBadge(
'TestWidgetOverrideNode',
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: 2 }]
)
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
expect(price).toBe(creditsLabel(0.07))
})
it('should treat missing input group arrays as zero connected inputs', async () => {
const node = Object.assign(createMockLGraphNode(), {
widgets: [],
constructor: {
nodeData: {
name: 'TestMissingInputGroupArrayNode',
api_node: true,
price_badge: priceBadge(
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
[],
[],
['images']
)
}
}
})
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
})
describe('edge cases', () => {
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
@@ -706,44 +541,6 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(creditsLabel(0.05))
})
it('should default missing price badge engine and dependency arrays', async () => {
const bareBadge = {
expr: '{"type":"usd","usd":0.05}'
} as PriceBadge
const node = createMockNodeWithPriceBadge('TestBareBadgeNode', bareBadge)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
const { getNodePricingConfig } = useNodePricing()
expect(getNodePricingConfig(node)).toMatchObject({
engine: 'jsonata',
depends_on: {
widgets: [],
inputs: [],
input_groups: []
}
})
})
it('should ignore non-jsonata pricing engines', () => {
const { getNodeDisplayPrice } = useNodePricing()
const literalEngineBadge: unknown = {
engine: 'literal',
expr: '{"type":"usd","usd":0.05}',
depends_on: {
widgets: [],
inputs: [],
input_groups: []
}
}
const node = createMockNodeWithPriceBadge(
'TestUnsupportedEngineNode',
literalEngineBadge as PriceBadge
)
expect(getNodeDisplayPrice(node)).toBe('')
})
})
describe('getNodePricingConfig', () => {
@@ -798,107 +595,6 @@ describe('useNodePricing', () => {
})
})
describe('node type pricing dependencies', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns empty dependency metadata for node types without pricing', () => {
const store = useNodeDefStore()
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
expect(getInputNames('UnpricedNode')).toEqual([])
})
it('dedupes dynamic pricing dependencies while preserving order', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'DynamicPricingNode',
priceBadge(
'{"type":"usd","usd":0.05}',
[
{ name: 'seed', type: 'INT' },
{ name: 'quality', type: 'COMBO' }
],
['image', 'seed'],
['clips', 'image']
)
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
'seed',
'quality',
'image',
'clips'
])
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
'clips',
'image'
])
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
})
it('handles fixed pricing metadata without dependencies', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'FixedPricingNode',
priceBadge('{"type":"usd","usd":0.05}')
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
expect(getInputNames('FixedPricingNode')).toEqual([])
})
it('handles price badges with omitted dependency metadata', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef('BareDependencyNode', {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05}'
} as PriceBadge)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('BareDependencyNode')).toEqual([])
expect(hasDynamicPricing('BareDependencyNode')).toBe(false)
expect(getInputGroupPrefixes('BareDependencyNode')).toEqual([])
expect(getInputNames('BareDependencyNode')).toEqual([])
})
})
describe('reactive revision', () => {
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
@@ -959,20 +655,6 @@ describe('useNodePricing', () => {
expect(second).toBe(first)
expect(pricingRevision.value).toBe(tickAfterFirst)
})
it('does not schedule duplicate work for the same in-flight signature', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestInFlightSignatureNode',
priceBadge('{"type":"usd","usd":0.05}')
)
expect(getNodeDisplayPrice(node)).toBe('')
expect(getNodeDisplayPrice(node)).toBe('')
await new Promise((r) => setTimeout(r, 50))
expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.05))
})
})
describe('getNodeRevisionRef', () => {
@@ -1061,16 +743,6 @@ describe('useNodePricing', () => {
expect(price).toBe('')
})
it('should reuse the cached empty label after runtime failures', async () => {
const node = createMockNodeWithPriceBadge(
'TestCachedRuntimeErrorNode',
priceBadge('$lookup(undefined, "key")')
)
expect(await resolveDisplayPrice(node)).toBe('')
expect(await resolveDisplayPrice(node)).toBe('')
})
it('should return empty string for invalid PricingResult type', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -1296,21 +968,8 @@ describe('formatPricingResult', () => {
expect(result).toBe('~10.6')
})
it('should parse string usd values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'usd', usd: '0.05' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null })
expect(result).toBe('')
})
it('should return empty for blank string usd', () => {
const result = formatPricingResult({ type: 'usd', usd: ' ' })
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})
@@ -1340,14 +999,6 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6')
})
it('should parse string range values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6-21.1')
})
})
describe('type: list_usd', () => {
@@ -1366,22 +1017,6 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6/21.1')
})
it('should return valueOnly format with approximate prefix', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6/21.1')
})
it('should return empty when list value is not an array', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: 'not-a-list'
})
expect(result).toBe('')
})
})
describe('type: text', () => {
@@ -1389,11 +1024,6 @@ describe('formatPricingResult', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
it('should return empty when text is missing', () => {
const result = formatPricingResult({ type: 'text' })
expect(result).toBe('')
})
})
describe('legacy format', () => {
@@ -1538,20 +1168,6 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('10.6')
})
it('should evaluate price badges with omitted dependency metadata', async () => {
const nodeDef = createMockNodeDef({
name: 'BareNodeDefPriceBadge',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05}'
} as PriceBadge
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should use default value from input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'DefaultValueNode',
@@ -1574,29 +1190,6 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use default value from optional input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'OptionalDefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {},
optional: {
count: ['INT', { default: 4 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('8.4')
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
@@ -1672,30 +1265,6 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('10.6')
})
it('should handle combo option arrays with primitive values', async () => {
const nodeDef = createMockNodeDef({
name: 'PrimitiveOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: ['COMBO', { options: ['fast', 'slow'] }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',

View File

@@ -1,87 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const mockTextPreviewWidget = vi.hoisted(() => vi.fn())
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget',
() => ({
useTextPreviewWidget: () => mockTextPreviewWidget
})
)
import { useNodeProgressText } from './useNodeProgressText'
function node(widgets?: IBaseWidget[]): LGraphNode {
return createMockLGraphNode({ widgets, setDirtyCanvas: vi.fn() })
}
describe('useNodeProgressText', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTextPreviewWidget.mockImplementation(
(_node: LGraphNode, spec: { name: string; type: string }) => ({
name: spec.name,
type: spec.type,
value: ''
})
)
})
it('updates an existing text preview widget', () => {
const existing = { name: '$$node-text-preview', value: '' } as IBaseWidget
const graphNode = node([existing])
const { showTextPreview } = useNodeProgressText()
showTextPreview(graphNode, 'running')
expect(existing.value).toBe('running')
expect(mockTextPreviewWidget).not.toHaveBeenCalled()
expect(graphNode.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('creates a text preview widget when one is missing', () => {
const graphNode = node([])
const { showTextPreview } = useNodeProgressText()
showTextPreview(graphNode, 'queued')
expect(mockTextPreviewWidget).toHaveBeenCalledWith(graphNode, {
name: '$$node-text-preview',
type: 'progressText'
})
expect(mockTextPreviewWidget.mock.results[0].value.value).toBe('queued')
})
it('removes an existing preview widget and calls its cleanup', () => {
const onRemove = vi.fn()
const keep = { name: 'other' } as IBaseWidget
const preview = fromPartial<IBaseWidget & { onRemove?: () => void }>({
name: '$$node-text-preview',
onRemove
})
const graphNode = node([keep, preview])
const { removeTextPreview } = useNodeProgressText()
removeTextPreview(graphNode)
expect(onRemove).toHaveBeenCalledOnce()
expect(graphNode.widgets).toEqual([keep])
})
it('does nothing when there are no widgets or no preview widget', () => {
const { removeTextPreview } = useNodeProgressText()
const withoutWidgets = node()
const withoutPreview = node([{ name: 'other' } as IBaseWidget])
removeTextPreview(withoutWidgets)
removeTextPreview(withoutPreview)
expect(withoutWidgets.widgets).toBeUndefined()
expect(withoutPreview.widgets).toHaveLength(1)
})
})

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -6,34 +6,26 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { adjustColor } from '@/utils/colorUtil'
const getNodeDisplayPrice = vi.fn(
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
String(overrides?.get('prompt') ?? 'missing override')
)
const mockPalette = vi.hoisted(() => ({
completedActivePalette: {
light_theme: false,
colors: {
litegraph_base: {
BADGE_FG_COLOR: '#ffffff'
}
}
}
}))
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({ getNodeDisplayPrice })
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => mockPalette
useColorPaletteStore: () => ({
completedActivePalette: {
light_theme: false,
colors: { litegraph_base: {} }
}
})
}))
const { updateSubgraphCredits, getCreditsBadge, isCreditsBadge } =
usePriceBadge()
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
const mockNode = new LGraphNode('mock node')
mockNode.badges = [getCreditsBadge('$0.05/Run')]
@@ -44,34 +36,6 @@ function getBadgeText(node: LGraphNode): string {
}
describe('subgraph pricing', () => {
beforeEach(() => {
mockPalette.completedActivePalette.light_theme = false
})
it('identifies credit badges and ignores unrelated badges', () => {
expect(isCreditsBadge(getCreditsBadge('$0.05/Run'))).toBe(true)
expect(isCreditsBadge(() => getCreditsBadge('$0.05/Run'))).toBe(true)
expect(isCreditsBadge({ text: 'other' })).toBe(false)
})
it('uses the adjusted credits background in light themes', () => {
mockPalette.completedActivePalette.light_theme = true
expect(getCreditsBadge('$0.05/Run').bgColor).toBe(
adjustColor('#8D6932', { lightness: 0.5 })
)
})
it('does nothing for non-subgraph nodes', () => {
const node = new LGraphNode('plain node')
const badge = getCreditsBadge('$0.05/Run')
node.badges = [badge]
updateSubgraphCredits(node)
expect(node.badges).toEqual([badge])
})
subgraphTest(
'should not display badge for subgraphs without API nodes',
({ subgraphWithNode }) => {

View File

@@ -151,9 +151,7 @@ describe('useComputedWithWidgetWatch', () => {
})
it('should handle nodes without widgets gracefully', () => {
const mockNode = Object.assign(createMockLGraphNode(), {
widgets: undefined
}) as LGraphNode
const mockNode = createMockNode([])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
@@ -162,85 +160,6 @@ describe('useComputedWithWidgetWatch', () => {
expect(computedValue.value).toBe('no widgets')
})
it('observes named input connection changes when requested', async () => {
const mockNode = Object.assign(
createMockNode([{ name: 'width', value: 1 }]),
{
inputs: [{ name: 'image' }],
onConnectionsChange: undefined as
| ((type: unknown, index: number, isConnected: boolean) => void)
| undefined
}
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
widgetNames: ['width', 'image'],
triggerCanvasRedraw: true
})
let runs = 0
const computedValue = computedWithWidgetWatch(() => ++runs)
expect(computedValue.value).toBe(1)
mockNode.onConnectionsChange?.('input', 0, true)
await nextTick()
expect(computedValue.value).toBe(2)
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('observes connection changes for watched inputs at non-zero slots', async () => {
const mockNode = Object.assign(
createMockNode([{ name: 'width', value: 1 }]),
{
inputs: [{ name: 'other' }, { name: 'image' }],
onConnectionsChange: undefined as
| ((type: unknown, index: number, isConnected: boolean) => void)
| undefined
}
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
widgetNames: ['width', 'image'],
triggerCanvasRedraw: true
})
let runs = 0
const computedValue = computedWithWidgetWatch(() => ++runs)
expect(computedValue.value).toBe(1)
mockNode.onConnectionsChange?.('input', 1, true)
await nextTick()
expect(computedValue.value).toBe(2)
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('ignores unobserved input connection changes', async () => {
const mockNode = Object.assign(
createMockNode([{ name: 'width', value: 1 }]),
{
inputs: [{ name: 'image' }],
onConnectionsChange: undefined as
| ((type: unknown, index: number, isConnected: boolean) => void)
| undefined
}
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
widgetNames: ['width', 'image']
})
let runs = 0
const computedValue = computedWithWidgetWatch(() => ++runs)
expect(computedValue.value).toBe(1)
mockNode.onConnectionsChange?.('input', 1, true)
await nextTick()
expect(computedValue.value).toBe(1)
})
it('should chain with existing widget callbacks', async () => {
const existingCallback = vi.fn()
const mockNode = createMockNode([

View File

@@ -1,4 +1,3 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
@@ -13,12 +12,8 @@ afterEach(() => {
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef<HTMLDivElement | null>(
document.createElement('div')
)
const picker = shallowRef<HTMLInputElement | null>(
document.createElement('input')
)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
@@ -27,12 +22,7 @@ function setup(initial: string[]) {
return { modelValue, container, picker, ...api }
}
const mouseEvent = () => fromPartial<MouseEvent>({ stopPropagation: vi.fn() })
function makeInputEvent(value: string): Event {
const event: unknown = { target: { value } }
return event as Event
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
@@ -61,29 +51,16 @@ describe('usePaletteSwatchRow', () => {
expect(picker.value!.value).toBe('#ffffff')
})
it('tracks the picker index even when the input is unavailable', () => {
const { modelValue, picker, openPicker, onPickerInput } = setup([
'#000000',
'#111111'
])
picker.value = null
openPicker(1, mouseEvent())
onPickerInput(makeInputEvent('#222222'))
expect(modelValue.value).toEqual(['#000000', '#222222'])
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput(makeInputEvent('#123456'))
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput(makeInputEvent('#123456'))
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
})
@@ -123,82 +100,6 @@ describe('usePaletteSwatchRow', () => {
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores pointer movement before a drag starts', () => {
const { modelValue } = setup(['#a', '#b'])
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('waits until movement passes the drag threshold', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 12, clientY: 11, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores active drags when the row container is gone', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
container.value = null
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores invalid target rows during drag', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const current = document.createElement('div')
current.setAttribute('data-index', '0')
const invalid = document.createElement('div')
invalid.setAttribute('data-index', '-1')
container.value!.append(current, invalid)
invalid.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('cancels drags on pointerup and pointercancel', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
swatch.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(new PointerEvent('pointerup'))
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(new PointerEvent('pointercancel'))
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores non-left-button pointer downs', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')

View File

@@ -1,132 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createApp, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { Ref } from 'vue'
const { cancelJob, removeFailedJob, wrapWithErrorHandlingAsync } = vi.hoisted(
() => ({
cancelJob: vi.fn(),
removeFailedJob: vi.fn(),
wrapWithErrorHandlingAsync: vi.fn(
<T extends (...args: never[]) => Promise<unknown>>(fn: T) => fn
)
})
)
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({ wrapWithErrorHandlingAsync })
}))
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({ cancelJob, removeFailedJob })
}))
function mountJobActions(job: Ref<JobListItem | null | undefined>) {
let result: ReturnType<typeof useJobActions> | undefined
const app = createApp({
setup() {
result = useJobActions(job)
return () => h('div')
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
)
app.mount(document.createElement('div'))
if (!result) throw new Error('useJobActions did not initialize')
return {
result,
unmount: () => app.unmount()
}
}
function job(overrides: Partial<JobListItem> = {}): JobListItem {
return {
id: 'job-1',
title: 'Job 1',
meta: '',
state: 'pending',
...overrides
}
}
beforeEach(() => {
cancelJob.mockReset().mockResolvedValue(undefined)
removeFailedJob.mockReset().mockResolvedValue(undefined)
wrapWithErrorHandlingAsync.mockClear()
})
describe('useJobActions', () => {
it('exposes localized action metadata', () => {
const { result, unmount } = mountJobActions(ref(job()))
expect(result.cancelAction).toMatchObject({
icon: 'icon-[lucide--x]',
label: 'sideToolbar.queueProgressOverlay.cancelJobTooltip',
variant: 'destructive'
})
expect(result.deleteAction).toMatchObject({
icon: 'icon-[lucide--circle-minus]',
label: 'queue.jobMenu.removeJob',
variant: 'destructive'
})
expect(wrapWithErrorHandlingAsync).toHaveBeenCalledTimes(2)
unmount()
})
it('cancels active jobs unless clearing is hidden', async () => {
const currentJob = ref(job({ state: 'running' }))
const { result, unmount } = mountJobActions(currentJob)
expect(result.canCancelJob.value).toBe(true)
await result.runCancelJob()
expect(cancelJob).toHaveBeenCalledWith(currentJob.value)
currentJob.value = job({ state: 'pending', showClear: false })
expect(result.canCancelJob.value).toBe(false)
unmount()
})
it('ignores cancel and delete requests without a current job or task', async () => {
const currentJob = ref<JobListItem | null>(null)
const { result, unmount } = mountJobActions(currentJob)
expect(result.canCancelJob.value).toBe(false)
expect(result.canDeleteJob.value).toBe(false)
await result.runCancelJob()
await result.runDeleteJob()
currentJob.value = job({ state: 'failed' })
await result.runDeleteJob()
expect(cancelJob).not.toHaveBeenCalled()
expect(removeFailedJob).not.toHaveBeenCalled()
unmount()
})
it('removes failed jobs through their queue task', async () => {
const task = fromPartial<TaskItemImpl>({ job: { id: 'prompt-1' } })
const { result, unmount } = mountJobActions(
ref(job({ state: 'failed', taskRef: task }))
)
expect(result.canDeleteJob.value).toBe(true)
await result.runDeleteJob()
expect(removeFailedJob).toHaveBeenCalledWith(task)
unmount()
})
})

View File

@@ -280,20 +280,6 @@ describe('useJobMenu', () => {
expect(copyToClipboardMock).not.toHaveBeenCalled()
})
it('uses an empty menu with the default current item getter', async () => {
const { jobMenuEntries, openJobWorkflow, copyJobId, cancelJob } =
useJobMenu()
await openJobWorkflow()
await copyJobId()
await cancelJob()
expect(jobMenuEntries.value).toEqual([])
expect(getJobWorkflowMock).not.toHaveBeenCalled()
expect(copyToClipboardMock).not.toHaveBeenCalled()
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it.for([['running'], ['initialization'], ['pending']])(
'cancels %s job via the state-agnostic jobs-namespace endpoint',
async ([state]) => {
@@ -407,26 +393,6 @@ describe('useJobMenu', () => {
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
})
it('ignores failed report action when item disappears before click', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: {
errorMessage: 'Job failed with error'
} as Partial<TaskItemImpl>
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
setCurrentItem(null)
await entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
})
it('ignores error actions when message missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
@@ -578,74 +544,6 @@ describe('useJobMenu', () => {
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
})
it('uses no root folder when preview type is not an API result type', async () => {
const node = {
widgets: [{ name: 'image', value: null, callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
}
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo.png',
subfolder: 'bar',
type: 'archive',
url: 'http://asset'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(createAnnotatedPathMock).toHaveBeenCalledWith(
{
filename: 'foo.png',
subfolder: 'bar',
type: undefined
},
{ rootFolder: undefined },
undefined
)
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('marks graph dirty when created loader node has no matching widget', async () => {
const node = {
widgets: [{ name: 'other', value: null, callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
}
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo.png',
subfolder: '',
type: 'output'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(node.widgets[0].value).toBeNull()
expect(node.widgets[0].callback).not.toHaveBeenCalled()
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('ignores add-to-current entry when preview missing entirely', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
@@ -696,45 +594,6 @@ describe('useJobMenu', () => {
expect(downloadFileMock).not.toHaveBeenCalled()
})
it('ignores completed asset actions when item disappears before click', async () => {
const inspectSpy = vi.fn()
const { jobMenuEntries } = mountJobMenu(inspectSpy)
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo.png',
subfolder: '',
type: 'output',
url: 'https://asset'
}
}
})
)
await nextTick()
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
const addEntry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const downloadEntry = findActionEntry(jobMenuEntries.value, 'download')
const exportEntry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const deleteEntry = findActionEntry(jobMenuEntries.value, 'delete')
setCurrentItem(null)
void inspectEntry?.onClick?.()
await addEntry?.onClick?.()
void downloadEntry?.onClick?.()
await exportEntry?.onClick?.()
await deleteEntry?.onClick?.()
expect(inspectSpy).not.toHaveBeenCalled()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
expect(downloadFileMock).not.toHaveBeenCalled()
expect(getJobWorkflowMock).not.toHaveBeenCalled()
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
})
it('exports workflow with default filename when prompting disabled', async () => {
const workflow = { foo: 'bar' }
getJobWorkflowMock.mockResolvedValue(workflow)
@@ -759,17 +618,6 @@ describe('useJobMenu', () => {
)
})
it('does not export workflow when workflow data is unavailable', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed' }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(downloadBlobMock).not.toHaveBeenCalled()
})
it('prompts for filename when setting enabled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('custom-name')
@@ -865,24 +713,6 @@ describe('useJobMenu', () => {
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it('does not delete asset when preview disappears before click', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
await entry?.onClick?.()
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it('removes failed job via menu entry', async () => {
const taskRef = { id: 'task-1' }
const { jobMenuEntries } = mountJobMenu()

View File

@@ -79,10 +79,12 @@ describe(useQueueNotificationBanners, () => {
isImage?: boolean
} = {}
): MockTask => {
const { state = 'Completed', previewUrl, isImage = true } = options
// Only default the timestamp when the caller omitted the key, so an
// explicit `ts: undefined` really produces a task without a timestamp.
const ts = 'ts' in options ? options.ts : Date.now()
const {
state = 'Completed',
ts = Date.now(),
previewUrl,
isImage = true
} = options
const task: MockTask = {
displayStatus: state,
@@ -184,75 +186,6 @@ describe(useQueueNotificationBanners, () => {
}
})
it('converts a queued-pending notification waiting behind the active one', async () => {
const { unmount, composable } = mountComposable()
try {
mockApi.dispatchEvent(
new CustomEvent('promptQueued', {
detail: { requestId: 1, batchCount: 1 }
})
)
await nextTick()
mockApi.dispatchEvent(
new CustomEvent('promptQueueing', {
detail: { requestId: 2, batchCount: 3 }
})
)
mockApi.dispatchEvent(
new CustomEvent('promptQueued', {
detail: { requestId: 2, batchCount: 5 }
})
)
await nextTick()
expect(composable.currentNotification.value).toEqual({
type: 'queued',
count: 1,
requestId: 1
})
await vi.advanceTimersByTimeAsync(4000)
await nextTick()
expect(composable.currentNotification.value).toEqual({
type: 'queued',
count: 5,
requestId: 2
})
} finally {
unmount()
}
})
it('converts queued-pending notifications without request ids', async () => {
const { unmount, composable } = mountComposable()
try {
mockApi.dispatchEvent(
new CustomEvent('promptQueueing', {
detail: { batchCount: 2 }
})
)
await nextTick()
mockApi.dispatchEvent(
new CustomEvent('promptQueued', {
detail: { batchCount: 3 }
})
)
await nextTick()
expect(composable.currentNotification.value).toEqual({
type: 'queued',
count: 3
})
} finally {
unmount()
}
})
it('falls back to 1 when queued batch count is invalid', async () => {
const { unmount, composable } = mountComposable()
@@ -373,64 +306,6 @@ describe(useQueueNotificationBanners, () => {
}
})
it('shows failed notifications for failed-only batches', async () => {
const { unmount, composable } = mountComposable()
try {
await runBatch({
start: 5_000,
finish: 5_200,
tasks: [createTask({ state: 'Failed', ts: 5_050 })]
})
expect(composable.currentNotification.value).toEqual({
type: 'failed',
count: 1
})
} finally {
unmount()
}
})
it('does not notify for old or unfinished history entries', async () => {
const { unmount, composable } = mountComposable()
try {
await runBatch({
start: 6_000,
finish: 6_200,
tasks: [
createTask({ ts: 5_999 }),
createTask({ state: 'Running', ts: 6_050 }),
createTask({ state: 'Pending', ts: undefined })
]
})
expect(composable.currentNotification.value).toBeNull()
} finally {
unmount()
}
})
it('keeps no notification visible when an idle window has no finished tasks', async () => {
const { unmount, composable } = mountComposable()
try {
vi.setSystemTime(7_000)
executionStore().isIdle = false
await nextTick()
vi.setSystemTime(7_100)
executionStore().isIdle = true
queueStore().historyTasks = []
await nextTick()
expect(composable.currentNotification.value).toBeNull()
} finally {
unmount()
}
})
it('uses up to two completion thumbnails for notification icon previews', async () => {
const { unmount, composable } = mountComposable()

View File

@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { distribution, downloads } = vi.hoisted(() => ({
distribution: { isDesktop: false },
downloads: { values: [] as unknown[] }
}))
vi.mock('@/components/sidebar/tabs/ModelLibrarySidebarTab.vue', () => ({
default: {}
}))
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return distribution.isDesktop
}
}))
vi.mock('@/stores/electronDownloadStore', () => ({
useElectronDownloadStore: () => ({
inProgressDownloads: downloads.values
})
}))
describe('useModelLibrarySidebarTab', () => {
beforeEach(() => {
distribution.isDesktop = false
downloads.values = []
})
it('hides the badge outside desktop builds', async () => {
distribution.isDesktop = false
downloads.values = [{ id: 'download-1' }]
const { useModelLibrarySidebarTab } =
await import('./useModelLibrarySidebarTab')
const sidebarTab = useModelLibrarySidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
it('shows active desktop download count', async () => {
distribution.isDesktop = true
downloads.values = [{ id: 'a' }, { id: 'b' }]
const { useModelLibrarySidebarTab } =
await import('./useModelLibrarySidebarTab')
const sidebarTab = useModelLibrarySidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBe('2')
})
it('hides the badge when desktop has no active downloads', async () => {
distribution.isDesktop = true
const { useModelLibrarySidebarTab } =
await import('./useModelLibrarySidebarTab')
const sidebarTab = useModelLibrarySidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
})

View File

@@ -1,48 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { settings } = vi.hoisted(() => ({
settings: { newDesign: false }
}))
const legacyComponent = { name: 'NodeLibrarySidebarTab' }
const newDesignComponent = { name: 'NodeLibrarySidebarTabV2' }
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTab.vue', () => ({
default: legacyComponent
}))
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue', () => ({
default: newDesignComponent
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.NodeLibrary.NewDesign' && settings.newDesign
})
}))
describe('useNodeLibrarySidebarTab', () => {
beforeEach(() => {
settings.newDesign = false
})
it('uses the legacy node library component by default', async () => {
const { useNodeLibrarySidebarTab } =
await import('./useNodeLibrarySidebarTab')
const tab = useNodeLibrarySidebarTab()
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
expect(tab.component).toBe(legacyComponent)
})
it('uses the new node library component when the setting is enabled', async () => {
settings.newDesign = true
const { useNodeLibrarySidebarTab } =
await import('./useNodeLibrarySidebarTab')
const tab = useNodeLibrarySidebarTab()
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
expect(tab.component).toBe(newDesignComponent)
})
})

View File

@@ -1,138 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useTreeFolderOperations } from './useTreeFolderOperations'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function withI18n<T>(fn: () => T): T {
let result!: T
const app = createApp(
defineComponent({
setup() {
result = fn()
return () => null
}
})
)
app.use(i18n)
app.mount(document.createElement('div'))
return result
}
function makeFolder(
overrides: Partial<RenderedTreeExplorerNode> = {}
): RenderedTreeExplorerNode {
return {
key: 'root',
label: 'Root',
leaf: false,
children: [],
icon: 'pi pi-folder',
type: 'folder',
totalLeaves: 0,
...overrides
}
}
describe('useTreeFolderOperations', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(123)
})
it('creates a temporary editable folder under the selected target', () => {
const expandNode = vi.fn()
const target = makeFolder({ key: 'models', handleAddFolder: vi.fn() })
const operations = withI18n(() => useTreeFolderOperations(expandNode))
operations.addFolderCommand(target)
expect(expandNode).toHaveBeenCalledWith(target)
expect(operations.newFolderNode.value).toMatchObject({
key: 'models/new_folder_123',
label: '',
leaf: false,
icon: 'pi pi-folder',
type: 'folder',
isEditingLabel: true
})
})
it('passes the confirmed name to the target and clears temporary state', async () => {
const handleAddFolder = vi.fn()
const target = makeFolder({ handleAddFolder })
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
operations.addFolderCommand(target)
await operations.handleFolderCreation('New Folder')
expect(handleAddFolder).toHaveBeenCalledWith('New Folder')
expect(operations.newFolderNode.value).toBeNull()
})
it('clears temporary state even when folder creation fails', async () => {
const handleAddFolder = vi.fn().mockRejectedValue(new Error('failed'))
const target = makeFolder({ handleAddFolder })
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
operations.addFolderCommand(target)
await expect(operations.handleFolderCreation('New Folder')).rejects.toThrow(
'failed'
)
expect(operations.newFolderNode.value).toBeNull()
})
it('ignores folder creation when no target is pending', async () => {
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
await operations.handleFolderCreation('Unused')
expect(operations.newFolderNode.value).toBeNull()
})
it('returns a hidden menu item when the target cannot add folders', () => {
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
expect(operations.getAddFolderMenuItem(null)).toMatchObject({
label: 'g.newFolder',
visible: false,
isAsync: false
})
expect(
operations.getAddFolderMenuItem(makeFolder({ leaf: true }))
).toMatchObject({ visible: false })
expect(
operations.getAddFolderMenuItem(makeFolder({ leaf: false }))
).toMatchObject({ visible: false })
})
it('does nothing when the menu command fires without a target', () => {
const expandNode = vi.fn()
const operations = withI18n(() => useTreeFolderOperations(expandNode))
const item = operations.getAddFolderMenuItem(null)
expect(() =>
item.command?.({ originalEvent: new Event('click'), item })
).not.toThrow()
expect(expandNode).not.toHaveBeenCalled()
expect(operations.newFolderNode.value).toBeNull()
})
it('runs the add folder command from a visible menu item', () => {
const expandNode = vi.fn()
const target = makeFolder({ handleAddFolder: vi.fn() })
const operations = withI18n(() => useTreeFolderOperations(expandNode))
const item = operations.getAddFolderMenuItem(target)
expect(item.visible).toBe(true)
item.command?.({ originalEvent: new Event('click'), item })
expect(expandNode).toHaveBeenCalledWith(target)
expect(operations.newFolderNode.value?.key).toBe('root/new_folder_123')
})
})

View File

@@ -1,285 +0,0 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
type DropInput = {
clientX: number
clientY: number
}
type DropEvent = {
location: { current: { input: DropInput } }
source: { data: { type: string; data?: unknown } }
}
type DroppableOptions = {
getDropEffect: (
args: DropEvent
) => Exclude<DataTransfer['dropEffect'], 'none'>
onDrop: (event: DropEvent) => Promise<void>
}
const {
MockComfyModelDef,
MockComfyNodeDefImpl,
MockComfyWorkflow,
captured,
graph,
insertWorkflow,
addNodeOnGraph,
getNodeProvider,
getAllNodeProviders,
withNodeAddSource
} = vi.hoisted(() => {
class MockComfyNodeDefImpl {
name: string
constructor(name = 'NodeDef') {
this.name = name
}
}
class MockComfyModelDef {
directory: string
file_name: string
constructor(directory = 'checkpoints', fileName = 'model.safetensors') {
this.directory = directory
this.file_name = fileName
}
}
class MockComfyWorkflow {
id: string
constructor(id = 'workflow') {
this.id = id
}
}
return {
MockComfyModelDef,
MockComfyNodeDefImpl,
MockComfyWorkflow,
captured: {
options: undefined as DroppableOptions | undefined
},
graph: {
getNodeOnPos: vi.fn()
},
insertWorkflow: vi.fn(),
addNodeOnGraph: vi.fn(),
getNodeProvider: vi.fn(),
getAllNodeProviders: vi.fn(),
withNodeAddSource: vi.fn((_source: string, callback: () => unknown) =>
callback()
)
}
})
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: ([x, y]: [number, number]) => [x / 2, y / 2]
})
}))
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
usePragmaticDroppable: vi.fn(
(_target: () => HTMLCanvasElement | null, options: DroppableOptions) => {
captured.options = options
}
)
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: { NODE_TITLE_HEIGHT: 24 }
}))
vi.mock('@/platform/telemetry/nodeAdded/nodeAddSource', () => ({
withNodeAddSource
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({ insertWorkflow })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
ComfyWorkflow: MockComfyWorkflow
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph } }
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ addNodeOnGraph })
}))
vi.mock('@/stores/modelStore', () => ({
ComfyModelDef: MockComfyModelDef
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getNodeProvider,
getAllNodeProviders
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
ComfyNodeDefImpl: MockComfyNodeDefImpl
}))
function dropEvent(
data: unknown,
input: DropInput = { clientX: 20, clientY: 40 }
) {
return {
location: { current: { input } },
source: { data: { type: 'tree-explorer-node', data: { data } } }
}
}
function options() {
useCanvasDrop(ref(document.createElement('canvas')))
const value = captured.options
if (!value) throw new Error('droppable options were not registered')
return value
}
beforeEach(() => {
captured.options = undefined
graph.getNodeOnPos.mockReset()
insertWorkflow.mockReset()
addNodeOnGraph.mockReset()
getNodeProvider.mockReset()
getAllNodeProviders.mockReset()
withNodeAddSource.mockClear()
})
describe('useCanvasDrop', () => {
it('uses copy effect only for tree explorer nodes', () => {
const droppable = options()
expect(
droppable.getDropEffect({
...dropEvent(undefined),
source: { data: { type: 'tree-explorer-node' } }
})
).toBe('copy')
expect(
droppable.getDropEffect({
...dropEvent(undefined),
source: { data: { type: 'other' } }
})
).toBe('move')
})
it('adds dropped node definitions below the cursor', async () => {
const nodeDef = new MockComfyNodeDefImpl('KSampler')
const droppable = options()
await droppable.onDrop(dropEvent(nodeDef))
expect(withNodeAddSource).toHaveBeenCalledWith(
'sidebar_drag',
expect.any(Function)
)
expect(addNodeOnGraph).toHaveBeenCalledWith(nodeDef, {
pos: [10, 44]
})
})
it('ignores drops that do not come from tree explorer nodes', async () => {
const nodeDef = new MockComfyNodeDefImpl('KSampler')
const droppable = options()
await droppable.onDrop({
...dropEvent(nodeDef),
source: { data: { type: 'other', data: { data: nodeDef } } }
})
expect(addNodeOnGraph).not.toHaveBeenCalled()
expect(insertWorkflow).not.toHaveBeenCalled()
})
it('sets a model widget on an existing compatible node', async () => {
const widget = { name: 'ckpt_name', value: '' }
const node = { comfyClass: 'CheckpointLoaderSimple', widgets: [widget] }
const provider = {
key: 'ckpt_name',
nodeDef: { name: 'CheckpointLoaderSimple' }
}
graph.getNodeOnPos.mockReturnValue(node)
getAllNodeProviders.mockReturnValue([provider])
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
)
expect(widget.value).toBe('dream.safetensors')
expect(addNodeOnGraph).not.toHaveBeenCalled()
})
it('creates a provider node when the model has no compatible target', async () => {
const widget = { name: 'lora_name', value: '' }
const createdNode = { widgets: [widget] }
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
graph.getNodeOnPos.mockReturnValue(undefined)
getNodeProvider.mockReturnValue(provider)
addNodeOnGraph.mockReturnValue(createdNode)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
)
expect(addNodeOnGraph).toHaveBeenCalledWith(provider.nodeDef, {
pos: [10, 20]
})
expect(widget.value).toBe('style.safetensors')
})
it('does nothing for model drops without a compatible or default provider', async () => {
graph.getNodeOnPos.mockReturnValue({ comfyClass: 'OtherNode' })
getAllNodeProviders.mockReturnValue([
{ key: 'ckpt_name', nodeDef: { name: 'CheckpointLoaderSimple' } }
])
getNodeProvider.mockReturnValue(null)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
)
expect(addNodeOnGraph).not.toHaveBeenCalled()
})
it('does not set a model value when the target node lacks the provider widget', async () => {
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
const createdNode = { widgets: [{ name: 'other', value: '' }] }
graph.getNodeOnPos.mockReturnValue(undefined)
getNodeProvider.mockReturnValue(provider)
addNodeOnGraph.mockReturnValue(createdNode)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
)
expect(createdNode.widgets[0].value).toBe('')
})
it('inserts dropped workflows at the canvas position', async () => {
const workflow = new MockComfyWorkflow('wf-1')
const droppable = options()
await droppable.onDrop(dropEvent(workflow))
expect(insertWorkflow).toHaveBeenCalledWith(workflow, {
position: [10, 20]
})
})
})

View File

@@ -1,250 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IContextMenuOptions } from '@/lib/litegraph/src/interfaces'
const mockInstall = vi.hoisted(() => vi.fn())
const mockRegisterWrapper = vi.hoisted(() => vi.fn())
const mockExtractLegacyItems = vi.hoisted(() => vi.fn())
const mockCollectCanvasMenuItems = vi.hoisted(() => vi.fn())
const mockCollectNodeMenuItems = vi.hoisted(() => vi.fn())
const mockSt = vi.hoisted(() => vi.fn())
const mockTe = vi.hoisted(() => vi.fn())
const mockContextMenuConstructor = vi.hoisted(() => vi.fn())
const mockClasses = vi.hoisted(() => {
class LGraphCanvas {
getCanvasMenuOptions() {
return [{ content: 'Base' }, null]
}
getNodeMenuOptions(node: unknown) {
return [{ content: `Node:${String(node)}` }]
}
}
class ContextMenu {
constructor(values: unknown, options: unknown) {
mockContextMenuConstructor(values, options)
}
}
return {
LGraphCanvas,
ContextMenu,
LiteGraph: {
ContextMenu
}
}
})
vi.mock('@/lib/litegraph/src/contextMenuCompat', () => ({
legacyMenuCompat: {
install: mockInstall,
registerWrapper: mockRegisterWrapper,
extractLegacyItems: mockExtractLegacyItems
}
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LGraphCanvas: mockClasses.LGraphCanvas,
LiteGraph: mockClasses.LiteGraph
}))
vi.mock('@/scripts/app', () => ({
app: {
collectCanvasMenuItems: mockCollectCanvasMenuItems,
collectNodeMenuItems: mockCollectNodeMenuItems
}
}))
vi.mock('@/i18n', () => ({
st: mockSt,
te: mockTe
}))
vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: (value: string) => `normalized-${value}`
}))
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useContextMenuTranslation } from './useContextMenuTranslation'
describe('useContextMenuTranslation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollectCanvasMenuItems.mockReturnValue([{ content: 'NewApi' }])
mockCollectNodeMenuItems.mockReturnValue([{ content: 'NodeApi' }])
mockExtractLegacyItems.mockReturnValue([{ content: 'Legacy' }])
mockSt.mockImplementation((_key: string, fallback: string) => {
return `translated:${fallback}`
})
mockTe.mockImplementation(
(key: string) => key === 'contextMenu.TranslateMe'
)
mockClasses.LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base' }, null]
}
mockClasses.LGraphCanvas.prototype.getNodeMenuOptions = function (
node: unknown
) {
return [{ content: `Node:${String(node)}` }]
}
mockClasses.LiteGraph.ContextMenu = mockClasses.ContextMenu
})
it('wraps canvas menu options with new API, legacy, and translated items', () => {
useContextMenuTranslation()
const canvas = new mockClasses.LGraphCanvas()
const result = canvas.getCanvasMenuOptions()
expect(mockInstall).toHaveBeenCalledWith(
mockClasses.LGraphCanvas.prototype,
'getCanvasMenuOptions'
)
expect(mockCollectCanvasMenuItems).toHaveBeenCalledWith(canvas)
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
'getCanvasMenuOptions',
canvas
)
expect(result).toEqual([
{ content: 'translated:Base' },
null,
{ content: 'translated:NewApi' },
{ content: 'translated:Legacy' }
])
})
it('wraps node menu options with new API and legacy extension items', () => {
useContextMenuTranslation()
const canvas = new mockClasses.LGraphCanvas()
const result = canvas.getNodeMenuOptions('node')
expect(mockInstall).toHaveBeenCalledWith(
mockClasses.LGraphCanvas.prototype,
'getNodeMenuOptions'
)
expect(mockCollectNodeMenuItems).toHaveBeenCalledWith('node')
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
'getNodeMenuOptions',
canvas,
'node'
)
expect(result).toEqual([
{ content: 'Node:node' },
{ content: 'NodeApi' },
{ content: 'Legacy' }
])
})
it('translates LiteGraph context menu titles, nested items, and conversion labels', () => {
useContextMenuTranslation()
const values = [
{ content: 'TranslateMe' },
{ content: 'Convert seed to input' },
{ content: 'Convert value to widget' },
{
content: '',
submenu: {
options: [{ content: 'TranslateMe' }]
}
},
'separator'
]
const options = {
title: 'KSampler',
extra: {
inputs: [{ name: 'seed', label: 'Seed Label' }],
widgets: [{ name: 'value', label: 'Value Label' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(options.title).toBe('translated:KSampler')
expect(values).toMatchObject([
{ content: 'translated:TranslateMe' },
{ content: 'translated:Convert Seed Labeltranslated: to input' },
{ content: 'translated:Convert Value Labeltranslated: to widget' },
{
submenu: {
options: [{ content: 'translated:TranslateMe' }]
}
},
'separator'
])
expect(mockContextMenuConstructor).toHaveBeenCalledWith(values, options)
})
it('uses parent menu extra data when direct options do not provide it', () => {
useContextMenuTranslation()
const values = [{ content: 'Convert latent to input' }]
const options = {
parentMenu: {
options: {
extra: {
inputs: [{ name: 'latent', label: 'Latent Label' }]
}
}
}
}
new LiteGraph.ContextMenu(
values,
fromPartial<IContextMenuOptions<unknown, unknown>>(options)
)
expect(values[0].content).toBe(
'translated:Convert Latent Labeltranslated: to input'
)
})
it('keeps conversion names when matching inputs and widgets have no label', () => {
useContextMenuTranslation()
const values = [
{ content: 'Convert seed to input' },
{ content: 'Convert value to widget' }
]
const options = {
extra: {
inputs: [{ name: 'seed' }],
widgets: [{ name: 'value' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(values).toMatchObject([
{ content: 'translated:Convert seedtranslated: to input' },
{ content: 'translated:Convert valuetranslated: to widget' }
])
})
it('uses widget labels when input conversion names do not match inputs', () => {
useContextMenuTranslation()
const values = [{ content: 'Convert seed to input' }]
const options = {
extra: {
inputs: [{ name: 'other' }],
widgets: [{ name: 'seed', label: 'Widget Seed' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(values[0].content).toBe(
'translated:Convert Widget Seedtranslated: to input'
)
})
it('keeps plain unregistered menu items unchanged', () => {
useContextMenuTranslation()
const values = [{ content: 'Plain' }]
new LiteGraph.ContextMenu(values, {})
expect(values[0].content).toBe('Plain')
})
})

View File

@@ -1,125 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { DIALOG_KEY, useEditKeybindingDialog } from './useEditKeybindingDialog'
const mockShowSmallLayoutDialog = vi.fn()
const mockGetKeybinding = vi.fn()
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showSmallLayoutDialog: mockShowSmallLayoutDialog
})
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybinding: mockGetKeybinding
})
}))
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue',
() => ({
default: { name: 'EditKeybindingContent' }
})
)
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue',
() => ({
default: { name: 'EditKeybindingFooter' }
})
)
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue',
() => ({
default: { name: 'EditKeybindingHeader' }
})
)
function makeCombo(label: string): KeyComboImpl {
const combo: unknown = {
label,
equals: vi.fn((other: { label: string }) => other.label === label)
}
return combo as KeyComboImpl
}
describe('useEditKeybindingDialog', () => {
it('opens the edit dialog with default edit state', () => {
const currentCombo = makeCombo('Ctrl+A')
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo
})
expect(mockShowSmallLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: DIALOG_KEY,
props: expect.objectContaining({
commandLabel: 'Test command'
})
})
)
const dialog = mockShowSmallLayoutDialog.mock.calls[0][0]
expect(dialog.props.dialogState).toMatchObject({
commandId: 'app.test',
newCombo: currentCombo,
currentCombo,
mode: 'edit',
existingBinding: null
})
expect(dialog.footerProps.dialogState).toBe(dialog.props.dialogState)
})
it('updates combo state and reports a conflicting binding', () => {
const currentCombo = makeCombo('Ctrl+A')
const newCombo = makeCombo('Ctrl+B')
const binding = fromPartial<KeybindingImpl>({
commandId: 'other.command'
})
mockGetKeybinding.mockReturnValue(binding)
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo,
mode: 'add',
existingBinding: binding
})
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
dialog.props.onUpdateCombo(newCombo)
expect(dialog.props.dialogState.newCombo).toMatchObject({
label: 'Ctrl+B'
})
expect(dialog.props.existingKeybindingOnCombo.value).toBe(binding)
expect(mockGetKeybinding.mock.calls.at(-1)?.[0]).toMatchObject({
label: 'Ctrl+B'
})
})
it('does not report a conflict for an unchanged or empty combo', () => {
const currentCombo = makeCombo('Ctrl+A')
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo
})
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
dialog.props.dialogState.newCombo = null
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
})
})

View File

@@ -30,14 +30,6 @@ vi.mock('@/platform/distribution/types', () => ({
describe('useFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
localStorage.clear()
remoteConfig.value = {}
remoteConfigState.value = 'unloaded'
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
vi.mocked(distributionTypes).isCloud = false
vi.mocked(distributionTypes).isNightly = false
})
describe('flags object', () => {
@@ -234,69 +226,6 @@ describe('useFeatureFlags', () => {
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('teamWorkspacesEnabled is disabled outside cloud builds', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
})
it('teamWorkspacesEnabled uses the cached value before authenticated config loads', () => {
vi.mocked(distributionTypes).isCloud = true
cachedTeamWorkspacesEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
})
it('teamWorkspacesEnabled falls back to the server after authenticated config loads', () => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'authenticated'
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
false
)
})
it('nodeLibraryEssentialsEnabled checks config outside nightly and dev builds', () => {
vi.stubEnv('DEV', false)
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
false
)
})
it('nodeLibraryEssentialsEnabled uses remote config before the server fallback', () => {
vi.stubEnv('DEV', false)
remoteConfig.value = {
node_library_essentials_enabled: true
}
const { flags } = useFeatureFlags()
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')

View File

@@ -1,592 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, nextTick } from 'vue'
import type { App } from 'vue'
const mockStats = {
min: 0,
max: 4,
mean: 1,
stdDev: 0.5,
nanCount: 0,
infCount: 0
}
const mockHistograms = {
r: new Uint32Array([1]),
g: new Uint32Array([2]),
b: new Uint32Array([3]),
a: new Uint32Array([4]),
luminance: new Uint32Array([5])
}
interface MockTexture {
type: string
image: {
width: number
height: number
data: ArrayLike<number>
}
colorSpace?: string
minFilter?: string
magFilter?: string
needsUpdate?: boolean
dispose: ReturnType<typeof vi.fn>
}
const mocks = vi.hoisted(() => ({
exrLoad: vi.fn(),
exrSetDataType: vi.fn(),
rgbeLoad: vi.fn(),
render: vi.fn(),
setPixelRatio: vi.fn(),
setClearColor: vi.fn(),
setSize: vi.fn(),
updateProjectionMatrix: vi.fn(),
positionSet: vi.fn(),
scaleSet: vi.fn(),
sceneAdd: vi.fn(),
materialDispose: vi.fn(),
geometryDispose: vi.fn(),
viewportObserveResize: vi.fn(),
viewportDisposeRenderer: vi.fn(),
textureDispose: vi.fn(),
raycasterSetFromCamera: vi.fn(),
intersectObject: vi.fn(),
fromHalfFloat: vi.fn((value: number) => value + 0.5),
matrixSet: vi.fn(),
detectGamutFromChromaticities: vi.fn(() => 'Display P3'),
gamutToSrgbMatrix: vi.fn(() => [1, 0, 0, 0, 1, 0, 0, 0, 1]),
computeImageStats: vi.fn(() => mockStats),
computeChannelHistograms: vi.fn(() => mockHistograms),
lastCanvas: undefined as HTMLCanvasElement | undefined
}))
vi.mock('three', () => {
class WebGLRenderer {
domElement: HTMLCanvasElement
outputColorSpace?: string
constructor() {
const canvas = document.createElement('canvas')
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
width: 100,
height: 100,
right: 100,
bottom: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) satisfies DOMRect
this.domElement = canvas
mocks.lastCanvas = canvas
}
setPixelRatio(value: number) {
mocks.setPixelRatio(value)
}
setClearColor(color: number, alpha: number) {
mocks.setClearColor(color, alpha)
}
setSize(width: number, height: number, updateStyle: boolean) {
mocks.setSize(width, height, updateStyle)
}
render(scene: unknown, camera: unknown) {
mocks.render(scene, camera)
}
}
class Scene {
add(mesh: unknown) {
mocks.sceneAdd(mesh)
}
}
class OrthographicCamera {
left = -1
right = 1
top = 1
bottom = -1
zoom = 1
position = {
x: 0,
y: 0,
z: 1,
set: (x: number, y: number, z: number) => {
this.position.x = x
this.position.y = y
this.position.z = z
mocks.positionSet(x, y, z)
}
}
updateProjectionMatrix() {
mocks.updateProjectionMatrix()
}
}
class Matrix3 {
set(...values: number[]) {
mocks.matrixSet(values)
}
}
class ShaderMaterial {
uniforms: Record<string, { value: unknown }>
constructor(options: { uniforms: Record<string, { value: unknown }> }) {
this.uniforms = options.uniforms
}
dispose() {
mocks.materialDispose()
}
}
class Mesh {
scale = { set: mocks.scaleSet }
geometry = { dispose: mocks.geometryDispose }
constructor(
readonly geometryInput: unknown,
readonly materialInput: unknown
) {}
}
class PlaneGeometry {
constructor(
readonly width: number,
readonly height: number
) {}
}
class Raycaster {
setFromCamera(pointer: unknown, camera: unknown) {
mocks.raycasterSetFromCamera(pointer, camera)
}
intersectObject(mesh: unknown) {
return mocks.intersectObject(mesh)
}
}
class Vector2 {
constructor(
public x = 0,
public y = 0
) {}
}
return {
WebGLRenderer,
Scene,
OrthographicCamera,
Matrix3,
ShaderMaterial,
Mesh,
PlaneGeometry,
Raycaster,
Vector2,
DataUtils: { fromHalfFloat: mocks.fromHalfFloat },
MathUtils: {
clamp: (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
},
FloatType: 'FloatType',
HalfFloatType: 'HalfFloatType',
LinearSRGBColorSpace: 'LinearSRGBColorSpace',
LinearFilter: 'LinearFilter',
GLSL3: 'GLSL3'
}
})
vi.mock('three/examples/jsm/loaders/EXRLoader', () => ({
EXRLoader: class {
setDataType(type: string) {
mocks.exrSetDataType(type)
}
load(
url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void,
onProgress: unknown,
onError: (error: unknown) => void
) {
mocks.exrLoad(url, onLoad, onProgress, onError)
}
}
}))
vi.mock('three/examples/jsm/loaders/RGBELoader', () => ({
RGBELoader: class {
load(
url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void,
onProgress: unknown,
onError: (error: unknown) => void
) {
mocks.rgbeLoad(url, onLoad, onProgress, onError)
}
}
}))
vi.mock('@/renderer/three/WebGLViewport', () => ({
WebGLViewport: class {
constructor(readonly renderer: unknown) {}
observeResize(container: HTMLElement, resize: () => void) {
mocks.viewportObserveResize(container, resize)
}
disposeRenderer() {
mocks.viewportDisposeRenderer()
}
}
}))
vi.mock('@/renderer/hdr/colorGamut', () => ({
detectGamutFromChromaticities: mocks.detectGamutFromChromaticities,
gamutToSrgbMatrix: mocks.gamutToSrgbMatrix
}))
vi.mock('@/renderer/hdr/hdrStats', () => ({
computeImageStats: mocks.computeImageStats,
computeChannelHistograms: mocks.computeChannelHistograms
}))
import { CHANNEL_MODES, useHdrViewer } from './useHdrViewer'
type HdrViewer = ReturnType<typeof useHdrViewer>
let mountedApps: App[] = []
function createViewer(): HdrViewer {
let viewer: HdrViewer | undefined
const app = createApp(
defineComponent({
setup() {
viewer = useHdrViewer()
return () => null
}
})
)
app.mount(document.createElement('div'))
mountedApps.push(app)
if (!viewer) throw new Error('Expected useHdrViewer to initialize')
return viewer
}
function makeTexture(
data: ArrayLike<number> = [0, 0.25, 0.5, 1, 1, 2, 3, 4],
width = 2,
height = 1,
type = 'FloatType'
): MockTexture {
return {
type,
image: { width, height, data },
dispose: mocks.textureDispose
}
}
function makeContainer(): HTMLElement {
const container = document.createElement('div')
Object.defineProperty(container, 'clientWidth', { value: 200 })
Object.defineProperty(container, 'clientHeight', { value: 100 })
return container
}
describe('useHdrViewer', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
mocks.lastCanvas = undefined
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture())
)
mocks.rgbeLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture(), { header: { chromaticities: {} } })
)
mocks.intersectObject.mockReturnValue([])
})
afterEach(() => {
for (const app of mountedApps) app.unmount()
mountedApps = []
vi.unstubAllGlobals()
})
it('exposes all channel modes', () => {
expect(CHANNEL_MODES).toEqual(['rgb', 'r', 'g', 'b', 'a', 'luminance'])
})
it('mounts hdr textures through the RGBE loader and exposes image metadata', async () => {
const viewer = createViewer()
const container = makeContainer()
await viewer.mount(container, '/api/view?filename=scene.hdr')
expect(mocks.rgbeLoad).toHaveBeenCalledWith(
'/api/view?filename=scene.hdr',
expect.any(Function),
undefined,
expect.any(Function)
)
expect(mocks.exrSetDataType).not.toHaveBeenCalled()
expect(viewer.loading.value).toBe(false)
expect(viewer.error.value).toBeNull()
expect(viewer.gamut.value).toBe('Display P3')
expect(viewer.dimensions.value).toBe('2 x 1')
expect(viewer.stats.value).toEqual(mockStats)
expect(viewer.histogram.value).toBe(mockHistograms.luminance)
viewer.channel.value = 'g'
await nextTick()
expect(viewer.histogram.value).toBe(mockHistograms.g)
expect(container.contains(mocks.lastCanvas!)).toBe(true)
})
it('selects histograms for every channel mode', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.hdr')
for (const [mode, histogram] of [
['r', mockHistograms.r],
['g', mockHistograms.g],
['b', mockHistograms.b],
['a', mockHistograms.a],
['rgb', mockHistograms.luminance],
['luminance', mockHistograms.luminance]
] as const) {
viewer.channel.value = mode
await nextTick()
expect(viewer.histogram.value).toBe(histogram)
}
})
it('loads exr textures with float data and reads hovered pixels', async () => {
const data = [0, 1, 2, 3, 4, 5, 6, 7]
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture(data, 2, 1, 'HalfFloatType'))
)
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
)
expect(mocks.exrSetDataType).toHaveBeenCalledWith('FloatType')
expect(mocks.fromHalfFloat).toHaveBeenCalled()
expect(viewer.pixel.value).toEqual({
x: 1,
y: 0,
r: 4.5,
g: 5.5,
b: 6.5,
a: 7.5
})
})
it('reads three-channel pixels without alpha', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture([0, 1, 2, 3, 4, 5], 2, 1))
)
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
)
expect(viewer.pixel.value).toEqual({
x: 1,
y: 0,
r: 3,
g: 4,
b: 5,
a: null
})
})
it('clears the hovered pixel when the pointer leaves or misses the mesh', async () => {
mocks.intersectObject.mockReturnValueOnce([{ uv: { x: 0, y: 0 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
expect(viewer.pixel.value).not.toBeNull()
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointerleave'))
expect(viewer.pixel.value).toBeNull()
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
expect(viewer.pixel.value).toBeNull()
})
it('normalizes exposure and disposes renderer resources', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
viewer.normalizeExposure()
viewer.dispose()
expect(viewer.exposureStops.value).toBe(-2)
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
expect(mocks.textureDispose).toHaveBeenCalled()
expect(mocks.materialDispose).toHaveBeenCalled()
expect(mocks.geometryDispose).toHaveBeenCalled()
})
it('handles no-op viewer actions before mounting', () => {
const viewer = createViewer()
viewer.fitView()
viewer.normalizeExposure()
viewer.dispose()
expect(viewer.exposureStops.value).toBe(0)
expect(mocks.viewportDisposeRenderer).not.toHaveBeenCalled()
})
it('leaves sample-derived state empty when texture data is missing', async () => {
const noData: unknown = undefined
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) =>
onLoad({
...makeTexture(),
image: {
width: 2,
height: 1,
data: noData as ArrayLike<number>
}
})
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
viewer.normalizeExposure()
expect(viewer.dimensions.value).toBe('2 x 1')
expect(viewer.stats.value).toBeNull()
expect(viewer.histogram.value).toBeNull()
expect(viewer.exposureStops.value).toBe(0)
})
it('disposes textures that finish loading after viewer disposal', async () => {
let resolveLoad: (
texture: MockTexture,
textureData?: unknown
) => void = () => {}
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => {
resolveLoad = onLoad
}
)
const viewer = createViewer()
const mounting = viewer.mount(
makeContainer(),
'/api/view?filename=scene.exr'
)
viewer.dispose()
resolveLoad(makeTexture())
await mounting
expect(mocks.textureDispose).toHaveBeenCalled()
})
it('reports loader errors and clears loading state', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
_onProgress: unknown,
onError: (error: unknown) => void
) => onError(new Error('load failed'))
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
expect(viewer.error.value).toBe('load failed')
expect(viewer.loading.value).toBe(false)
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
})
it('reports string loader errors', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
_onProgress: unknown,
onError: (error: unknown) => void
) => onError('load failed')
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
expect(viewer.error.value).toBe('load failed')
})
it('zooms with the wheel and pans while dragging', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000 }))
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
window.dispatchEvent(
new PointerEvent('pointermove', { clientX: 20, clientY: 30 })
)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(mocks.updateProjectionMatrix).toHaveBeenCalled()
expect(mocks.render).toHaveBeenCalled()
})
it('ignores hover sampling while dragging', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
mocks.raycasterSetFromCamera.mockClear()
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(mocks.raycasterSetFromCamera).not.toHaveBeenCalled()
})
})

View File

@@ -1,189 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent } from 'vue'
const mockSettingGet = vi.hoisted(() => vi.fn())
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
const mockReleaseStore = vi.hoisted(() => ({
shouldShowRedDot: { value: false },
initialize: vi.fn()
}))
const mockHelpCenterStore = vi.hoisted(() => ({
isVisible: { value: false },
toggle: vi.fn(),
hide: vi.fn()
}))
const mockConflictDetection = vi.hoisted(() => ({
shouldShowConflictModalAfterUpdate: vi.fn()
}))
const mockShowNodeConflictDialog = vi.hoisted(() => vi.fn())
const mockConflictAcknowledgment = vi.hoisted(() => ({
shouldShowRedDot: { value: false },
markConflictsAsSeen: vi.fn()
}))
vi.mock('pinia', () => ({
storeToRefs: (store: Record<string, unknown>) => store
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: mockSettingGet })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => mockReleaseStore
}))
vi.mock('@/stores/helpCenterStore', () => ({
useHelpCenterStore: () => mockHelpCenterStore
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: () => mockConflictDetection
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
() => ({
useNodeConflictDialog: () => ({ show: mockShowNodeConflictDialog })
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: () => mockConflictAcknowledgment
})
)
import { useHelpCenter } from './useHelpCenter'
function mountHelpCenter() {
let result: ReturnType<typeof useHelpCenter> | undefined
const app = createApp(
defineComponent({
setup() {
result = useHelpCenter()
return () => null
}
})
)
app.mount(document.createElement('div'))
if (!result) throw new Error('Expected help center composable to initialize')
return { app, result }
}
describe('useHelpCenter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingGet.mockReturnValue('left')
mockReleaseStore.shouldShowRedDot.value = false
mockHelpCenterStore.isVisible.value = false
mockHelpCenterStore.toggle.mockImplementation(() => {
mockHelpCenterStore.isVisible.value = !mockHelpCenterStore.isVisible.value
})
mockHelpCenterStore.hide.mockImplementation(() => {
mockHelpCenterStore.isVisible.value = false
})
mockConflictAcknowledgment.shouldShowRedDot.value = false
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
false
)
})
it('initializes releases on mount and exposes store-backed computed state', async () => {
mockReleaseStore.shouldShowRedDot.value = true
const { app, result } = mountHelpCenter()
expect(mockReleaseStore.initialize).toHaveBeenCalledOnce()
expect(result.isHelpCenterVisible.value).toBe(false)
expect(result.shouldShowRedDot.value).toBe(true)
expect(result.sidebarLocation.value).toBe('left')
app.unmount()
})
it('uses the conflict red dot when the release red dot is hidden', () => {
mockConflictAcknowledgment.shouldShowRedDot.value = true
const { app, result } = mountHelpCenter()
expect(result.shouldShowRedDot.value).toBe(true)
app.unmount()
})
it('tracks and toggles help center visibility', () => {
const { app, result } = mountHelpCenter()
result.toggleHelpCenter()
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
})
expect(mockHelpCenterStore.toggle).toHaveBeenCalledOnce()
expect(mockHelpCenterStore.isVisible.value).toBe(true)
result.closeHelpCenter()
expect(mockHelpCenterStore.hide).toHaveBeenCalledOnce()
expect(mockHelpCenterStore.isVisible.value).toBe(false)
app.unmount()
})
it('opens the conflict modal after the whats-new dialog when needed', async () => {
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
true
)
const { app, result } = mountHelpCenter()
await result.handleWhatsNewDismissed()
expect(mockShowNodeConflictDialog).toHaveBeenCalledWith({
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: expect.any(Function)
}
})
const options = mockShowNodeConflictDialog.mock.calls[0][0]
options.dialogComponentProps.onClose()
expect(
mockConflictAcknowledgment.markConflictsAsSeen
).toHaveBeenCalledOnce()
app.unmount()
})
it('does not open the conflict modal when not needed', async () => {
const { app, result } = mountHelpCenter()
await result.handleWhatsNewDismissed()
expect(mockShowNodeConflictDialog).not.toHaveBeenCalled()
app.unmount()
})
it('logs conflict-check failures without throwing', async () => {
const error = new Error('failed')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockRejectedValue(
error
)
const { app, result } = mountHelpCenter()
await expect(result.handleWhatsNewDismissed()).resolves.toBeUndefined()
expect(consoleError).toHaveBeenCalledWith(
'[HelpCenter] Error checking conflict modal:',
error
)
app.unmount()
})
})

View File

@@ -137,7 +137,7 @@ function mountContainerLayout(
function makePointerEvent(
type: 'pointerdown' | 'pointermove' | 'pointerup',
target: EventTarget,
target: HTMLElement,
clientX: number,
clientY: number
) {
@@ -188,8 +188,7 @@ async function mountHarness(nodeId: NodeId = toNodeId(2)) {
const el = document.createElement('div')
document.body.appendChild(el)
const app = createApp(ImageCropHarness, { nodeId: Number(nodeId) })
const mounted: unknown = app.mount(el)
const vm = mounted as CropVm
const vm = app.mount(el) as unknown as CropVm
await nextTick()
await Promise.resolve()
harnessCleanups.push(() => {
@@ -303,32 +302,6 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBeNull()
})
it('returns null when a subgraph output link cannot be resolved', async () => {
const subgraphInput = createMockSubgraphNode([], {
id: 40,
resolveSubgraphOutputLink: vi.fn(() => undefined)
})
const sgCrop = createMockLGraphNode({
id: 2,
getInputNode: vi.fn(() => subgraphInput),
getInputLink: vi.fn(() => ({ origin_slot: 0 })),
isSubgraphNode: () => false
})
mockResolveNode.mockReturnValue(sgCrop)
const vm = await mountHarness()
expect(vm.imageUrl).toBeNull()
})
it('returns null when the source node has no image URLs', async () => {
mockGetNodeImageUrls.mockImplementation((n) =>
n === sourceNode ? [] : null
)
const vm = await mountHarness()
expect(vm.imageUrl).toBeNull()
})
it('resolves image through a subgraph input node', async () => {
const innerSource = createMockLGraphNode({
id: 50,
@@ -367,18 +340,6 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBe('https://example.com/b.png')
})
it('keeps loading unchanged when output updates keep the same URL', async () => {
const vm = await mountHarness()
;(vm.handleImageLoad as () => void)()
expect(vm.isLoading).toBe(false)
outputStore.nodeOutputs['touch'] = { updated: true }
await flushTicks()
expect(vm.imageUrl).toBe('https://example.com/a.png')
expect(vm.isLoading).toBe(false)
})
it('updates imageUrl when nodePreviewImages change', async () => {
let url = 'https://example.com/a.png'
mockGetNodeImageUrls.mockImplementation((n) =>
@@ -429,30 +390,6 @@ describe('useImageCrop', () => {
expect(parseFloat(style.height)).toBeCloseTo(80, 1)
})
it('ignores resize observer callbacks before an image is rendered', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
flushResizeObservers()
expect(vm.imageUrl).toBeNull()
expect(vm.cropBoxStyle).toEqual({
left: '38px',
top: '38px',
width: '160px',
height: '120px'
})
})
it('uses default crop dimensions when model dimensions are zero', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 0, y: 0, width: 0, height: 0 }
expect(vm.cropWidth).toBe(512)
expect(vm.cropHeight).toBe(512)
})
it('exposes eight resize handles when unlocked and four when locked', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
@@ -486,48 +423,6 @@ describe('useImageCrop', () => {
expect(vm.isLoading).toBe(false)
})
it('uses fallback scale when dragging before image dimensions are known', async () => {
const vm = await mountHarness()
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
dragStart(makePointerEvent('pointerdown', captureEl, 10, 10))
dragMove(makePointerEvent('pointermove', captureEl, 30, 30))
dragEnd(makePointerEvent('pointerup', captureEl, 30, 30))
expect(vm.modelValue.x).toBe(0)
expect(vm.modelValue.y).toBe(0)
})
it('uses fallback scale when rendered container width is missing', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
mountContainerLayout(vm.$el, 0, 300, 0)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 130, 80), 'right')
resizeMove(makePointerEvent('pointermove', captureEl, 150, 80))
resizeEnd(makePointerEvent('pointerup', captureEl, 150, 80))
expect(vm.modelValue.width).toBe(140)
})
it('does not start dragging when there is no image', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
@@ -541,41 +436,6 @@ describe('useImageCrop', () => {
expect(vm.cropX as number).toBe(xBefore)
})
it('ignores drag move and end before dragging starts', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const releaseEl = document.createElement('div')
releaseEl.releasePointerCapture = vi.fn()
;(vm.handleDragMove as (e: PointerEvent) => void)(
makePointerEvent('pointermove', releaseEl, 260, 180)
)
;(vm.handleDragEnd as (e: PointerEvent) => void)(
makePointerEvent('pointerup', releaseEl, 260, 180)
)
expect(vm.modelValue).toEqual({ x: 10, y: 10, width: 120, height: 90 })
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
})
it('drags without pointer capture when the event target is not an element', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
dragStart(makePointerEvent('pointerdown', document, 200, 150))
dragMove(makePointerEvent('pointermove', document, 260, 180))
dragEnd(makePointerEvent('pointerup', document, 260, 180))
expect(vm.modelValue.x).toBeGreaterThan(10)
expect(vm.modelValue.y).toBeGreaterThan(10)
})
it('drags the crop box in image space and ends on pointerup', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
@@ -646,62 +506,6 @@ describe('useImageCrop', () => {
expect(vm.modelValue.height).toBeLessThan(200)
})
it('resizes from the left edge and clamps to the image origin', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 500, 500)
vm.modelValue = { x: 50, y: 50, width: 120, height: 100 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 100, 120), 'left')
resizeMove(makePointerEvent('pointermove', captureEl, -100, 120))
resizeEnd(makePointerEvent('pointerup', captureEl, -100, 120))
expect(vm.modelValue.x).toBe(0)
expect(vm.modelValue.width).toBeGreaterThan(120)
})
it('ignores resize move and end before resizing starts', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 40, y: 40, width: 120, height: 120 }
const releaseEl = document.createElement('div')
releaseEl.releasePointerCapture = vi.fn()
;(vm.handleResizeMove as (e: PointerEvent) => void)(
makePointerEvent('pointermove', releaseEl, 360, 360)
)
;(vm.handleResizeEnd as (e: PointerEvent) => void)(
makePointerEvent('pointerup', releaseEl, 360, 360)
)
expect(vm.modelValue).toEqual({ x: 40, y: 40, width: 120, height: 120 })
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
})
it('does not start resizing when there is no image', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
;(vm.handleResizeStart as (e: PointerEvent, dir: string) => void)(
makePointerEvent('pointerdown', captureEl, 20, 20),
'right'
)
expect(captureEl.setPointerCapture).not.toHaveBeenCalled()
})
it('applies a preset aspect ratio and clamps height to the image', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 800, 500)
@@ -720,25 +524,6 @@ describe('useImageCrop', () => {
expect(vm.isLockEnabled).toBe(false)
})
it('ignores unknown aspect-ratio presets and unlocks explicit lock changes', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 0, y: 0, width: 160, height: 120 }
vm.selectedRatio = 'missing'
expect(vm.selectedRatio).toBe('custom')
expect(vm.isLockEnabled).toBe(false)
vm.isLockEnabled = true
expect(vm.selectedRatio).toBe('4:3')
vm.isLockEnabled = true
expect(vm.selectedRatio).toBe('4:3')
vm.isLockEnabled = false
expect(vm.selectedRatio).toBe('custom')
})
it('shows custom in the ratio label when lock does not match a preset', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
@@ -798,58 +583,6 @@ describe('useImageCrop', () => {
expect(vm.modelValue.y + vm.modelValue.height).toBeLessThanOrEqual(400)
})
it('clamps constrained north-west resize to the image top-left bounds', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 20, y: 20, width: 80, height: 80 }
vm.isLockEnabled = true
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 40, 40), 'nw')
resizeMove(makePointerEvent('pointermove', captureEl, -200, -200))
resizeEnd(makePointerEvent('pointerup', captureEl, -200, -200))
expect(vm.modelValue.x).toBeGreaterThanOrEqual(0)
expect(vm.modelValue.y).toBeGreaterThanOrEqual(0)
expect(vm.modelValue.width).toBeGreaterThanOrEqual(16)
expect(vm.modelValue.height).toBeGreaterThanOrEqual(16)
})
it('clamps constrained corner resize to minimum dimensions', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 40, y: 40, width: 160, height: 80 }
vm.isLockEnabled = true
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 200, 120), 'se')
resizeMove(makePointerEvent('pointermove', captureEl, -800, -800))
resizeEnd(makePointerEvent('pointerup', captureEl, -800, -800))
expect(vm.modelValue.width).toBe(32)
expect(vm.modelValue.height).toBe(16)
})
it('ends resize and clears direction on pointerup', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)

View File

@@ -1,135 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createApp, h, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import type { Ref } from 'vue'
type ObserverInit = ConstructorParameters<typeof IntersectionObserver>[1]
type ObserverCallback = ConstructorParameters<typeof IntersectionObserver>[0]
const observers: MockIntersectionObserver[] = []
class MockIntersectionObserver {
readonly callback: ObserverCallback
readonly options?: ObserverInit
readonly observe = vi.fn()
readonly unobserve = vi.fn()
readonly disconnect = vi.fn()
constructor(callback: ObserverCallback, options?: ObserverInit) {
this.callback = callback
this.options = options
observers.push(this)
}
}
function mountObserver(
target: Ref<Element | null>,
callback: IntersectionObserverCallback,
options: Parameters<typeof useIntersectionObserver>[2] = {}
) {
let result: ReturnType<typeof useIntersectionObserver> | undefined
const app = createApp({
setup() {
result = useIntersectionObserver(target, callback, options)
return () => h('div')
}
})
app.mount(document.createElement('div'))
if (!result) throw new Error('useIntersectionObserver did not initialize')
return {
result,
unmount: () => app.unmount()
}
}
beforeEach(() => {
observers.length = 0
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: MockIntersectionObserver
})
})
describe('useIntersectionObserver', () => {
it('observes the target immediately and updates intersection state', async () => {
const target = ref<Element | null>(document.createElement('div'))
const callback = vi.fn()
const { result, unmount } = mountObserver(target, callback, {
threshold: 0.5
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(result.isSupported).toBe(true)
expect(observers).toHaveLength(1)
expect(observers[0].options).toMatchObject({ threshold: 0.5 })
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
observers[0].callback(
[
{ isIntersecting: false },
{ isIntersecting: true }
] as IntersectionObserverEntry[],
fromPartial<IntersectionObserver>(observers[0])
)
expect(result.isIntersecting.value).toBe(true)
expect(callback).toHaveBeenCalled()
unmount()
expect(observers[0].disconnect).toHaveBeenCalled()
})
it('supports manual observe, unobserve, and cleanup', () => {
const target = ref<Element | null>(document.createElement('div'))
const { result, unmount } = mountObserver(target, vi.fn(), {
immediate: false
})
expect(observers).toHaveLength(0)
result.observe()
expect(observers).toHaveLength(1)
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
result.unobserve()
expect(observers[0].unobserve).toHaveBeenCalledWith(target.value)
result.cleanup()
expect(observers[0].disconnect).toHaveBeenCalled()
unmount()
})
it('does nothing when unsupported or missing a target', () => {
Reflect.deleteProperty(window, 'IntersectionObserver')
const unsupported = mountObserver(
ref(document.createElement('div')),
vi.fn(),
{
immediate: false
}
)
unsupported.result.observe()
expect(unsupported.result.isSupported).toBe(false)
expect(observers).toHaveLength(0)
unsupported.unmount()
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: MockIntersectionObserver
})
const missingTarget = mountObserver(ref<Element | null>(null), vi.fn(), {
immediate: false
})
missingTarget.result.observe()
expect(observers).toHaveLength(0)
missingTarget.unmount()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,8 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import Load3d from '@/extensions/core/load3d/Load3d'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
@@ -99,9 +97,9 @@ describe('useLoad3dViewer', () => {
},
'Resource Folder': ''
},
graph: fromPartial<LGraph>({
graph: {
setDirtyCanvas: vi.fn()
}),
} as Partial<LGraph> as LGraph,
widgets: []
})
@@ -130,9 +128,6 @@ describe('useLoad3dViewer', () => {
setCameraState: vi.fn(),
addEventListener: vi.fn(),
hasAnimations: vi.fn().mockReturnValue(false),
toggleAnimation: vi.fn(),
setAnimationSpeed: vi.fn(),
updateSelectedAnimation: vi.fn(),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
@@ -152,11 +147,6 @@ describe('useLoad3dViewer', () => {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}),
setAnimationTime: vi.fn(),
getAnimationDuration: vi.fn().mockReturnValue(12),
animationManager: fromPartial<Load3d['animationManager']>({
animationClips: []
})
}
@@ -179,18 +169,6 @@ describe('useLoad3dViewer', () => {
currentUpDirection: 'original',
materialMode: 'original'
} as Load3d['modelManager'],
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
forceRender: vi.fn()
@@ -201,16 +179,20 @@ describe('useLoad3dViewer', () => {
})
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
mockLoad3dService = fromPartial<ReturnType<typeof useLoad3dService>>({
mockLoad3dService = {
copyLoad3dState: vi.fn().mockResolvedValue(undefined),
handleViewportRefresh: vi.fn(),
getLoad3d: vi.fn().mockReturnValue(mockSourceLoad3d)
})
} as Partial<ReturnType<typeof useLoad3dService>> as ReturnType<
typeof useLoad3dService
>
vi.mocked(useLoad3dService).mockReturnValue(mockLoad3dService)
mockToastStore = fromPartial<ReturnType<typeof useToastStore>>({
mockToastStore = {
addAlert: vi.fn()
})
} as Partial<ReturnType<typeof useToastStore>> as ReturnType<
typeof useToastStore
>
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
})
@@ -266,91 +248,6 @@ describe('useLoad3dViewer', () => {
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('passes target dimensions when width and height widgets are present', async () => {
mockNode.widgets = [
{ name: 'width', value: 640 },
{ name: 'height', value: 360 }
] as LGraphNode['widgets']
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(createLoad3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({
width: 640,
height: 360,
isViewerMode: true,
getDimensions: expect.any(Function)
})
)
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
mockNode.widgets![0].value = 800
mockNode.widgets![1].value = 450
expect(config.getDimensions?.()).toEqual({ width: 800, height: 450 })
})
it('falls back to source values when saved configs are empty', async () => {
mockNode.properties!['Scene Config'] = {
backgroundColor: '',
showGrid: undefined,
backgroundImage: '',
backgroundRenderMode: ''
}
mockNode.properties!['Camera Config'] = {
cameraType: undefined,
fov: undefined
}
mockNode.properties!['Model Config'] = {
upDirection: undefined,
materialMode: undefined
}
mockSourceLoad3d.sceneManager!.backgroundRenderMode = 'panorama'
mockSourceLoad3d.sceneManager!.gridHelper.visible = false
mockSourceLoad3d.sceneManager!.currentBackgroundColor = '#111111'
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
'orthographic'
)
mockSourceLoad3d.cameraManager!.perspectiveCamera.fov = 35
mockSourceLoad3d.modelManager!.currentUpDirection = '+y'
mockSourceLoad3d.modelManager!.materialMode = 'normal'
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(viewer.backgroundColor.value).toBe('#111111')
expect(viewer.showGrid.value).toBe(false)
expect(viewer.backgroundRenderMode.value).toBe('panorama')
expect(viewer.cameraType.value).toBe('orthographic')
expect(viewer.fov.value).toBe(35)
expect(viewer.upDirection.value).toBe('+y')
expect(viewer.materialMode.value).toBe('normal')
})
it('initializes animation state from existing clips', async () => {
vi.mocked(mockLoad3d.hasAnimations!).mockReturnValue(true)
mockLoad3d.animationManager = fromPartial<Load3d['animationManager']>({
animationClips: [{ name: 'Walk' }, { name: '' }]
})
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(viewer.animations.value).toEqual([
{ name: 'Walk', index: 0 },
{ name: 'Animation 2', index: 1 }
])
expect(viewer.animationDuration.value).toBe(12)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
@@ -368,41 +265,6 @@ describe('useLoad3dViewer', () => {
})
describe('error handling', () => {
it('ignores watcher changes before Load3d is initialized', async () => {
const viewer = useLoad3dViewer(mockNode)
viewer.backgroundColor.value = '#ff0000'
viewer.showGrid.value = false
viewer.cameraType.value = 'orthographic'
viewer.fov.value = 60
viewer.lightIntensity.value = 2
viewer.backgroundImage.value = 'bg.jpg'
viewer.backgroundRenderMode.value = 'panorama'
viewer.upDirection.value = '+y'
viewer.materialMode.value = 'normal'
viewer.playing.value = true
viewer.selectedSpeed.value = 2
viewer.selectedAnimation.value = 1
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
await nextTick()
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
expect(mockLoad3d.toggleGrid).not.toHaveBeenCalled()
expect(mockLoad3d.toggleCamera).not.toHaveBeenCalled()
expect(mockLoad3d.setFOV).not.toHaveBeenCalled()
expect(mockLoad3d.setLightIntensity).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundImage).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundRenderMode).not.toHaveBeenCalled()
expect(mockLoad3d.setUpDirection).not.toHaveBeenCalled()
expect(mockLoad3d.setMaterialMode).not.toHaveBeenCalled()
expect(mockLoad3d.toggleAnimation).not.toHaveBeenCalled()
expect(mockLoad3d.setAnimationSpeed).not.toHaveBeenCalled()
expect(mockLoad3d.updateSelectedAnimation).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
})
it('should handle watcher errors gracefully', async () => {
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
function () {
@@ -422,82 +284,6 @@ describe('useLoad3dViewer', () => {
'toastMessages.failedToUpdateBackgroundColor'
)
})
it('surfaces watcher errors for each viewer control', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(mockLoad3d.toggleGrid!).mockImplementation(() => {
throw new Error('grid failed')
})
vi.mocked(mockLoad3d.toggleCamera!).mockImplementation(() => {
throw new Error('camera failed')
})
vi.mocked(mockLoad3d.setFOV!).mockImplementation(() => {
throw new Error('fov failed')
})
vi.mocked(mockLoad3d.setLightIntensity!).mockImplementation(() => {
throw new Error('light failed')
})
vi.mocked(mockLoad3d.setBackgroundImage!).mockRejectedValue(
new Error('background failed')
)
vi.mocked(mockLoad3d.setBackgroundRenderMode!).mockImplementation(() => {
throw new Error('render failed')
})
vi.mocked(mockLoad3d.setUpDirection!).mockImplementation(() => {
throw new Error('up failed')
})
vi.mocked(mockLoad3d.setMaterialMode!).mockImplementation(() => {
throw new Error('material failed')
})
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
viewer.showGrid.value = false
await nextTick()
viewer.showGrid.value = true
viewer.cameraType.value = 'orthographic'
viewer.fov.value = 60
viewer.lightIntensity.value = 2
viewer.backgroundImage.value = 'bg.jpg'
viewer.backgroundRenderMode.value = 'panorama'
viewer.upDirection.value = '+y'
viewer.materialMode.value = 'normal'
await nextTick()
await Promise.resolve()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToToggleGrid'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToToggleCamera'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateFOV'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateLightIntensity'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateBackgroundImage'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateBackgroundRenderMode'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateUpDirection'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateMaterialMode'
)
consoleErrorSpy.mockRestore()
})
})
describe('exportModel', () => {
@@ -582,43 +368,6 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
it('handles animation controls and seek after progress is known', async () => {
const eventHandlers: Record<string, (value: unknown) => void> = {}
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
eventHandlers[event] = handler as (value: unknown) => void
}
)
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
eventHandlers.animationListChange([{ name: 'Spin', index: 0 }])
eventHandlers.animationProgressChange({
progress: 25,
currentTime: 1,
duration: 8
})
viewer.playing.value = true
viewer.selectedSpeed.value = 0
const undefNum: unknown = undefined
viewer.selectedAnimation.value = undefNum as number
await nextTick()
viewer.selectedSpeed.value = 2
viewer.selectedAnimation.value = 0
await nextTick()
viewer.handleSeek(50)
expect(viewer.animations.value).toEqual([{ name: 'Spin', index: 0 }])
expect(viewer.animationProgress.value).toBe(25)
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(0)
expect(mockLoad3d.setAnimationTime).toHaveBeenCalledWith(4)
})
})
describe('restoreInitialState', () => {
@@ -673,14 +422,6 @@ describe('useLoad3dViewer', () => {
.futureField
).toBe('preserve-me')
})
it('does nothing in standalone mode', () => {
const viewer = useLoad3dViewer()
viewer.restoreInitialState()
expect(viewer.needApplyChanges.value).toBe(true)
})
})
describe('applyChanges', () => {
@@ -735,26 +476,6 @@ describe('useLoad3dViewer', () => {
expect(result).toBe(false)
})
it('applies without writing properties or dirtying when node state is absent', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
const undefProps: unknown = undefined
mockNode.properties = undefProps as LGraphNode['properties']
const undefGraph: unknown = undefined
mockNode.graph = undefGraph as LGraphNode['graph']
const result = await viewer.applyChanges()
expect(result).toBe(true)
expect(mockLoad3dService.copyLoad3dState).toHaveBeenLastCalledWith(
mockLoad3d,
mockSourceLoad3d
)
})
it('should preserve unknown fields on Model Config when applying', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -857,37 +578,6 @@ describe('useLoad3dViewer', () => {
)
})
it('alerts when uploading without an active Load3d instance', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.handleBackgroundImageUpdate(
new File([''], 'test.jpg', { type: 'image/jpeg' })
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dScene'
)
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
})
it('leaves existing background state when upload returns no path', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
viewer.backgroundImage.value = 'existing.jpg'
viewer.hasBackgroundImage.value = true
await viewer.handleBackgroundImageUpdate(
new File([''], 'test.jpg', { type: 'image/jpeg' })
)
expect(viewer.backgroundImage.value).toBe('existing.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should work in standalone mode without a node', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
@@ -974,69 +664,6 @@ describe('useLoad3dViewer', () => {
)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
})
it('updates the model_file widget after a successful drop', async () => {
const modelWidget = {
name: 'model_file',
value: '',
options: { values: ['existing.glb'] }
}
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'new.glb'))
expect(modelWidget.value).toBe('3d/new.glb')
expect(modelWidget.options.values).toEqual(['existing.glb', '3d/new.glb'])
})
it('does not duplicate model_file widget options', async () => {
const modelWidget = {
name: 'model_file',
value: '',
options: { values: ['3d/new.glb'] }
}
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'new.glb'))
expect(modelWidget.options.values).toEqual(['3d/new.glb'])
})
it('alerts when model loading throws', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/bad.glb')
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('bad model')
)
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'bad.glb'))
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadModel'
)
consoleErrorSpy.mockRestore()
})
})
describe('cleanup', () => {
@@ -1067,26 +694,13 @@ describe('useLoad3dViewer', () => {
expect(Load3d).not.toHaveBeenCalled()
})
it('should handle missing node in node-mode initialization', async () => {
const viewer = useLoad3dViewer()
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(createLoad3d).not.toHaveBeenCalled()
})
it('should handle orthographic camera', async () => {
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
'orthographic'
)
mockSourceLoad3d.cameraManager = fromPartial<Load3d['cameraManager']>({
perspectiveCamera: fromPartial<
Load3d['cameraManager']['perspectiveCamera']
>({ fov: 75 })
})
mockSourceLoad3d.cameraManager = {
perspectiveCamera: { fov: 75 }
} as Partial<Load3d['cameraManager']> as Load3d['cameraManager']
delete (mockNode.properties!['Camera Config'] as Record<string, unknown>)
.cameraType
@@ -1111,96 +725,6 @@ describe('useLoad3dViewer', () => {
})
describe('standalone mode persistence', () => {
it('should handle missing standalone container ref', async () => {
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(null!, 'model.glb')
expect(createLoad3d).not.toHaveBeenCalled()
})
it('syncs pending hover state during standalone initialization', async () => {
const viewer = useLoad3dViewer()
viewer.handleMouseEnter()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'hover.glb'
)
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
it('shows an alert when first standalone load fails', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('load failed')
)
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'broken.glb'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadModel'
)
consoleErrorSpy.mockRestore()
})
it('shows an alert when reusing a standalone viewer fails to load the next model', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'first.glb'
)
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('reload failed')
)
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'second.glb'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'Failed to load 3D model'
)
consoleErrorSpy.mockRestore()
})
it('restores cached standalone camera state', async () => {
const cameraState = {
position: { x: 2, y: 3, z: 4 },
target: { x: 0, y: 0, z: 0 },
zoom: 2,
cameraType: 'perspective'
}
const cameraReturn: unknown = cameraState
vi.mocked(mockLoad3d.getCameraState!).mockReturnValue(
cameraReturn as CameraState
)
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
await viewer.initializeStandaloneViewer(containerRef, 'camera.glb')
viewer.cleanup()
const restoredViewer = useLoad3dViewer()
await restoredViewer.initializeStandaloneViewer(
containerRef,
'camera.glb'
)
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith(cameraState)
})
it('should save and restore configuration in standalone mode', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -34,6 +33,12 @@ vi.mock('@/scripts/api', () => ({
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: ref('en')
})
}))
vi.mock('@/types/nodeSource', () => ({
NodeSourceType: {
Core: 'core',
@@ -47,23 +52,6 @@ vi.mock('@/types/nodeSource', () => ({
})
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function withI18n<T>(fn: () => T): T {
let result!: T
const app = createApp(
defineComponent({
setup() {
result = fn()
return () => null
}
})
)
app.use(i18n)
app.mount(document.createElement('div'))
return result
}
describe('useNodeHelpContent', () => {
const mockCoreNode = createMockNode({
name: 'TestNode',
@@ -97,7 +85,7 @@ describe('useNodeHelpContent', () => {
text: async () => '# Test'
})
const { baseUrl } = withI18n(() => useNodeHelpContent(nodeRef))
const { baseUrl } = useNodeHelpContent(nodeRef)
await nextTick()
expect(baseUrl.value).toBe(`/docs/${mockCoreNode.name}/`)
@@ -110,7 +98,7 @@ describe('useNodeHelpContent', () => {
text: async () => '# Test'
})
const { baseUrl } = withI18n(() => useNodeHelpContent(nodeRef))
const { baseUrl } = useNodeHelpContent(nodeRef)
await nextTick()
expect(baseUrl.value).toBe('/extensions/test_module/docs/')
@@ -123,7 +111,7 @@ describe('useNodeHelpContent', () => {
text: async () => '# Test Help\nThis is test help content'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain('This is test help content')
@@ -136,9 +124,7 @@ describe('useNodeHelpContent', () => {
statusText: 'Not Found'
})
const { error, renderedHelpHtml } = withI18n(() =>
useNodeHelpContent(nodeRef)
)
const { error, renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(error.value).toBe('Not Found')
@@ -152,7 +138,7 @@ describe('useNodeHelpContent', () => {
text: async () => '![image](test.jpg)'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain('alt="image"')
@@ -165,7 +151,7 @@ describe('useNodeHelpContent', () => {
text: async () => '<video src="video.mp4"></video>'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
@@ -180,7 +166,7 @@ describe('useNodeHelpContent', () => {
text: async () => '<video src="video.mp4"></video>'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
@@ -192,7 +178,7 @@ describe('useNodeHelpContent', () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
const { isLoading } = withI18n(() => useNodeHelpContent(nodeRef))
const { isLoading } = useNodeHelpContent(nodeRef)
await nextTick()
expect(isLoading.value).toBe(true)
@@ -210,7 +196,7 @@ describe('useNodeHelpContent', () => {
text: async () => '# Fallback content'
})
withI18n(() => useNodeHelpContent(nodeRef))
useNodeHelpContent(nodeRef)
await flushPromises()
expect(mockFetch).toHaveBeenCalledTimes(2)
@@ -230,7 +216,7 @@ describe('useNodeHelpContent', () => {
'<video><source src="video.mp4" type="video/mp4" /></video>'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
@@ -246,7 +232,7 @@ describe('useNodeHelpContent', () => {
'<video><source src="video.webm" type="video/webm" /></video>'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
@@ -261,7 +247,7 @@ describe('useNodeHelpContent', () => {
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
@@ -277,7 +263,7 @@ describe('useNodeHelpContent', () => {
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
@@ -293,7 +279,7 @@ describe('useNodeHelpContent', () => {
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain('src="/absolute/image.png"')
@@ -308,7 +294,7 @@ describe('useNodeHelpContent', () => {
'<img src="https://example.com/image.png" alt="External">'
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
@@ -338,7 +324,7 @@ Testing quote styles in properly formed HTML:
The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
})
const { renderedHelpHtml } = withI18n(() => useNodeHelpContent(nodeRef))
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
// All media src attributes should be prefixed correctly
@@ -377,7 +363,7 @@ The MEDIA_SRC_REGEX handles both single and double quotes in img, video and sour
text: async () => '# Second node content'
})
const { helpContent } = withI18n(() => useNodeHelpContent(nodeRef))
const { helpContent } = useNodeHelpContent(nodeRef)
await nextTick()
// Change node before first request completes
@@ -395,58 +381,4 @@ The MEDIA_SRC_REGEX handles both single and double quotes in img, video and sour
// Should have second node's content, not first
expect(helpContent.value).toBe('# Second node content')
})
it('returns empty state when no node is selected', async () => {
const nodeRef = ref<ComfyNodeDefImpl | null>(null)
const { baseUrl, helpContent, isLoading, error } = withI18n(() =>
useNodeHelpContent(nodeRef)
)
await nextTick()
expect(baseUrl.value).toBe('')
expect(helpContent.value).toBe('')
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockFetch).not.toHaveBeenCalled()
})
it('uses stringified non-error rejections with the node description', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockRejectedValueOnce('offline')
const { error, helpContent } = withI18n(() => useNodeHelpContent(nodeRef))
await flushPromises()
expect(error.value).toBe('offline')
expect(helpContent.value).toBe(mockCoreNode.description)
})
it('ignores stale rejected requests after the node changes', async () => {
const nodeRef = ref(mockCoreNode)
let rejectFirst: (reason?: unknown) => void
const firstRequest = new Promise((_resolve, reject) => {
rejectFirst = reject
})
mockFetch
.mockImplementationOnce(() => firstRequest)
.mockResolvedValueOnce({
ok: true,
text: async () => '# Current node content'
})
const { error, helpContent } = withI18n(() => useNodeHelpContent(nodeRef))
await nextTick()
nodeRef.value = mockCustomNode
await nextTick()
await flushPromises()
rejectFirst!(new Error('stale failure'))
await flushPromises()
expect(error.value).toBeNull()
expect(helpContent.value).toBe('# Current node content')
})
})

View File

@@ -1,4 +1,3 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraphCanvas,
@@ -24,7 +23,6 @@ import {
pasteVideoNodes,
usePaste
} from './usePaste'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
function createMockNode(): LGraphNode {
return createMockLGraphNode({
@@ -61,19 +59,19 @@ function createDataTransfer(files: File[] = []): DataTransfer {
return dataTransfer
}
const mockCanvas = fromPartial<LGraphCanvas>({
const mockCanvas = {
current_node: null as LGraphNode | null,
graph: fromPartial<LGraph>({
graph: {
add: vi.fn(),
change: vi.fn()
}),
} as Partial<LGraph> as LGraph,
graph_mouse: [100, 200],
pasteFromClipboard: vi.fn(),
_deserializeItems: vi.fn()
})
} as Partial<LGraphCanvas> as LGraphCanvas
const mockCanvasStore = {
canvas: mockCanvas as LGraphCanvas | null,
canvas: mockCanvas,
getCanvas: vi.fn(() => mockCanvas)
}
@@ -141,17 +139,6 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when image node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteImageNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing image node when provided', async () => {
const mockNode = createMockNode()
const file = createImageFile()
@@ -229,14 +216,6 @@ describe('pasteImageNodes', () => {
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
})
it('omits files whose image node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteImageNodes(mockCanvas, [createImageFile()])
expect(result).toEqual([])
})
})
describe('pasteAudioNode', () => {
@@ -257,17 +236,6 @@ describe('pasteAudioNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when audio node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createAudioFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteAudioNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing audio node when provided', async () => {
const mockNode = createMockNode()
const file = createAudioFile()
@@ -344,14 +312,6 @@ describe('pasteAudioNodes', () => {
expect(createNode).toHaveBeenCalledTimes(1)
expect(result).toEqual([mockNode])
})
it('omits files whose audio node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteAudioNodes(mockCanvas, [createAudioFile()])
expect(result).toEqual([])
})
})
describe('pasteVideoNode', () => {
@@ -372,17 +332,6 @@ describe('pasteVideoNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when video node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createVideoFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteVideoNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing video node when provided', async () => {
const mockNode = createMockNode()
const file = createVideoFile()
@@ -459,23 +408,13 @@ describe('pasteVideoNodes', () => {
expect(createNode).toHaveBeenCalledTimes(1)
expect(result).toEqual([mockNode])
})
it('omits files whose video node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteVideoNodes(mockCanvas, [createVideoFile()])
expect(result).toEqual([])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvasStore.canvas = mockCanvas
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(false)
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
@@ -605,31 +544,6 @@ describe('usePaste', () => {
expect(createNode).not.toHaveBeenCalled()
})
it('ignores paste when the target owns native copy paste', () => {
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(true)
usePaste()
const dataTransfer = createDataTransfer([createImageFile()])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(createNode).not.toHaveBeenCalled()
})
it('ignores paste when the canvas is unavailable', () => {
mockCanvasStore.canvas = null
usePaste()
const dataTransfer = createDataTransfer([createImageFile()])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(createNode).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
})
it('should use existing image node when selected', () => {
const mockNode = createMockLGraphNode({
is_selected: true,
@@ -682,66 +596,6 @@ describe('usePaste', () => {
})
})
it('falls back to litegraph paste when metadata cannot be decoded', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData(
'text/html',
`<div data-metadata="${btoa('{')}"></div>`
)
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
errorSpy.mockRestore()
})
it('leaves plain text paste alone in text inputs', () => {
usePaste()
const input = document.createElement('input')
input.type = 'text'
document.body.append(input)
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'plain text')
input.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true
})
)
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
input.remove()
})
it('leaves plain text paste alone in textareas', () => {
usePaste()
const textarea = document.createElement('textarea')
document.body.append(textarea)
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'plain text')
textarea.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true
})
)
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
textarea.remove()
})
it('should skip node metadata paste when a media node is selected', async () => {
const mockNode = createMockLGraphNode({
is_selected: true,

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import { useProgressBarBackground } from './useProgressBarBackground'
describe('useProgressBarBackground', () => {
it('identifies finite progress values', () => {
const { hasProgressPercent, hasAnyProgressPercent } =
useProgressBarBackground()
expect(hasProgressPercent(undefined)).toBe(false)
expect(hasProgressPercent(Number.NaN)).toBe(false)
expect(hasProgressPercent(0)).toBe(true)
expect(hasAnyProgressPercent(undefined, Number.POSITIVE_INFINITY)).toBe(
false
)
expect(hasAnyProgressPercent(undefined, 42)).toBe(true)
})
it('clamps progress styles to the valid percent range', () => {
const { progressPercentStyle } = useProgressBarBackground()
expect(progressPercentStyle(undefined)).toBeUndefined()
expect(progressPercentStyle(-10)).toEqual({ width: '0%' })
expect(progressPercentStyle(125)).toEqual({ width: '100%' })
expect(progressPercentStyle(37)).toEqual({ width: '37%' })
})
})

View File

@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
const mockCanvasStore = reactive({
selectedItems: [] as unknown[]
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
function makeNode(widgets?: unknown[]): LGraphNode {
const node = new LGraphNode('Test')
node.widgets = widgets as LGraphNode['widgets']
return node
}
describe('useRefreshableSelection', () => {
beforeEach(() => {
mockCanvasStore.selectedItems = []
})
it('does nothing when no selected widget is refreshable', async () => {
const selection = useRefreshableSelection()
await selection.refreshSelected()
expect(selection.isRefreshable.value).toBe(false)
})
it('refreshes selected widgets that expose a refresh function', async () => {
const refresh = vi.fn()
const ignoredRefresh = vi.fn()
mockCanvasStore.selectedItems = [
makeNode([{ refresh }, { refresh: 'not callable' }, null]),
{ widgets: [{ refresh: ignoredRefresh }] }
]
const selection = useRefreshableSelection()
await nextTick()
expect(selection.isRefreshable.value).toBe(true)
await selection.refreshSelected()
expect(refresh).toHaveBeenCalledOnce()
expect(ignoredRefresh).not.toHaveBeenCalled()
})
it('treats selected nodes without widgets as not refreshable', async () => {
mockCanvasStore.selectedItems = [makeNode()]
const selection = useRefreshableSelection()
await nextTick()
expect(selection.isRefreshable.value).toBe(false)
})
})

View File

@@ -1,4 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { useEventListener } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -20,13 +20,6 @@ vi.mock('@vueuse/core', () => ({
}))
describe('useServerLogs', () => {
const listenerFor = <T>(eventType: string) =>
vi
.mocked(useEventListener)
.mock.calls.find(([, type]) => type === eventType)?.[2] as
| ((event: CustomEvent<T>) => void)
| undefined
beforeEach(() => {
vi.clearAllMocks()
})
@@ -87,7 +80,8 @@ describe('useServerLogs', () => {
// Simulate receiving a log event
const mockEvent = new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'Log message 1' }, { m: 'Log message 2' }]
})
}) as CustomEvent<LogsWsMessage>
@@ -110,7 +104,8 @@ describe('useServerLogs', () => {
) => void
const mockEvent = new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [
{ m: 'Log message 1 dont remove me' },
{ m: 'remove me' },
@@ -124,127 +119,4 @@ describe('useServerLogs', () => {
expect(logs.value).toEqual(['Log message 1 dont remove me', ''])
})
it('only captures logs while the matching task is active', async () => {
const { logs, startListening } = useServerLogs({ ui_id: 'task-1' })
await startListening()
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
api,
'cm-task-started',
expect.any(Function)
)
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
api,
'cm-task-completed',
expect.any(Function)
)
const onLogs = listenerFor<LogsWsMessage>('logs')
const onTaskStarted = listenerFor<{ ui_id: string }>('cm-task-started')
const onTaskDone = listenerFor<{ ui_id: string }>('cm-task-completed')
onLogs?.(
new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
entries: [{ m: 'before start' }]
})
})
)
onTaskStarted?.(
new CustomEvent('cm-task-started', { detail: { ui_id: 'other-task' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
entries: [{ m: 'wrong task' }]
})
})
)
onTaskStarted?.(
new CustomEvent('cm-task-started', { detail: { ui_id: 'task-1' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
entries: [{ m: 'captured' }]
})
})
)
onTaskDone?.(
new CustomEvent('cm-task-completed', { detail: { ui_id: 'other-task' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
entries: [{ m: 'still active' }]
})
})
)
onTaskDone?.(
new CustomEvent('cm-task-completed', { detail: { ui_id: 'task-1' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
entries: [{ m: 'after done' }]
})
})
)
expect(logs.value).toEqual(['captured', 'still active'])
})
it('ignores invalid and empty log events', async () => {
const { logs, startListening } = useServerLogs()
await startListening()
const onLogs = listenerFor<LogsWsMessage>('logs')
onLogs?.(
new CustomEvent('not-logs', {
detail: fromPartial<LogsWsMessage>({
entries: [{ m: 'wrong event' }]
})
})
)
onLogs?.(
new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
entries: []
})
})
)
onLogs?.(
new CustomEvent('logs', {
detail: fromPartial<LogsWsMessage>({
entries: [{ m: ' ' }]
})
})
)
expect(logs.value).toEqual([])
})
it('stops every registered listener', async () => {
const stopLogs = vi.fn()
const stopTaskStarted = vi.fn()
const stopTaskDone = vi.fn()
vi.mocked(useEventListener)
.mockReturnValueOnce(stopLogs)
.mockReturnValueOnce(stopTaskStarted)
.mockReturnValueOnce(stopTaskDone)
const { startListening, stopListening } = useServerLogs({ ui_id: 'task-1' })
await startListening()
await stopListening()
expect(stopLogs).toHaveBeenCalledTimes(1)
expect(stopTaskStarted).toHaveBeenCalledTimes(1)
expect(stopTaskDone).toHaveBeenCalledTimes(1)
expect(api.subscribeLogs).toHaveBeenLastCalledWith(false)
})
})

View File

@@ -248,158 +248,6 @@ describe('useTemplateFiltering', () => {
])
})
it('accepts plain template arrays and treats invalid ref values as empty', () => {
const plain = useTemplateFiltering([
{
name: 'plain-template',
description: 'Plain template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
expect(
plain.filteredTemplates.value.map((template) => template.name)
).toEqual(['plain-template'])
const notAnArrayRef: unknown = ref('not-an-array')
const invalid = useTemplateFiltering(
notAnArrayRef as Parameters<typeof useTemplateFiltering>[0]
)
expect(invalid.filteredTemplates.value).toEqual([])
expect(invalid.totalCount.value).toBe(0)
})
it('ignores malformed models and tags while applying active filters', () => {
const fluxString: unknown = 'Flux'
const videoString: unknown = 'Video'
const templates = ref<TemplateInfo[]>([
{
name: 'model-template',
description: 'Model template',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Flux'],
tags: ['Video']
},
{
name: 'missing-models',
description: 'Missing models',
mediaType: 'image',
mediaSubtype: 'png',
models: fluxString as string[],
tags: videoString as string[]
}
])
const {
availableModels,
availableUseCases,
selectedModels,
selectedUseCases,
filteredTemplates
} = useTemplateFiltering(templates)
expect(availableModels.value).toEqual(['Flux'])
expect(availableUseCases.value).toEqual(['Video'])
selectedModels.value = ['Flux']
selectedUseCases.value = ['Video']
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'model-template'
])
})
it('returns no templates for unknown runs-on filters', () => {
const templates = ref<TemplateInfo[]>([
{
name: 'template',
description: 'Template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { selectedRunsOn, filteredTemplates } =
useTemplateFiltering(templates)
selectedRunsOn.value = ['Unknown target']
expect(filteredTemplates.value).toEqual([])
})
it('supports recommended and popular score sorting', async () => {
defaultRankingStore.computeDefaultScore.mockImplementation(
(_date?: string, rank?: number) => rank ?? 0
)
defaultRankingStore.computePopularScore.mockImplementation(
(_date?: string, usage?: number) => usage ?? 0
)
const templates = ref<TemplateInfo[]>([
{
name: 'low',
description: 'Low score',
mediaType: 'image',
mediaSubtype: 'png',
searchRank: 1,
usage: 10
},
{
name: 'high',
description: 'High score',
mediaType: 'image',
mediaSubtype: 'png',
searchRank: 9,
usage: 1
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'recommended'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'high',
'low'
])
sortBy.value = 'popular'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'low',
'high'
])
})
it('keeps equal and missing model sizes stable for size sorting', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'first',
description: 'First',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'second',
description: 'Second',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'model-size-low-to-high'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'first',
'second'
])
})
describe('loadFuseOptions', () => {
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
const templates = ref<TemplateInfo[]>([

View File

@@ -1,101 +0,0 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import type { TreeNode } from '@/types/treeExplorerTypes'
function node(over: Partial<TreeNode>): TreeNode {
return over as TreeNode
}
// root ─┬─ a ── a1 (leaf)
// └─ b (leaf)
function sampleTree() {
const a1 = node({ key: 'a1', leaf: true })
const a = node({ key: 'a', leaf: false, children: [a1] })
const b = node({ key: 'b', leaf: true })
const root = node({ key: 'root', leaf: false, children: [a, b] })
return { root, a, a1, b }
}
describe('useTreeExpansion', () => {
it('toggleNode adds then removes a node key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
const n = node({ key: 'x' })
toggleNode(n)
expect(expandedKeys.value).toEqual({ x: true })
toggleNode(n)
expect(expandedKeys.value).toEqual({})
})
it('toggleNode ignores nodes without a string key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
toggleNode(node({ key: undefined }))
expect(expandedKeys.value).toEqual({})
})
it('expandNode expands the node and all non-leaf descendants only', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
expandNode(root)
// root and a are folders; a1 and b are leaves and must be skipped
expect(expandedKeys.value).toEqual({ root: true, a: true })
})
it('expandNode does nothing for a leaf node', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
expandNode(node({ key: 'leaf', leaf: true }))
expect(expandedKeys.value).toEqual({})
})
it('collapseNode removes the node and its non-leaf descendants', () => {
const expandedKeys = ref<Record<string, boolean>>({
root: true,
a: true,
stray: true
})
const { collapseNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
collapseNode(root)
expect(expandedKeys.value).toEqual({ stray: true })
})
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({})
})
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
// Plain toggle removes only the node's own key, leaving descendants
toggleNodeOnEvent(new MouseEvent('click'), root)
expect(expandedKeys.value).toEqual({ a: true })
})
})

View File

@@ -16,14 +16,12 @@ import {
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
appendQuarantine,
flushProxyWidgetMigration,
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -181,33 +179,6 @@ describe('flushProxyWidgetMigration', () => {
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
})
it('createSubgraphInput: resolves a nested promoted input by host input name', () => {
const rootGraph = new LGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const source = new LGraphNode('CLIPTextEncode')
const sourceSlot = source.addInput('text', 'STRING')
sourceSlot.widget = { name: 'text' }
source.addWidget('text', 'text', 'nested value', () => {})
innerSubgraph.add(source)
const nestedHost = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
nestedHost.properties.proxyWidgets = [[String(source.id), 'text']]
flushProxyWidgetMigration({ hostNode: nestedHost })
const outerSubgraph = createTestSubgraph({ rootGraph })
outerSubgraph.add(nestedHost)
const outerHost = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
outerHost.properties.proxyWidgets = [[String(nestedHost.id), 'text']]
flushProxyWidgetMigration({ hostNode: outerHost })
expect(getPromotedInputValue(outerHost, 'text')).toBe('nested value')
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
@@ -269,41 +240,6 @@ describe('flushProxyWidgetMigration', () => {
).toBe('renamed_from_sidepanel')
})
it('createSubgraphInput: falls back to the source widget type when the slot type is missing', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.type = undefined as never
slot.widget = { name: 'seed' }
n.addWidget('number', 'seed', 0, () => {})
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
).toBe('number')
})
it('createSubgraphInput: falls back to wildcard type when slot and widget type are missing', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.type = undefined as never
slot.widget = { name: 'seed' }
const widget = n.addWidget('number', 'seed', 0, () => {})
widget.type = undefined as never
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
).toBe('*')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
@@ -392,88 +328,6 @@ describe('flushProxyWidgetMigration', () => {
expect(getPromotedInputValue(host, 'value')).toBe(11)
})
it('uses the primitive title as the promoted input name when it was renamed', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.title = 'Batch Size'
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(
host.inputs.find((input) => input.name === 'Batch Size')
).toBeDefined()
})
it('skips a stale primitive bypass marker when the host input is absent', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.properties = {
proxyBypassedToSubgraphInput: 'deleted_input'
}
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
const slot = targets[0].inputs[0]
const link = host.subgraph.links.get(slot.link!)
expect(link?.origin_id).not.toBe(primitive.id)
expect(host.inputs.find((input) => input.name === 'value')).toBeDefined()
})
it('quarantines a stale primitive bypass marker that points to a plain input', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.properties = {
proxyBypassedToSubgraphInput: 'plain'
}
host.addInput('plain', 'INT')
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [12]
})
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'missingSubgraphInput'
})
])
})
it('quarantines a stale primitive bypass marker that matches ambiguous host inputs', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.properties = {
proxyBypassedToSubgraphInput: 'plain'
}
host.addInput('plain', 'INT')
host.addInput('plain', 'INT')
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [12]
})
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'ambiguousSubgraphInput'
})
])
})
it('quarantines an unlinked primitive node with no fan-out', () => {
const host = buildHost()
const primitive = new LGraphNode('Primitive')
@@ -492,64 +346,6 @@ describe('flushProxyWidgetMigration', () => {
])
})
it('quarantines primitive cohorts that disagree on source widget name', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'other']
]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
}),
expect.objectContaining({
originalEntry: [String(primitive.id), 'other'],
reason: 'primitiveBypassFailed'
})
])
})
it('quarantines duplicate primitive entries with no fan-out targets', () => {
const host = buildHost()
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', 'INT')
host.subgraph.add(primitive)
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'value']
]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps the target default when the primitive source widget has no value', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.widgets = []
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(getPromotedInputValue(host, 'value')).toBe(0)
})
it('quarantines all cohort entries when a target slot type is incompatible', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
@@ -570,73 +366,6 @@ describe('flushProxyWidgetMigration', () => {
])
})
it('quarantines primitive repair when the target slot disappeared', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 1
})
targets[0].inputs = []
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('quarantines primitive repair when the target node id is stale', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
const linkId = primitive.outputs[0].links?.[0]
if (!linkId) throw new Error('Missing primitive link')
const link = host.subgraph.links.get(linkId)
if (!link) throw new Error('Missing primitive link record')
link.target_id = toNodeId(999_999)
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('quarantines duplicate primitive entries when the fan-out target is unassigned', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
const linkId = primitive.outputs[0].links?.[0]
if (!linkId) throw new Error('Missing primitive link')
const link = host.subgraph.links.get(linkId)
if (!link) throw new Error('Missing primitive link record')
link.target_id = UNASSIGNED_NODE_ID
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'value']
]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
@@ -843,22 +572,6 @@ describe('flushProxyWidgetMigration', () => {
])
})
it('does not preserve non-widget host values on quarantine rows', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [null]
})
expect(readHostQuarantine(host)).toEqual([
expect.not.objectContaining({
hostValue: expect.anything()
})
])
})
it('round-trips appended entries via the public read helper', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
@@ -889,14 +602,6 @@ describe('flushProxyWidgetMigration', () => {
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
})
it('ignores empty quarantine append requests', () => {
const host = buildHost()
appendQuarantine(host, [])
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
})
describe('idempotency', () => {
@@ -1119,22 +824,6 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('strips nested legacy prefixes from widget name', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'111: 222: seed'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: '222'
})
})
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')

View File

@@ -1,180 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetId } from '@/types/widgetId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import {
inputForWidget,
promotedInputSource,
promotedInputWidget,
promotedInputWidgets,
widgetPromotedSource
} from './promotedInputWidget'
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
const mocks = vi.hoisted(() => ({
widgets: new Map<string, Record<string, unknown>>(),
setValue: vi.fn(),
resolveSubgraphInputTarget: vi.fn()
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getWidget: (id: string) => mocks.widgets.get(id),
setValue: mocks.setValue
})
}))
vi.mock('./resolveSubgraphInputTarget', () => ({
resolveSubgraphInputTarget: mocks.resolveSubgraphInputTarget
}))
function input(overrides: Partial<INodeInputSlot> = {}): INodeInputSlot {
return {
name: 'prompt',
type: 'STRING',
label: 'Prompt',
...overrides
} as INodeInputSlot
}
function node(overrides: Record<string, unknown> = {}): LGraphNode {
return createMockLGraphNode({
inputs: [],
isSubgraphNode: () => true,
getSlotFromWidget: vi.fn(),
...overrides
})
}
describe('promotedInputWidget helpers', () => {
beforeEach(() => {
mocks.widgets.clear()
mocks.setValue.mockClear()
mocks.resolveSubgraphInputTarget.mockReset()
})
it('resolves promoted input sources only for widget-backed inputs', () => {
const graphNode = node()
mocks.resolveSubgraphInputTarget.mockReturnValue({
nodeId: '12',
widgetName: 'prompt'
})
expect(promotedInputSource(graphNode, input())).toBeUndefined()
expect(
promotedInputSource(
graphNode,
input({ widgetId: 'graph:12:prompt' as WidgetId })
)
).toEqual({
nodeId: '12',
widgetName: 'prompt'
})
expect(resolveSubgraphInputTarget).toHaveBeenCalledWith(graphNode, 'prompt')
})
it('resolves promoted widget sources only on subgraph nodes with matching inputs', () => {
const widget = { name: 'prompt' } as IBaseWidget
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
mocks.resolveSubgraphInputTarget.mockReturnValue({
nodeId: '12',
widgetName: 'prompt'
})
expect(
widgetPromotedSource(node({ isSubgraphNode: () => false }), widget)
).toBeUndefined()
expect(
widgetPromotedSource(node({ getSlotFromWidget: () => undefined }), widget)
).toBeUndefined()
expect(
widgetPromotedSource(
node({ getSlotFromWidget: () => backingInput }),
widget
)
).toEqual({
nodeId: '12',
widgetName: 'prompt'
})
})
it('projects store-backed widget fields with input fallbacks', () => {
const widgetId = 'graph:12:prompt' as WidgetId
const widget = promotedInputWidget(input({ widgetId }))
expect(widget?.name).toBe('prompt')
expect(widget?.label).toBe('Prompt')
expect(widget?.y).toBe(0)
expect(widget?.type).toBe('text')
expect(widget?.options).toEqual({})
expect(widget?.value).toBeUndefined()
widget!.label = 'Ignored'
widget!.y = 12
widget!.value = 'next'
widget!.callback?.('callback')
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'next')
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'callback')
})
it('projects live widget store fields and mutates store state', () => {
const widgetId = 'graph:12:prompt' as WidgetId
const state = {
name: 'store-name',
label: 'Store Label',
y: 42,
type: 'combo',
options: { values: ['a'] },
value: 'a'
}
mocks.widgets.set(widgetId, state)
const widget = promotedInputWidget(input({ widgetId, label: undefined }))
expect(widget?.name).toBe('store-name')
expect(widget?.label).toBe('Store Label')
expect(widget?.y).toBe(42)
expect(widget?.type).toBe('combo')
expect(widget?.options).toEqual({ values: ['a'] })
expect(widget?.value).toBe('a')
widget!.label = 'New Label'
widget!.y = 52
expect(state.label).toBe('New Label')
expect(state.y).toBe(52)
})
it('returns null for non-promoted inputs and filters projected widget lists', () => {
const widgetId = 'graph:12:prompt' as WidgetId
const graphNode = node({
inputs: [input(), input({ widgetId })]
})
expect(promotedInputWidget(input())).toBeNull()
expect(promotedInputWidgets(graphNode)).toHaveLength(1)
})
it('returns undefined for null stored values', () => {
const widgetId = 'graph:12:prompt' as WidgetId
mocks.widgets.set(widgetId, { value: null })
expect(promotedInputWidget(input({ widgetId }))?.value).toBeUndefined()
})
it('delegates input lookup to the graph node', () => {
const widget = { name: 'prompt' } as IBaseWidget
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
const graphNode = node({
getSlotFromWidget: vi.fn(() => backingInput)
})
expect(inputForWidget(graphNode, widget)).toBe(backingInput)
expect(graphNode.getSlotFromWidget).toHaveBeenCalledWith(widget)
})
})

View File

@@ -15,10 +15,6 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toLinkId } from '@/types/linkId'
import type { WidgetId } from '@/types/widgetId'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { toNodeId } from '@/types/nodeId'
function promotedInputNames(host: {
inputs: Array<{ widgetId?: unknown; name: string }>
@@ -55,37 +51,19 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
}))
const addBreadcrumbMock = vi.hoisted(() => vi.fn())
vi.mock('@sentry/vue', () => ({
addBreadcrumb: addBreadcrumbMock
}))
const mockNavigation = vi.hoisted(() => ({
stack: [] as Subgraph[]
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: () => ({
navigationStack: mockNavigation.stack
})
}))
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
addWidgetPromotionOptions,
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteWidget,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected,
reorderSubgraphInputsByName,
reorderSubgraphInputsByWidgetOrder,
tryToggleWidgetPromotion
reorderSubgraphInputsByWidgetOrder
} from './promotionUtils'
function widget(
@@ -124,11 +102,6 @@ function buildDuplicateNamePromotion() {
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
}
function setupNavigation(host: SubgraphNode) {
host.subgraph.rootGraph.add(host)
mockNavigation.stack = [host.subgraph]
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -330,284 +303,6 @@ describe('getPromotableWidgets', () => {
})
})
describe('widget promotion actions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
addBreadcrumbMock.mockReset()
mockNavigation.stack = []
})
function setupPromotableWidget() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
setupNavigation(host)
const node = new LGraphNode('Prompt')
subgraph.add(node)
const input = node.addInput('text', 'STRING')
input.label = 'Prompt text'
const callback = vi.fn()
const textWidget = node.addWidget('text', 'text', 'value', callback)
textWidget.label = 'Prompt'
input.widget = { name: textWidget.name }
return { host, node, textWidget, callback }
}
it('adds a promote menu option and runs the widget callback after promotion', () => {
const { host, node, textWidget, callback } = setupPromotableWidget()
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
addWidgetPromotionOptions(options, textWidget, node)
const menuCallback = options[0]?.callback as
| ((...args: unknown[]) => unknown)
| undefined
void menuCallback?.(null, undefined, undefined)
expect(options[0]?.content).toContain('Prompt')
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
expect(callback).toHaveBeenCalledWith('value')
})
it('adds an unpromote menu option when the widget is already promoted', () => {
const { host, node, textWidget, callback } = setupPromotableWidget()
expect(promoteValueWidgetViaSubgraphInput(host, node, textWidget).ok).toBe(
true
)
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
addWidgetPromotionOptions(options, textWidget, node)
const menuCallback = options[0]?.callback as
| ((...args: unknown[]) => unknown)
| undefined
void menuCallback?.(null, undefined, undefined)
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
false
)
expect(callback).toHaveBeenCalledWith('value')
})
it('reports outside-subgraph promotion attempts through the toast store', () => {
const node = new LGraphNode('Prompt')
const textWidget = node.addWidget('text', 'text', 'value', () => {})
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
addWidgetPromotionOptions(options, textWidget, node)
expect(useToastStore().messagesToAdd).toHaveLength(1)
expect(options).toHaveLength(1)
})
it('toggles promotion for the widget under the canvas pointer', () => {
const { host, node, textWidget } = setupPromotableWidget()
const canvas = fromPartial<ReturnType<typeof useCanvasStore>['canvas']>({
graph_mouse: [10, 20],
visible_nodes: [node],
setDirty: vi.fn(),
graph: {
getNodeOnPos: vi.fn(() => node)
}
})
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(textWidget)
useCanvasStore().canvas = canvas
tryToggleWidgetPromotion()
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
tryToggleWidgetPromotion()
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
false
)
})
it('leaves state unchanged when toggle has no node or widget target', () => {
const { host, node, textWidget } = setupPromotableWidget()
useCanvasStore().canvas = fromPartial<
ReturnType<typeof useCanvasStore>['canvas']
>({
graph_mouse: [0, 0],
visible_nodes: [],
setDirty: vi.fn(),
graph: {
getNodeOnPos: vi.fn(() => null)
}
})
tryToggleWidgetPromotion()
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
false
)
useCanvasStore().canvas = fromPartial<
ReturnType<typeof useCanvasStore>['canvas']
>({
graph_mouse: [0, 0],
visible_nodes: [node],
setDirty: vi.fn(),
graph: {
getNodeOnPos: vi.fn(() => node)
}
})
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(undefined)
tryToggleWidgetPromotion()
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
false
)
})
it('records a breadcrumb when value promotion has no source slot', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const node = new LGraphNode('LooseWidgetNode')
subgraph.add(node)
const looseWidget = node.addWidget('text', 'loose', 'value', () => {})
promoteWidget(node, looseWidget, [host])
expect(addBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
level: 'warning',
message: expect.stringContaining('missingSourceSlot')
})
)
})
it('ignores promotion calls for node-shaped values that are not graph nodes', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const partialNode = {
id: toNodeId(123),
title: 'Partial',
type: 'Partial'
}
promoteWidget(partialNode, widget({ name: 'seed', type: 'number' }), [host])
expect(host.subgraph.inputs).toEqual([])
expect(addBreadcrumbMock).not.toHaveBeenCalled()
})
it('uses the widget name in menu text when label is absent', () => {
const { node, textWidget } = setupPromotableWidget()
textWidget.label = undefined
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
addWidgetPromotionOptions(options, textWidget, node)
expect(options[0]?.content).toContain('text')
})
})
describe('preview promotion actions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
addBreadcrumbMock.mockReset()
mockNavigation.stack = []
})
it('identifies preview exposure as promotion only for preview pseudo widgets', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(previewNode)
const previewWidget = widget({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
serialize: false,
type: 'preview'
})
usePreviewExposureStore().addExposure(host.rootGraph.id, String(host.id), {
sourceNodeId: previewNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
expect(
isWidgetPromotedOnSubgraphNode(
host,
{
sourceNodeId: previewNode.id,
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
},
previewWidget
)
).toBe(true)
expect(
isWidgetPromotedOnSubgraphNode(
host,
{
sourceNodeId: previewNode.id,
sourceWidgetName: 'other'
},
previewWidget
)
).toBe(false)
})
it('deduplicates preview exposures when the same preview is promoted twice', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(previewNode)
const previewWidget = widget({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
serialize: false,
type: 'preview'
})
promoteWidget(previewNode, previewWidget, [host])
promoteWidget(previewNode, previewWidget, [host])
expect(
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
).toHaveLength(1)
})
it('demotes preview exposures when no linked value promotion exists', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(previewNode)
const previewWidget = widget({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
serialize: false,
type: 'preview'
})
promoteWidget(previewNode, previewWidget, [host])
demoteWidget(previewNode, previewWidget, [host])
expect(
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
).toEqual([])
})
it('leaves unexposed preview widgets unchanged when demoted', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(previewNode)
const previewWidget = widget({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
serialize: false,
type: 'preview'
})
demoteWidget(previewNode, previewWidget, [host])
expect(
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
).toEqual([])
expect(addBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining(CANVAS_IMAGE_PREVIEW_WIDGET)
})
)
})
})
describe('promoteRecommendedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -651,49 +346,6 @@ describe('promoteRecommendedWidgets', () => {
)
})
it('keeps value promotion idempotent when the widget is already linked', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Prompt')
const input = interiorNode.addInput('text', 'STRING')
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
input.widget = { name: textWidget.name }
subgraph.add(interiorNode)
expect(
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
.ok
).toBe(true)
expect(
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
.ok
).toBe(true)
expect(subgraph.inputs.map((slot) => slot.name)).toEqual(['text'])
})
it('seeds outer promoted widget state from a nested promoted input', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
writePromotedInputValue(innerHost, 'text', 'inner value')
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
expect(
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, 'text')
).ok
).toBe(true)
const hostInput = outerHost.inputs.find((input) => input.name === 'text')
if (!hostInput?.widgetId) throw new Error('Missing promoted host widget id')
expect(useWidgetValueStore().getWidget(hostInput.widgetId)?.value).toBe(
'inner value'
)
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -762,24 +414,6 @@ describe('promoteRecommendedWidgets', () => {
})
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('records a breadcrumb when a recommended value widget has no source slot', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('CLIPTextEncode')
interiorNode.type = 'CLIPTextEncode'
interiorNode.addWidget('text', 'text', '', () => {})
subgraph.add(interiorNode)
promoteRecommendedWidgets(subgraphNode)
expect(addBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
level: 'warning',
message: expect.stringContaining('missingSourceSlot')
})
)
})
})
describe('autoExposeKnownPreviewNodes', () => {
@@ -848,52 +482,6 @@ describe('autoExposeKnownPreviewNodes', () => {
.map((e) => e.sourceNodeId)
).not.toContain(String(glslNode.id))
})
it('defers preview discovery for nodes without eager preview widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('DeferredPreview')
const rafCallbacks: FrameRequestCallback[] = []
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
rafCallbacks.push(callback)
return rafCallbacks.length
})
subgraph.add(interiorNode)
try {
autoExposeKnownPreviewNodes(subgraphNode)
rafCallbacks[0]?.(0)
const updateCallback = updatePreviewsMock.mock.calls[0]?.[1]
const previewWidget = interiorNode.addWidget(
'preview' as Parameters<typeof interiorNode.addWidget>[0],
'preview',
'',
() => {}
)
previewWidget.serialize = false
previewWidget.type = 'preview'
updateCallback?.()
expect(updatePreviewsMock).toHaveBeenCalledWith(
interiorNode,
expect.any(Function)
)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
String(subgraphNode.id)
)
).toContainEqual({
name: 'preview',
sourceNodeId: String(interiorNode.id),
sourcePreviewName: 'preview'
})
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
})
describe('hasUnpromotedWidgets', () => {
@@ -1085,25 +673,6 @@ describe('reorderSubgraphInputsByName', () => {
])
})
it('leaves unordered names after explicitly ordered inputs', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'number' },
{ name: 'second', type: 'number' },
{ name: 'third', type: 'number' }
]
})
const host = createTestSubgraphNode(subgraph)
reorderSubgraphInputsByName(host, ['second'])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'second',
'first',
'third'
])
})
it('updates subgraph input link slot indices after reordering', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
@@ -1199,33 +768,6 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
'first value'
])
})
it('appends promoted inputs that are absent from the widget order', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
reorderSubgraphInputsByWidgetOrder(host, [
promotedWidgetRef(host, 'second')
])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'second',
'first'
])
})
})
describe('demoteWidget — axiomatic projection retraction', () => {
@@ -1256,23 +798,6 @@ describe('demoteWidget — axiomatic projection retraction', () => {
return { host, interiorNode, interiorWidget }
}
it('runs as a no-op for an unpromoted non-preview widget', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
host.subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'value', 'initial', () => {})
demoteWidget(interiorNode, widget, [host])
expect(host.subgraph.inputs).toEqual([])
expect(addBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Demoted widget "value"')
})
)
})
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
@@ -1418,54 +943,4 @@ describe('disambiguated nested promotion identity', () => {
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
})
it('promotes a widget whose source widget state is missing', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Source')
subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('text', 'STRING')
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
interiorInput.widget = { name: interiorWidget.name }
interiorInput.widgetId = 'missing-widget-state' as WidgetId
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
).toBe(true)
expect(host.subgraph.inputs.map((input) => input.name)).toEqual(['text'])
})
it('keeps plain inputs after ordered promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'plain', type: 'STRING' }]
})
const host = createTestSubgraphNode(subgraph)
reorderSubgraphInputsByWidgetOrder(host, [
{ widgetId: 'missing-widget-state' as WidgetId }
])
expect(host.inputs.map((input) => input.name)).toEqual(['plain'])
})
it('falls back to append order when promoted input links are stale', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Source')
subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('text', 'STRING')
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
interiorInput.widget = { name: interiorWidget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
).toBe(true)
const promotedInput = host.subgraph.inputs[0]
const linkId = promotedInput.linkIds[0]
host.subgraph.links.delete(linkId)
reorderSubgraphInputsByWidgetOrder(host, [promotedWidgetRef(host, 'text')])
expect(host.inputs.map((input) => input.name)).toEqual(['text'])
})
})

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveInputType } from './dynamicTypes'
describe('resolveInputType', () => {
it('splits concrete comma-delimited input types', () => {
expect(resolveInputType({ type: 'MODEL,CLIP' } as never)).toEqual([
'MODEL',
'CLIP'
])
})
it('resolves match-type templates from allowed types', () => {
expect(
resolveInputType({
type: 'COMFY_MATCHTYPE_V3',
template: {
allowed_types: 'IMAGE,MASK',
template_id: 'image'
}
} as never)
).toEqual(['IMAGE', 'MASK'])
})
it('returns an empty type list for invalid match-type templates', () => {
expect(resolveInputType({ type: 'COMFY_MATCHTYPE_V3' } as never)).toEqual(
[]
)
})
it('resolves autogrow templates from required and optional inputs', () => {
expect(
resolveInputType({
type: 'COMFY_AUTOGROW_V3',
template: {
input: {
required: {
image: ['IMAGE', {}]
},
optional: {
mask: ['MASK,IMAGE', {}]
}
}
}
} as never)
).toEqual(['IMAGE', 'MASK', 'IMAGE'])
})
it('returns an empty type list for invalid autogrow templates', () => {
expect(resolveInputType({ type: 'COMFY_AUTOGROW_V3' } as never)).toEqual([])
})
})

View File

@@ -1,19 +1,13 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import { app } from '@/scripts/app'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useLitegraphService } from '@/services/litegraphService'
import type { HasInitialMinSize } from '@/services/litegraphService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toLinkId } from '@/types/linkId'
import { applyDynamicInputs, dynamicWidgets } from './dynamicWidgets'
setActivePinia(createTestingPinia({ stubActions: false }))
setActivePinia(createTestingPinia())
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
type TestAutogrowNode = LGraphNode & {
comfyDynamic: { autogrow: Record<string, unknown> }
@@ -21,15 +15,6 @@ type TestAutogrowNode = LGraphNode & {
const { addNodeInput } = useLitegraphService()
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
})
function mockConfiguringGraph() {
vi.spyOn(app, 'configuringGraph', 'get').mockReturnValue(true)
}
function nextTick() {
return new Promise<void>((r) => requestAnimationFrame(() => r()))
}
@@ -71,23 +56,6 @@ function addAutogrow(node: LGraphNode, template: unknown) {
})
)
}
function addMatchType(
node: LGraphNode,
name: string,
allowedTypes = '*',
templateId = 'a'
) {
addNodeInput(
node,
transformInputSpecV1ToV2(
[
'COMFY_MATCHTYPE_V3',
{ template: { allowed_types: allowedTypes, template_id: templateId } }
],
{ name, isOptional: false }
)
)
}
function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
const node2 = testNode()
node2.addOutput('out', '*')
@@ -148,312 +116,7 @@ describe('Dynamic Combos', () => {
node.widgets[0].value = '1'
expect.soft(node.widgets[1].tooltip).toBe('1')
})
test('throws for malformed dynamic combo specs before creating a widget', () => {
const node = testNode()
const comboApp = fromPartial<
Parameters<typeof dynamicWidgets.COMFY_DYNAMICCOMBO_V3>[3]
>({ widgets: { COMBO: vi.fn() } })
expect(() =>
dynamicWidgets.COMFY_DYNAMICCOMBO_V3(
node,
'bad',
['COMFY_DYNAMICCOMBO_V3', {}] as InputSpec,
comboApp
)
).toThrow('invalid DynamicCombo spec')
expect(comboApp.widgets.COMBO).not.toHaveBeenCalled()
})
test('clears grouped widgets when selection becomes empty', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
node.widgets[0].value = '1'
const onRemove = vi.fn()
node.widgets[1].onRemove = onRemove
node.widgets[0].value = undefined
expect(onRemove).toHaveBeenCalled()
expect(node.widgets).toHaveLength(1)
})
test('deletes widget state when removing grouped dynamic widgets', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addDynamicCombo(node, [['INT'], ['STRING']])
const childWidget = node.widgets[1]
const childWidgetId = childWidget.widgetId
if (!childWidgetId) throw new Error('Missing child widget id')
const deleteWidget = vi.mocked(useWidgetValueStore().deleteWidget)
node.widgets[0].value = undefined
expect(deleteWidget).toHaveBeenCalledWith(childWidgetId)
})
test('preserves an existing dynamic input link when refreshing a selection', () => {
const graph = new LGraph()
const node = testNode()
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
graph.add(node)
addDynamicCombo(node, [['IMAGE'], ['STRING']])
node.widgets[0].value = '0'
connectInput(node, 1, graph)
const linkId = node.inputs[1].link
expect(linkId).not.toBeNull()
onConnectionsChange.mockClear()
node.widgets[0].value = '0'
expect(node.inputs[1].link).toBe(linkId)
expect(graph.links[linkId!].target_slot).toBe(1)
expect(onConnectionsChange).toHaveBeenCalledWith(
LiteGraph.INPUT,
1,
true,
graph.links[linkId!],
node.inputs[1]
)
})
test('throws if the backing widgets array disappears during update', () => {
const node: LGraphNode = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
const controller = node.widgets![0]
node.widgets = undefined
expect(() => {
controller.value = '1'
}).toThrow('Not Reachable')
})
test('throws when the dynamic controller widget is missing during update', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
const controller = node.widgets[0]
node.widgets = node.widgets.slice(1)
expect(() => {
controller.value = '1'
}).toThrow("Dynamic widget doesn't exist on node")
})
test('throws when input-only dynamic sockets have no insertion point', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['IMAGE']])
const controller = node.widgets[0]
node.inputs = []
expect(() => {
controller.value = '1'
}).toThrow('Failed to find input socket for 0')
})
test('updates dynamic inputs without requiring a graph', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['IMAGE']])
node.widgets[0].value = '1'
expect(node.inputs[1].type).toBe('IMAGE')
})
test('reads dynamic combo values from widget state when available', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addDynamicCombo(node, [['INT'], ['STRING']])
const controller = node.widgets[0]
const controllerId = controller.widgetId
if (!controllerId) throw new Error('Missing controller widget id')
controller.value = '1'
useWidgetValueStore().setValue(controllerId, '0')
expect(controller.value).toBe('0')
})
})
describe('Dynamic input dispatch', () => {
test('returns false for unknown dynamic input types', () => {
const node = testNode()
expect(
applyDynamicInputs(node, {
name: 'plain',
type: 'STRING',
isOptional: false
})
).toBe(false)
})
test('returns true after applying a known dynamic input type', () => {
const node = testNode()
expect(
applyDynamicInputs(
node,
transformInputSpecV1ToV2(
[
'COMFY_AUTOGROW_V3',
{ template: { input: { required: { image: ['IMAGE', {}] } } } }
],
{ name: 'grow', isOptional: false }
)
)
).toBe(true)
})
test('throws when an autogrow input spec is malformed', () => {
const node = testNode()
const inputSpec = {
name: 'bad',
type: 'COMFY_AUTOGROW_V3'
} as InputSpecV2
expect(() => addNodeInput(node, inputSpec)).toThrow('invalid Autogrow spec')
})
test('ignores malformed match type specs', () => {
const node = testNode()
expect(
applyDynamicInputs(node, {
name: 'bad',
type: 'COMFY_MATCHTYPE_V3',
isOptional: false
})
).toBe(true)
expect(node.inputs).toHaveLength(0)
})
})
describe('MatchType inputs', () => {
function createMatchTypeNode(graph: LGraph, outputMatchTypes = ['a']) {
const node = testNode()
node.constructor.nodeData = {
name: 'testnode',
output_matchtypes: outputMatchTypes
} as typeof node.constructor.nodeData
node.addOutput('out', '*')
graph.add(node)
addMatchType(node, 'on_true')
addMatchType(node, 'on_false')
return node
}
function createSourceNode(graph: LGraph, type: string) {
const node = testNode()
node.addOutput('out', type)
graph.add(node)
return node
}
test('ignores match type notifications outside registered inputs', () => {
const graph = new LGraph()
const node = createMatchTypeNode(graph)
node.addInput('plain', 'STRING')
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, node.inputs[2])
expect(node.outputs[0].type).toBe('*')
})
test('uses wildcard types for stale match type links', () => {
const graph = new LGraph()
const node = createMatchTypeNode(graph)
node.inputs[0].link = toLinkId(999)
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
expect(node.outputs[0].type).toBe('*')
})
test('leaves unmatched output groups unchanged', () => {
const graph = new LGraph()
const node = createMatchTypeNode(graph, ['other'])
const source = createSourceNode(graph, 'IMAGE')
source.connect(0, node, 0)
expect(node.outputs[0].type).toBe('*')
})
test('throws when match group input constraints cannot overlap', () => {
const graph = new LGraph()
const node = testNode()
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation(() => 1)
node.constructor.nodeData = {
name: 'testnode',
output_matchtypes: ['a']
} as typeof node.constructor.nodeData
node.addOutput('out', '*')
graph.add(node)
addMatchType(node, 'image', 'IMAGE')
addMatchType(node, 'latent', 'LATENT')
const source = createSourceNode(graph, 'IMAGE')
try {
expect(() => source.connect(0, node, 0)).toThrow('invalid connection')
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
test('disconnects downstream links when a match type output narrows', () => {
const graph = new LGraph()
const node = createMatchTypeNode(graph)
const downstream = testNode()
downstream.addInput('latent', 'LATENT')
downstream.onConnectionsChange = vi.fn()
graph.add(downstream)
node.connect(0, downstream, 0)
const source = createSourceNode(graph, 'IMAGE')
source.connect(0, node, 0)
expect(downstream.inputs[0].link).toBeNull()
expect(downstream.onConnectionsChange).toHaveBeenCalledWith(
LiteGraph.INPUT,
0,
false,
expect.anything(),
downstream.inputs[0]
)
})
test('ignores deferred match type refresh after the input is removed', () => {
const graph = new LGraph()
const node = testNode()
const rafCallbacks: FrameRequestCallback[] = []
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
rafCallbacks.push(callback)
return rafCallbacks.length
})
graph.add(node)
try {
addMatchType(node, 'removed')
node.inputs.pop()
rafCallbacks[0]?.(0)
expect(node.inputs).toHaveLength(0)
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
})
describe('Autogrow', () => {
const inputsSpec = { required: { image: ['IMAGE', {}] } }
test('Can name by prefix', () => {
@@ -499,244 +162,6 @@ describe('Autogrow', () => {
connectInput(node, 2, graph)
expect(node.inputs.length).toBe(3)
})
test('ignores autogrow notifications that cannot affect a known input group', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
const inputCount = node.inputs.length
const unknownInput = node.addInput('outside.0', 'IMAGE')
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
node.onConnectionsChange?.(LiteGraph.INPUT, 99, true, null, node.inputs[0])
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, unknownInput)
expect(node.inputs).toHaveLength(inputCount + 1)
})
test('does not grow autogrow inputs when connection metadata is missing', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.onConnectionsChange?.(LiteGraph.INPUT, 1, true, null, node.inputs[1])
expect(node.inputs).toHaveLength(2)
})
test('keeps minimum autogrow rows when disconnecting early ordinals', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 2, input: inputsSpec, prefix: 'test' })
node.onConnectionsChange?.(LiteGraph.INPUT, 0, false, null, node.inputs[0])
await nextTick()
expect(node.inputs).toHaveLength(3)
})
test('restores a configure-time autogrow widget shim', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.inputs[1].widget = { name: node.inputs[1].name }
mockConfiguringGraph()
connectInput(node, 1, graph)
expect(node.widgets.some((widget) => widget.name === '0.test1')).toBe(true)
})
test('draws configure-time autogrow shim text from the input name', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.inputs[1].widget = { name: node.inputs[1].name }
mockConfiguringGraph()
connectInput(node, 1, graph)
const shim = node.widgets.find((widget) => widget.name === '0.test1')
if (!shim?.draw) throw new Error('Missing shim widget')
node.inputs[1].label = undefined
const ctx = fromPartial<CanvasRenderingContext2D>({
save: vi.fn(),
fillText: vi.fn(),
restore: vi.fn()
})
shim.draw(ctx, node, 100, 10, 20)
expect(ctx.fillText).toHaveBeenCalledWith('0.test1', 20, 25)
})
test('keeps an existing configure-time autogrow widget shim', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.inputs[1].widget = { name: node.inputs[1].name }
node.widgets.push({
name: node.inputs[1].name,
type: 'shim',
y: 0,
options: {},
serialize: false,
draw: vi.fn()
})
mockConfiguringGraph()
connectInput(node, 1, graph)
expect(
node.widgets.filter((widget) => widget.name === '0.test1')
).toHaveLength(1)
})
test('defers disconnect handling during an input swap', () => {
const graph = new LGraph()
const node = testNode()
const rafCallbacks: FrameRequestCallback[] = []
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
rafCallbacks.push(callback)
return rafCallbacks.length
})
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
try {
connectInput(node, 0, graph)
node.disconnectInput(0)
expect(node.inputs).toHaveLength(2)
expect(rafCallbacks).toHaveLength(2)
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
test('stops cleanup for uneven multi-input autogrow groups', async () => {
const graph = new LGraph()
const node = testNode()
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
graph.add(node)
addAutogrow(node, {
min: 1,
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
})
node.inputs.pop()
try {
node.onConnectionsChange?.(
LiteGraph.INPUT,
0,
false,
null,
node.inputs[0]
)
await nextTick()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to group multi-input autogrow inputs'
)
} finally {
consoleErrorSpy.mockRestore()
}
})
test('keeps trailing autogrow row when disconnecting the last slot', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
await nextTick()
expect(node.inputs.map((input) => input.name)).toEqual([
'0.test0',
'0.test1'
])
})
test('ignores named autogrow input names outside the configured list', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, names: ['a', 'b'] })
const unknownInput = node.addInput('0.c', 'IMAGE')
node.onConnectionsChange?.(
LiteGraph.INPUT,
node.inputs.length - 1,
false,
null,
unknownInput
)
await nextTick()
expect(node.inputs.map((input) => input.name)).toEqual([
'0.a',
'0.b',
'0.c'
])
})
test('ignores autogrow input names without numeric ordinals', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
const unknownInput = node.addInput('0.testx', 'IMAGE')
node.onConnectionsChange?.(
LiteGraph.INPUT,
node.inputs.length - 1,
false,
null,
unknownInput
)
await nextTick()
expect(node.inputs.map((input) => input.name)).toEqual([
'0.test0',
'0.test1',
'0.testx'
])
})
test('marks optional autogrow inputs as optional after required inputs', () => {
const node = testNode()
addAutogrow(node, {
min: 1,
input: {
required: { image: ['IMAGE', {}] },
optional: { mask: ['MASK', {}] }
}
})
expect(node.inputs.map((input) => input.name)).toEqual([
'0.image0',
'0.mask0',
'0.image1',
'0.mask1'
])
expect(node.inputs.map((input) => input.type)).toEqual([
'IMAGE',
'MASK',
'IMAGE',
'MASK'
])
})
test('Removing connections decreases to min + 1', async () => {
const graph = new LGraph()
const node = testNode()
@@ -833,42 +258,6 @@ describe('Autogrow', () => {
expect(vid0Link).not.toBeNull()
expect(graph.links[vid0Link!].target_slot).toBe(vid0Index)
})
test('removes shim widgets when multi-input autogrow rows shrink', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, {
min: 1,
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
})
connectInput(node, 2, graph)
await nextTick()
expect(node.inputs).toHaveLength(6)
const removedWidgetNames = ['0.image2', '0.mask2']
const onRemove = vi.fn()
for (const widget of node.widgets.filter((widget) =>
removedWidgetNames.includes(widget.name)
)) {
widget.onRemove = onRemove
}
node.disconnectInput(2)
await nextTick()
expect(node.inputs.map((input) => input.name)).toEqual([
'0.image0',
'0.mask0',
'0.image1',
'0.mask1'
])
expect(onRemove).toHaveBeenCalledTimes(2)
expect(
node.widgets.some((widget) => removedWidgetNames.includes(widget.name))
).toBe(false)
})
test('Can deserialize a complex node', async () => {
const graph = new LGraph()
const node = testNode()

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
@@ -53,7 +54,6 @@ function createSourceNode(graph: LGraph, type: string) {
describe('MatchType during configure', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
})
@@ -73,22 +73,30 @@ describe('MatchType during configure', () => {
const link2Id = switchNode.inputs[1].link!
const outputTypeBefore = switchNode.outputs[0].type
vi.spyOn(app, 'configuringGraph', 'get').mockReturnValue(true)
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 1
const link1 = graph.links[link1Id]
switchNode.onConnectionsChange?.(
LiteGraph.INPUT,
0,
true,
link1,
switchNode.inputs[0]
)
try {
const link1 = graph.links[link1Id]
switchNode.onConnectionsChange?.(
LiteGraph.INPUT,
0,
true,
link1,
switchNode.inputs[0]
)
expect(switchNode.inputs[0].link).toBe(link1Id)
expect(switchNode.inputs[1].link).toBe(link2Id)
expect(graph.links[link1Id]).toBeDefined()
expect(graph.links[link2Id]).toBeDefined()
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
expect(switchNode.inputs[0].link).toBe(link1Id)
expect(switchNode.inputs[1].link).toBe(link2Id)
expect(graph.links[link1Id]).toBeDefined()
expect(graph.links[link2Id]).toBeDefined()
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
} finally {
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 0
}
})
test('performs type recalculation during normal operation', () => {
@@ -119,45 +127,4 @@ describe('MatchType during configure', () => {
expect(switchNode.inputs[1].link).not.toBeNull()
expect(switchNode.outputs[0].type).toBe('IMAGE')
})
test('keeps compatible downstream links after output type recalculation', () => {
const graph = new LGraph()
const switchNode = createMatchTypeNode(graph)
const target = new LGraphNode('target')
target.addInput('image', 'IMAGE')
target.onConnectionsChange = vi.fn()
graph.add(target)
const source = createSourceNode(graph, 'IMAGE')
switchNode.connect(0, target, 0)
vi.mocked(target.onConnectionsChange).mockClear()
source.connect(0, switchNode, 0)
expect(switchNode.outputs[0].type).toBe('IMAGE')
expect(target.inputs[0].link).not.toBeNull()
expect(target.onConnectionsChange).toHaveBeenCalledWith(
LiteGraph.INPUT,
0,
true,
expect.anything(),
target.inputs[0]
)
})
test('disconnects incompatible downstream links after output type recalculation', () => {
const graph = new LGraph()
const switchNode = createMatchTypeNode(graph)
const target = new LGraphNode('target')
target.addInput('image', 'IMAGE')
graph.add(target)
const source = createSourceNode(graph, 'LATENT')
switchNode.connect(0, target, 0)
expect(target.inputs[0].link).not.toBeNull()
source.connect(0, switchNode, 0)
expect(switchNode.outputs[0].type).toBe('LATENT')
expect(target.inputs[0].link).toBeNull()
})
})

View File

@@ -1,47 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
import type { GroupNodeWorkflowData } from './groupNode'
const appMock = vi.hoisted(() => ({
canvas: {
emitAfterChange: vi.fn(),
emitBeforeChange: vi.fn(),
selected_nodes: {}
},
registerExtension: vi.fn(),
registerNodeDef: vi.fn(),
rootGraph: {
convertToSubgraph: vi.fn(),
extra: {},
getNodeById: vi.fn(),
links: {},
nodes: [],
remove: vi.fn()
}
}))
const widgetStoreMock = vi.hoisted(() => ({
inputIsWidget: vi.fn((spec: unknown[]) =>
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
)
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => widgetStoreMock
app: {
registerExtension: vi.fn()
}
}))
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
@@ -59,46 +26,6 @@ function makeNode(type: string): ComfyNode {
}
}
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
return {
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
...overrides
} as ComfyNodeDef
}
function extension(): ComfyExtension {
const groupExtension = appMock.registerExtension.mock.calls.find(
([registered]) => registered.name === 'Comfy.GroupNode'
)?.[0]
if (!groupExtension) throw new Error('GroupNode extension was not registered')
return groupExtension as ComfyExtension
}
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
const groupExtension = extension()
if (!groupExtension.addCustomNodeDefs) {
throw new Error('GroupNode extension does not implement addCustomNodeDefs')
}
groupExtension.addCustomNodeDefs(defs, fromPartial<ComfyApp>(appMock))
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
appMock.registerNodeDef.mockReset()
widgetStoreMock.inputIsWidget.mockClear()
LiteGraph.registered_node_types = {}
addCustomNodeDefs({})
})
describe('replaceLegacySeparators', () => {
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
const nodes = [makeNode('workflow/My Group')]
@@ -122,7 +49,7 @@ describe('replaceLegacySeparators', () => {
describe('GroupNodeConfig.getLinks', () => {
function configFrom(
links: SerialisedLLinkArray[],
external: (number | string | null)[][] = []
external: (number | string)[][] = []
) {
const nodeData: GroupNodeWorkflowData = {
nodes: [
@@ -177,390 +104,4 @@ describe('GroupNodeConfig.getLinks', () => {
const config = configFrom([], [[0, 1, 'IMAGE']])
expect(config.externalFrom[0][1]).toBe('IMAGE')
})
it('ignores external links without a type and accumulates multiple slots', () => {
const config = configFrom(
[],
[
[0, 1, null],
[0, 2, 'LATENT'],
[0, 3, 'IMAGE']
]
)
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
})
})
describe('GroupNodeConfig.getNodeDef', () => {
const imageNodeDef = makeNodeDef({
name: 'ImageNode',
input: {
required: {
image: ['IMAGE', {}],
mode: [['fast', 'slow'], {}]
},
optional: {
strength: ['FLOAT', { default: 1 }]
}
},
output: ['IMAGE'],
output_name: ['image'],
output_is_list: [false]
})
beforeEach(() => {
addCustomNodeDefs({ ImageNode: imageNodeDef })
})
it('returns registered definitions for normal node types', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'ImageNode' }],
links: [],
external: []
})
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
imageNodeDef
)
})
it('returns undefined for nodes without an index or a known type', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ type: 'UnknownNode' }],
links: [],
external: []
})
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
})
it('skips unlinked primitive nodes', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'PrimitiveNode' }],
links: [],
external: []
})
expect(
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
).toBeUndefined()
})
it('derives primitive node type from the outgoing link type', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'PrimitiveNode' },
{ index: 1, type: 'ImageNode' }
],
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
external: []
})
expect(
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
).toMatchObject({
input: { required: { value: ['IMAGE', {}] } },
output: ['IMAGE']
})
})
it('falls back to null when primitive combo target spec is not primitive', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{
index: 0,
type: 'PrimitiveNode',
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
},
{ index: 1, type: 'ImageNode' }
],
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
external: []
})
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
input: { required: { value: [null, {}] } },
output: [null]
})
})
it('returns null for reroutes used only inside the group', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'ImageNode' },
{ index: 1, type: 'Reroute' },
{ index: 2, type: 'ImageNode' }
],
links: [
[0, 0, 1, 0, 1, 'IMAGE'],
[1, 0, 2, 0, 2, 'IMAGE']
] as SerialisedLLinkArray[],
external: []
})
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
})
it('derives reroute type from outgoing target inputs', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'Reroute' },
{
index: 1,
type: 'ImageNode',
inputs: [{ name: 'image', type: 'IMAGE' }]
}
],
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
external: [[0, 0, 'IMAGE']]
})
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
output: ['IMAGE']
})
})
it('derives reroute type from incoming output metadata', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
{ index: 1, type: 'Reroute' }
],
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
external: [[1, 0, 'LATENT']]
})
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
output: ['LATENT']
})
})
it('derives pipe reroute type from external metadata when links omit it', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'Reroute' }],
links: [],
external: [[0, 0, 'MASK']]
})
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
input: { required: { MASK: ['MASK', { forceInput: true }] } },
output: ['MASK']
})
})
})
describe('GroupNodeConfig input and output mapping', () => {
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
const config = new GroupNodeConfig('group', {
nodes: [node],
links: [],
external: [],
config: {
0: {
input: {
hidden: { visible: false },
renamed: { name: 'Custom Name' }
},
output: {
1: { name: 'Custom Output' },
2: { visible: false }
}
}
}
})
config.nodeDef = makeNodeDef({
input: { required: {} },
output: [],
output_name: [],
output_is_list: []
})
return config
}
it('renames duplicate inputs and adds seed control metadata', () => {
const config = configWithNode({
index: 0,
type: 'Sampler',
title: 'Sampler A',
inputs: [{ name: 'seed', label: 'Seed Label' }]
})
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
const result = config.getInputConfig(
{ index: 0, type: 'Sampler', title: 'Sampler A' },
'seed',
seenInputs,
['INT', {}]
)
expect(result.name).toBe('Sampler A 1 seed')
expect(result.config).toEqual([
'INT',
{ control_after_generate: 'Sampler A control_after_generate' }
])
})
it('maps image upload widget aliases through converted widget names', () => {
const config = configWithNode({ index: 0, type: 'LoadImage' })
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
expect(
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
'IMAGEUPLOAD',
{ widget: 'customImage' }
])
).toMatchObject({
name: 'Custom Name',
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
})
})
it('splits widget inputs, socket inputs, and converted widget slots', () => {
const config = configWithNode({
index: 0,
type: 'MixedNode',
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
})
const result = config.processWidgetInputs(
{
mode: ['COMBO', {}],
image: ['IMAGE', {}]
},
{
index: 0,
type: 'MixedNode',
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
},
['mode', 'image'],
{}
)
expect(result.slots).toEqual(['image'])
expect(result.converted.get(0)).toBe('mode')
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
})
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
const config = configWithNode({
index: 0,
type: 'InputNode'
})
const inputMap: Record<number, number> = {}
config.processInputSlots(
{
image: ['IMAGE', {}],
hidden: ['LATENT', {}]
},
{ index: 0, type: 'InputNode' },
['image', 'hidden'],
{},
inputMap,
{}
)
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
expect(inputMap).toEqual({ 0: 0 })
})
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
const config = configWithNode({
index: 0,
type: 'OutputNode',
title: 'Output A',
outputs: [{ name: 'image', label: 'Rendered' }]
})
config.linksFrom[0] = {
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
}
config.processNodeOutputs(
{ index: 0, type: 'OutputNode', title: 'Output A' },
{ Rendered: 1 },
{
input: { required: {} },
output: ['IMAGE', 'LATENT', 'MASK'],
output_name: ['image', 'latent', 'mask'],
output_is_list: [false, true, false]
}
)
expect(config.outputVisibility).toEqual([false, true, false])
expect(config.nodeDef?.output).toEqual(['LATENT'])
expect(config.nodeDef?.output_is_list).toEqual([true])
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
})
})
describe('GroupNodeConfig.registerFromWorkflow', () => {
it('adds missing type actions and skips registration for incomplete groups', async () => {
const groupNodes: Record<string, GroupNodeWorkflowData> = {
Broken: {
nodes: [{ index: 0, type: 'MissingNode' }],
links: [],
external: []
}
}
const missingNodeTypes: Parameters<
typeof GroupNodeConfig.registerFromWorkflow
>[1] = []
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
expect(missingNodeTypes).toHaveLength(2)
expect(missingNodeTypes[0]).toMatchObject({
type: 'MissingNode',
hint: " (In group node 'workflow>Broken')"
})
const action = missingNodeTypes[1]
if (typeof action === 'string') {
throw new Error('Expected an action entry for the broken group node')
}
const target = document.createElement('button')
const { callback } = action.action as {
callback: (event: MouseEvent) => void
}
const event = new MouseEvent('click')
Object.defineProperty(event, 'target', { value: target })
callback(event)
expect(groupNodes.Broken).toBeUndefined()
expect(target.textContent).toBe('Removed')
expect(target.style.pointerEvents).toBe('none')
})
it('registers complete group node types and stores their generated node defs', async () => {
addCustomNodeDefs({
ImageNode: makeNodeDef({
name: 'ImageNode',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE'],
output_name: ['image'],
output_is_list: [false]
})
})
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
await GroupNodeConfig.registerFromWorkflow(
{
Complete: {
nodes: [{ index: 0, type: 'ImageNode' }],
links: [],
external: [[0, 0, 'IMAGE']]
}
},
[]
)
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
'workflow>Complete',
expect.objectContaining({
category: 'group nodes>workflow',
display_name: 'Complete',
name: 'workflow>Complete'
})
)
})
})

View File

@@ -62,7 +62,7 @@ interface GroupNodeConfigEntry {
}
export interface GroupNodeWorkflowData {
external: (number | string | null)[][]
external: (number | string)[][]
links: SerialisedLLinkArray[]
nodes: {
index?: number

View File

@@ -260,6 +260,7 @@ useExtensionService().registerExtension({
if (!isLoad3dNode(selectedNode)) return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
const props = { node: selectedNode }

View File

@@ -2494,7 +2494,6 @@ export class LGraph
// Deprecated - old schema version, links are arrays
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
if (!linkData) continue
const link = LLink.createFromArray(linkData)
this._links.set(link.id, link)
}

View File

@@ -3799,7 +3799,7 @@ export class LGraphNode
const title = String(rawTitle) + (this.pinned ? '📌' : '')
if (title) {
if (selected) {
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR ?? '#FFF'
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR
} else {
ctx.fillStyle = this.constructor.title_text_color || default_title_color
}

View File

@@ -54,10 +54,10 @@ export class LiteGraphGlobal {
NODE_COLLAPSED_RADIUS = 10
NODE_COLLAPSED_WIDTH = 80
NODE_TITLE_COLOR = '#999'
NODE_SELECTED_TITLE_COLOR: string | undefined = '#FFF'
NODE_SELECTED_TITLE_COLOR = '#FFF'
NODE_TEXT_SIZE = 14
NODE_TEXT_COLOR = '#AAA'
NODE_TEXT_HIGHLIGHT_COLOR: string | undefined = '#EEE'
NODE_TEXT_HIGHLIGHT_COLOR = '#EEE'
NODE_SUBTEXT_SIZE = 12
NODE_DEFAULT_COLOR = '#333'
NODE_DEFAULT_BGCOLOR = '#353535'

View File

@@ -1,451 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromPartial } from '@total-typescript/shoehorn'
import type {
DefaultConnectionColors,
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SlotType } from '@/lib/litegraph/src/draw'
import {
LinkDirection,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import { toLinkId } from '@/types/linkId'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeInputSlot } from './NodeInputSlot'
import { NodeOutputSlot } from './NodeOutputSlot'
function createColorContext(): DefaultConnectionColors {
return {
getConnectedColor: vi.fn(() => '#0f0'),
getDisconnectedColor: vi.fn(() => '#f00')
}
}
function createNode(): LGraphNode {
const node = new LGraphNode('Test Node')
node.pos = [0, 0]
return node
}
function createInputSlot(
overrides: Partial<INodeInputSlot> = {},
node = createNode()
): NodeInputSlot {
return new NodeInputSlot(
{
name: 'in',
type: 'STRING',
link: null,
boundingRect: [10, 20, 20, 20] as const,
...overrides
},
node
)
}
function createOutputSlot(
overrides: Partial<INodeOutputSlot> = {},
node = createNode()
): NodeOutputSlot {
return new NodeOutputSlot(
{
name: 'out',
type: 'STRING',
links: null,
boundingRect: [10, 20, 20, 20] as const,
...overrides
},
node
)
}
describe('NodeSlot rendering', () => {
let ctx: CanvasRenderingContext2D
let colorContext: DefaultConnectionColors
beforeEach(() => {
ctx = createMockCanvasRenderingContext2D()
colorContext = createColorContext()
})
describe('draw', () => {
it('draws a disconnected circle slot with its label', () => {
const slot = createInputSlot()
slot.draw(ctx, { colorContext })
expect(colorContext.getDisconnectedColor).toHaveBeenCalledWith('STRING')
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
4,
0,
Math.PI * 2
)
expect(ctx.fill).toHaveBeenCalled()
expect(ctx.fillText).toHaveBeenCalledWith(
'in',
expect.any(Number),
expect.any(Number)
)
})
it('uses the connected colour and a larger radius when highlighted', () => {
const slot = createInputSlot({ link: toLinkId(1) })
slot.draw(ctx, { colorContext, highlight: true })
expect(colorContext.getConnectedColor).toHaveBeenCalledWith('STRING')
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
5,
0,
Math.PI * 2
)
})
it('prefers color_on over the colour context when connected', () => {
const slot = createInputSlot({ link: toLinkId(1), color_on: '#abc' })
let fillStyleAtFill: typeof ctx.fillStyle | undefined
vi.mocked(ctx.fill).mockImplementation(() => {
fillStyleAtFill = ctx.fillStyle
})
slot.draw(ctx, { colorContext })
expect(fillStyleAtFill).toBe('#abc')
expect(ctx.fillStyle).not.toBe('#abc') // restored after draw
})
it('draws a box for event slots', () => {
const slot = createInputSlot({ type: SlotType.Event })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('draws a box for box-shaped slots', () => {
const slot = createInputSlot({ shape: RenderShape.BOX })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(1)
})
it('draws a triangle for arrow-shaped slots', () => {
const slot = createInputSlot({ shape: RenderShape.ARROW })
slot.draw(ctx, { colorContext })
expect(ctx.moveTo).toHaveBeenCalledTimes(1)
expect(ctx.lineTo).toHaveBeenCalledTimes(2)
expect(ctx.closePath).toHaveBeenCalled()
})
it('draws a 3x3 grid for array-typed slots', () => {
const slot = createInputSlot({ type: SlotType.Array })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(9)
})
it('draws a simple rect and no label in low quality mode', () => {
const slot = createInputSlot()
slot.draw(ctx, { colorContext, lowQuality: true })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('clips hollow circle slots to a ring', () => {
const arc = vi.fn()
vi.stubGlobal(
'Path2D',
class {
arc = arc
}
)
try {
const slot = createInputSlot({ shape: RenderShape.HollowCircle })
slot.draw(ctx, { colorContext, highlight: true })
slot.draw(ctx, { colorContext })
expect(ctx.clip).toHaveBeenCalledTimes(2)
// Inner radius is larger while highlighted.
expect(arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
2.5,
0,
Math.PI * 2
)
expect(arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
1.5,
0,
Math.PI * 2
)
} finally {
vi.unstubAllGlobals()
}
})
it('draws one pie segment per type for multi-type slots', () => {
const slot = createInputSlot({ type: 'STRING,INT' })
slot.draw(ctx, { colorContext })
// Once for the base slot colour, then once per type in the pie.
expect(colorContext.getDisconnectedColor).toHaveBeenCalledTimes(3)
// One filled arc per type, plus the final outline arc.
expect(ctx.arc).toHaveBeenCalledTimes(3)
expect(ctx.stroke).toHaveBeenCalled()
})
it('hides the label for widget input slots', () => {
const slot = createInputSlot({ widget: { name: 'in' } })
slot.draw(ctx, { colorContext })
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('skips the label when there is no text to render', () => {
const slot = createInputSlot({ name: '' })
slot.draw(ctx, { colorContext })
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('draws input labels above the slot when directed up', () => {
const slot = createInputSlot({ dir: LinkDirection.UP })
slot.draw(ctx, { colorContext })
const [, x, y] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentre = [
slot.boundingRect[0] + 10 - slot.node.pos[0],
slot.boundingRect[1] + 10 - slot.node.pos[1]
]
expect(x).toBe(slotCentre[0])
expect(y).toBeLessThan(slotCentre[1])
})
it('draws output labels to the left of the slot', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext })
const [, x] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentreX = slot.boundingRect[0] + 10 - slot.node.pos[0]
expect(x).toBeLessThan(slotCentreX)
})
it('draws output labels above the slot when directed down', () => {
const slot = createOutputSlot({ dir: LinkDirection.DOWN })
slot.draw(ctx, { colorContext })
const [, , y] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentreY = slot.boundingRect[1] + 10 - slot.node.pos[1]
expect(y).toBeLessThan(slotCentreY)
})
it('strokes output slots in normal quality', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext })
expect(ctx.stroke).toHaveBeenCalled()
})
it('does not stroke output slots in low quality', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext, lowQuality: true })
expect(ctx.stroke).not.toHaveBeenCalled()
})
it('rings the slot in red when it has errors', () => {
const slot = createInputSlot({ hasErrors: true })
slot.draw(ctx, { colorContext })
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
12,
0,
Math.PI * 2
)
expect(ctx.stroke).toHaveBeenCalled()
})
})
describe('highlightColor', () => {
const original = {
highlight: LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR,
selectedTitle: LiteGraph.NODE_SELECTED_TITLE_COLOR
}
afterEach(() => {
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = original.highlight
LiteGraph.NODE_SELECTED_TITLE_COLOR = original.selectedTitle
})
it('prefers the dedicated text highlight colour', () => {
expect(createInputSlot().highlightColor).toBe(
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR
)
})
it('falls back to the selected title colour, then text colour', () => {
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = undefined
expect(createInputSlot().highlightColor).toBe(
LiteGraph.NODE_SELECTED_TITLE_COLOR
)
LiteGraph.NODE_SELECTED_TITLE_COLOR = undefined
expect(createInputSlot().highlightColor).toBe(LiteGraph.NODE_TEXT_COLOR)
})
})
describe('renderingLabel', () => {
it.for<[string, Partial<NodeInputSlot>, string]>([
['label', { label: 'A Label', localized_name: 'Localized' }, 'A Label'],
['localized_name', { localized_name: 'Localized' }, 'Localized'],
['name', {}, 'in'],
['empty string', { name: '' }, '']
])('falls back through %s', ([, overrides, expected]) => {
expect(createInputSlot(overrides).renderingLabel).toBe(expected)
})
})
describe('drawCollapsed', () => {
it('draws a box for event slots', () => {
createInputSlot({ type: SlotType.Event }).drawCollapsed(ctx)
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.fill).toHaveBeenCalled()
})
it('draws a box for box-shaped slots', () => {
createInputSlot({ shape: RenderShape.BOX }).drawCollapsed(ctx)
expect(ctx.rect).toHaveBeenCalledTimes(1)
})
it('draws an input-facing arrow for arrow-shaped input slots', () => {
createInputSlot({ shape: RenderShape.ARROW }).drawCollapsed(ctx)
expect(ctx.moveTo).toHaveBeenCalledWith(8, expect.any(Number))
expect(ctx.closePath).toHaveBeenCalled()
})
it('draws an output-facing arrow for arrow-shaped output slots', () => {
const node = createNode()
node._collapsed_width = 60
createOutputSlot({ shape: RenderShape.ARROW }, node).drawCollapsed(ctx)
expect(ctx.moveTo).toHaveBeenCalledWith(66, expect.any(Number))
})
it('draws a circle by default', () => {
createInputSlot().drawCollapsed(ctx)
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
4,
0,
Math.PI * 2
)
})
})
describe('collapsedPos', () => {
it('places output slots at the collapsed node width', () => {
const node = createNode()
node._collapsed_width = 42
expect(createOutputSlot({}, node).collapsedPos[0]).toBe(42)
})
it('falls back to the default collapsed width', () => {
const slot = createOutputSlot()
expect(slot.collapsedPos[0]).toBe(LiteGraph.NODE_COLLAPSED_WIDTH)
})
it('places input slots at the node origin', () => {
expect(createInputSlot().collapsedPos[0]).toBe(0)
})
})
describe('isValidTarget', () => {
it('validates input slots against output slots', () => {
const input = createInputSlot()
const output = createOutputSlot()
expect(input.isValidTarget(output)).toBe(true)
expect(output.isValidTarget(input)).toBe(true)
})
it('rejects connections between incompatible slot types', () => {
const input = createInputSlot()
const output = createOutputSlot({ type: 'INT' })
expect(input.isValidTarget(output)).toBe(false)
})
it('validates output slots against subgraph outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'STRING' }]
})
const subgraphOutput = subgraph.outputNode.slots[0]
expect(createOutputSlot().isValidTarget(subgraphOutput)).toBe(true)
expect(createOutputSlot({ type: 'INT' }).isValidTarget(subgraphOutput)) //
.toBe(false)
})
it('validates input slots against subgraph inputs', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const subgraphInput = subgraph.inputNode.slots[0]
expect(createInputSlot().isValidTarget(subgraphInput)).toBe(true)
})
it('rejects unknown slot shapes', () => {
const input = createInputSlot()
const output = createOutputSlot()
expect(input.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
expect(output.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
})
})
describe('isConnected', () => {
it('reports output connectivity from the links array', () => {
expect(createOutputSlot().isConnected).toBe(false)
expect(createOutputSlot({ links: [] }).isConnected).toBe(false)
expect(createOutputSlot({ links: [toLinkId(1)] }).isConnected).toBe(true)
})
})
})

View File

@@ -1,90 +1,18 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/litegraph'
import {
NodeInputSlot,
NodeOutputSlot,
inputAsSerialisable,
outputAsSerialisable
} from '@/lib/litegraph/src/litegraph'
import { SlotType } from '@/lib/litegraph/src/draw'
import type {
DefaultConnectionColors,
ReadOnlyRect
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
LinkDirection,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import { toLinkId } from '@/types/linkId'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
const boundingRect: ReadOnlyRect = [0, 0, 10, 10]
type MockCanvasContext = CanvasRenderingContext2D & {
arc: ReturnType<typeof vi.fn>
beginPath: ReturnType<typeof vi.fn>
clip: ReturnType<typeof vi.fn>
closePath: ReturnType<typeof vi.fn>
fill: ReturnType<typeof vi.fn>
fillText: ReturnType<typeof vi.fn>
lineTo: ReturnType<typeof vi.fn>
moveTo: ReturnType<typeof vi.fn>
rect: ReturnType<typeof vi.fn>
restore: ReturnType<typeof vi.fn>
save: ReturnType<typeof vi.fn>
stroke: ReturnType<typeof vi.fn>
}
function createContext(): MockCanvasContext {
return fromPartial<MockCanvasContext>({
fillStyle: '#initial-fill',
strokeStyle: '#initial-stroke',
lineWidth: 7,
textAlign: 'start',
arc: vi.fn(),
beginPath: vi.fn(),
clip: vi.fn(),
closePath: vi.fn(),
fill: vi.fn(),
fillText: vi.fn(),
lineTo: vi.fn(),
moveTo: vi.fn(),
rect: vi.fn(),
restore: vi.fn(),
save: vi.fn(),
stroke: vi.fn()
})
}
function createColors(): DefaultConnectionColors {
return {
getConnectedColor: vi.fn((type) => `connected-${type}`),
getDisconnectedColor: vi.fn((type) => `disconnected-${type}`)
}
}
function createNode(): LGraphNode {
return {
pos: [100, 200],
_collapsed_width: 80
} as LGraphNode
}
describe('NodeSlot', () => {
beforeEach(() => {
vi.stubGlobal(
'Path2D',
class {
arc = vi.fn()
}
)
})
describe('inputAsSerialisable', () => {
it('removes _data from serialized slot', () => {
const slot: INodeOutputSlot = {
@@ -146,328 +74,4 @@ describe('NodeSlot', () => {
expect(serialized.widget).not.toHaveProperty('options')
})
})
describe('rendering', () => {
it('draws an input label on the right and restores canvas styles', () => {
const ctx = createContext()
const slot = new NodeInputSlot(
{
name: 'input',
label: 'Input label',
type: 'FLOAT',
link: null,
boundingRect: [110, 210, 10, 10]
},
createNode()
)
slot.draw(ctx, { colorContext: createColors(), highlight: true })
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 5, 0, Math.PI * 2)
expect(ctx.fillText).toHaveBeenCalledWith('Input label', 25, 20)
expect(ctx.fillStyle).toBe('#initial-fill')
expect(ctx.strokeStyle).toBe('#initial-stroke')
expect(ctx.lineWidth).toBe(7)
expect(ctx.textAlign).toBe('start')
})
it('draws output labels on the left and strokes output slots', () => {
const ctx = createContext()
const slot = new NodeOutputSlot(
{
name: 'output',
localized_name: 'Localized output',
type: 'FLOAT',
links: [toLinkId(1)],
boundingRect: [110, 210, 10, 10]
},
createNode()
)
slot.draw(ctx, { colorContext: createColors() })
expect(ctx.stroke).toHaveBeenCalled()
expect(ctx.fillText).toHaveBeenCalledWith('Localized output', 5, 20)
expect(ctx.textAlign).toBe('start')
expect(ctx.strokeStyle).toBe('#initial-stroke')
})
it('draws event, box, arrow, grid, and low-quality slot shapes', () => {
const colorContext = createColors()
const node = createNode()
const eventCtx = createContext()
const boxCtx = createContext()
const arrowCtx = createContext()
const gridCtx = createContext()
const lowQualityCtx = createContext()
new NodeInputSlot(
{
name: 'event',
type: SlotType.Event,
link: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(eventCtx, { colorContext })
new NodeInputSlot(
{
name: 'box',
type: 'FLOAT',
shape: RenderShape.BOX,
link: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(boxCtx, { colorContext })
new NodeOutputSlot(
{
name: 'arrow',
type: 'FLOAT',
shape: RenderShape.ARROW,
links: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(arrowCtx, { colorContext })
new NodeInputSlot(
{
name: 'grid',
type: SlotType.Array,
link: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(gridCtx, { colorContext })
new NodeInputSlot(
{
name: 'low',
type: 'FLOAT',
link: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(lowQualityCtx, { colorContext, lowQuality: true })
expect(eventCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
expect(boxCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
expect(arrowCtx.moveTo).toHaveBeenCalledWith(23, 15.5)
expect(gridCtx.rect).toHaveBeenCalledTimes(9)
expect(lowQualityCtx.rect).toHaveBeenCalledWith(11, 11, 8, 8)
expect(lowQualityCtx.fillText).not.toHaveBeenCalled()
})
it('draws hollow and multi-type slots', () => {
const colorContext = createColors()
const hollowCtx = createContext()
const multiCtx = createContext()
new NodeInputSlot(
{
name: 'hollow',
type: 'FLOAT',
shape: RenderShape.HollowCircle,
link: null,
boundingRect: [110, 210, 10, 10]
},
createNode()
).draw(hollowCtx, { colorContext, highlight: true })
new NodeInputSlot(
{
name: 'multi',
type: 'A,B,C,D,E',
link: toLinkId(1),
boundingRect: [110, 210, 10, 10]
},
createNode()
).draw(multiCtx, { colorContext })
expect(hollowCtx.clip).toHaveBeenCalledWith(expect.any(Object), 'evenodd')
expect(
vi
.mocked(colorContext.getConnectedColor)
.mock.calls.some(([type]) => type === 'A')
).toBe(true)
expect(multiCtx.fill.mock.calls.length).toBeGreaterThan(1)
expect(multiCtx.stroke).toHaveBeenCalled()
})
it('hides widget input labels and draws error rings', () => {
const ctx = createContext()
const slot = new NodeInputSlot(
{
name: 'widget-input',
label: 'Hidden label',
type: 'FLOAT',
link: null,
widget: { name: 'widget' },
hasErrors: true,
boundingRect: [110, 210, 10, 10]
},
createNode()
)
slot.draw(ctx, { colorContext: createColors() })
expect(ctx.fillText).not.toHaveBeenCalled()
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 12, 0, Math.PI * 2)
expect(ctx.stroke).toHaveBeenCalled()
})
it('places directional labels above vertical slots', () => {
const rightCtx = createContext()
const leftCtx = createContext()
const node = createNode()
const input = new NodeInputSlot(
{
name: 'up',
type: 'FLOAT',
link: null,
dir: LinkDirection.UP,
boundingRect: [110, 210, 10, 10]
},
node
)
const output = new NodeOutputSlot(
{
name: 'down',
type: 'FLOAT',
links: null,
dir: LinkDirection.DOWN,
boundingRect: [110, 210, 10, 10]
},
node
)
input.draw(rightCtx, { colorContext: createColors() })
output.draw(leftCtx, { colorContext: createColors() })
expect(rightCtx.fillText).toHaveBeenCalledWith('up', 15, 5)
expect(leftCtx.fillText).toHaveBeenCalledWith('down', 15, 7)
})
})
describe('collapsed rendering', () => {
it('draws collapsed input and output arrows in their own directions', () => {
const inputCtx = createContext()
const outputCtx = createContext()
new NodeInputSlot(
{
name: 'input',
type: 'FLOAT',
shape: RenderShape.ARROW,
link: null,
boundingRect
},
createNode()
).drawCollapsed(inputCtx)
new NodeOutputSlot(
{
name: 'output',
type: 'FLOAT',
shape: RenderShape.ARROW,
links: null,
boundingRect
},
createNode()
).drawCollapsed(outputCtx)
expect(inputCtx.moveTo).toHaveBeenCalledWith(8, -15)
expect(inputCtx.lineTo).toHaveBeenCalledWith(-4, -19)
expect(outputCtx.moveTo).toHaveBeenCalledWith(86, -15)
expect(outputCtx.lineTo).toHaveBeenCalledWith(74, -19)
})
it('draws collapsed event and circle slots', () => {
const eventCtx = createContext()
const circleCtx = createContext()
new NodeInputSlot(
{
name: 'event',
type: SlotType.Event,
link: null,
boundingRect
},
createNode()
).drawCollapsed(eventCtx)
new NodeInputSlot(
{
name: 'circle',
type: 'FLOAT',
link: null,
boundingRect
},
createNode()
).drawCollapsed(circleCtx)
expect(eventCtx.rect).toHaveBeenCalledWith(-6.5, -19, 14, 8)
expect(circleCtx.arc).toHaveBeenCalledWith(0, -15, 4, 0, Math.PI * 2)
expect(circleCtx.fillStyle).toBe('#initial-fill')
})
})
describe('serialization and validation', () => {
it('serializes slot fields without the node reference', () => {
const slot = new NodeOutputSlot(
{
name: 'out',
type: 'FLOAT',
label: 'Output',
color_on: '#fff',
color_off: '#000',
shape: RenderShape.BOX,
dir: LinkDirection.RIGHT,
localized_name: 'Localized',
pos: [1, 2],
links: [toLinkId(3)],
slot_index: 4,
boundingRect: [1, 2, 3, 4]
},
createNode()
)
expect(slot.toJSON()).toEqual({
name: 'out',
type: 'FLOAT',
label: 'Output',
color_on: '#fff',
color_off: '#000',
shape: RenderShape.BOX,
dir: LinkDirection.RIGHT,
localized_name: 'Localized',
pos: [1, 2],
boundingRect: [1, 2, 3, 4],
links: [toLinkId(3)],
slot_index: 4
})
})
it('validates input and output targets by slot direction', () => {
const input = new NodeInputSlot(
{
name: 'input',
type: 'FLOAT',
link: null,
boundingRect
},
createNode()
)
const output = new NodeOutputSlot(
{
name: 'output',
type: 'FLOAT',
links: null,
boundingRect
},
createNode()
)
expect(input.isValidTarget(output)).toBe(true)
expect(output.isValidTarget(input)).toBe(true)
expect(input.isValidTarget(input)).toBe(false)
expect(output.isValidTarget(output)).toBe(false)
})
})
})

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { toLinkId } from '@/types/linkId'
import { outputAsSerialisable } from './slotUtils'
@@ -34,18 +33,4 @@ describe('outputAsSerialisable', () => {
const serialised = outputAsSerialisable(output as OutputSlotParam)
expect(serialised.links).toBeNull()
})
it('serialises only the widget name for outputs with widgets', () => {
const node = new LGraphNode('test')
const output = node.addOutput('out', 'number') as OutputSlotParam
output.widget = node.addWidget(
'number',
'my-widget',
0,
null
) as INumericWidget
const serialised = outputAsSerialisable(output)
expect(serialised.widget).toEqual({ name: 'my-widget' })
})
})

Some files were not shown because too many files have changed in this diff Show More