Compare commits

..

14 Commits

Author SHA1 Message Date
huang47
ad3686631c fix(lint): also catch vi.mock(import('vue-i18n')) form 2026-07-02 22:51:28 -07:00
huang47
ffc92d3d42 fix(lint): exclude browser_tests from warn-level test rules to preserve error-level guards 2026-07-02 22:41:33 -07:00
huang47
1ab34ae4de chore(lint): warn on double assertions, fromAny, and vue-i18n mocks in unit tests 2026-07-02 22:33:30 -07:00
imick-io
156f2f59b7 feat(website): swap nav featured card to Comfy MCP (#13388)
## Summary

Repurpose the Products dropdown featured card to promote Comfy MCP.

## Changes

- **What**: Update the nav featured card title ("NEW: COMFY MCP"), alt
text, image asset (`mcp-card.webp`), and CTA ("GET STARTED") in
`mainNavigation.ts`; route the CTA to the localized `/mcp` page via
`routes.mcp`. All copy is i18n'd (en + zh-CN) in `translations.ts`,
adding a reusable `cta.getStarted` key.

## Review Focus

- CTA uses a new reusable `cta.getStarted` key rather than the
section-scoped `mcp.setup.label`, and routes to the internal
`routes.mcp` so non-en locales resolve to `/{locale}/mcp`.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:01:55 +00:00
nav-tej
d855466fdf fix(website): cap contact intro text width and space it from the form (#13420)
*PR Created by the Glary-Bot Agent*

---

## Summary

On `comfy.org/contact` the intro copy ("Create powerful workflows, scale
without limits." + description) ran right up against the HubSpot form
fields on desktop. The two `lg:w-1/2` columns had no gap between them
and the left column had no max-width, so long description text extended
almost to the form's edge.

- Add `lg:gap-16` between the two columns in `FormSection.vue`, matching
the pattern already used by `common/ContentSection.vue` and
`legal/LegalContentSection.vue`.
- Wrap the intro text block (badge + heading + description) in an
`lg:max-w-xl` container so the copy no longer stretches into the gap.
The illustration below keeps its full-column bleed via the existing
`lg:-ml-20`.
- Mobile (`<lg`) layout is unchanged — all new classes are
`lg:`-prefixed.

## Verification

Screenshots taken via Playwright against the local `apps/website` dev
server:

- Desktop (1512×900) — intro text now caps at a comfortable line length
with a clear gap to the form.
- 1024px — still works at the `lg:` breakpoint.
- 375px mobile — visually identical to before.

Also ran `pnpm format` and `pnpm --filter @comfyorg/website typecheck`
(0 errors). Three pre-existing
`better-tailwindcss/enforce-consistent-class-order` lint warnings on
this file exist on `main` and were left untouched.

- Fixes contact-page layout complaint from #website-and-docs (July 2)

## Screenshots

![Contact page after fix at 1512px — intro text capped with clear gap to
form](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049233475-b5932d36-9087-4689-a7ea-925bad2f09ff.png)

![Contact page after fix at 1024px
viewport](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049234208-8ef2cdac-b929-4c4d-b60a-e794f989fd76.png)

![Contact page after fix at 375px mobile viewport — layout
unchanged](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049234909-c502d978-2586-4ce7-b54a-d96fc759d306.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-07-03 03:51:31 +00:00
AustinMroz
9d5719871a Compact vue nodes (#12886)
Updates vue nodes to be compact. 

This PR does modify the sizing of the asset dropdown (as used on nodes
like "Load Image"). There are outstanding concerns about the visibility
of the upload button and ongoing work to address this.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/5c866d6f-d83e-40e1-9d87-17b990d94e04"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/2a809e90-13aa-4f95-8b73-3f20b02fd9a1"
/>|

Subsumes #12678

---------

Co-authored-by: Alex <alex@Alexs-MacBook-Pro.local>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-03 02:31:41 +00:00
ShihChi Huang
7610a61250 test: cover queue display formatting (#13089)
## Summary

Add direct tests for queue job display formatting.

Base: `main`

## Changes

- Covers state icons, pending/initializing labels, running progress,
completed local/cloud output, fallback completed titles, and failed
display.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:unit src/utils/queueDisplay.test.ts --run` | no direct
queue display test file |  13 passed |

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/queueDisplay.ts` 22.72% -> 79.54% (+56.82%); overall branches
52.95% -> 53.03% (+0.08%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only change; no runtime or production code modified.
> 
> **Overview**
> Adds **`src/utils/queueDisplay.test.ts`**, a Vitest suite that
exercises **`iconForJobState`** and **`buildJobDisplay`** from
`queueDisplay.ts` without touching UI or production logic.
> 
> Tests use small **`createJob` / `createTask` / `createCtx`** helpers
with a stub **`t`** and clock formatter so expectations assert i18n keys
and formatted values. Coverage includes pending “added to queue” hint,
queued/initializing labels, active vs inactive running progress,
completed local preview vs cloud duration, completed title fallback, and
failed rows with **`showClear`** behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6260c101e5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:39:24 +00:00
ShihChi Huang
47c8b09ebf test: 2/x cover fuse search ranking (#13087)
## Summary

Add direct tests for `fuseUtil` search ranking and filter behavior.


## Changes

- Covers ranking tiers, deprecated penalties, post-processing, empty
queries, auxiliary score comparison, and filter wildcard/comma matching.

## Test Results

- `pnpm test:unit src/utils/fuseUtil.test.ts`: 7 passed.
- `pnpm typecheck`: passed.
- `pnpm test:coverage`: 876 test files passed; 11,759 passed / 8
skipped.

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/fuseUtil.ts` 81.48% -> 92.59% (+11.11%); overall branches
52.93% -> 52.95% (+0.02%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a
summary for commit 8bf748d1a4. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-07-02 22:30:50 +00:00
ShihChi Huang
65b4c53bcb ci: skip website report deploy for fork PRs (#13344)
## Summary

Skip the website e2e report/deploy step for fork PRs, which lack the
deploy secrets and otherwise fail the job.

## Changes

- **What**: Guard the report/deploy step's `if:` in
`ci-website-e2e.yaml` so it runs only when the event is not a fork pull
request.
- **Breaking**: none. CI-config only.

## Review Focus

CI-config only — no test or coverage change. Confirms fork PRs no longer
fail on the deploy step.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> CI workflow condition only; no application or test logic changes.
> 
> **Overview**
> **Website E2E CI** no longer runs the **Deploy report to Cloudflare**
step on pull requests from forks.
> 
> The step’s `if:` still requires `always()` and `!cancelled()`, and now
also requires either a non–pull-request event or a PR whose head repo is
**not** a fork. Playwright tests and artifact upload are unchanged; only
the wrangler deploy (which needs `CLOUDFLARE_*` secrets) is skipped for
fork PRs so those runs don’t fail when secrets aren’t available.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
02a4ab0769. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:30:03 +00:00
ShihChi Huang
15b31d69ea ci: skip secret-backed CI deploys for fork PRs (#13291)
## Summary

Skip secret-backed CI deploy and dispatch work for fork PRs so missing
repo secrets do not fail otherwise valid checks.

## Changes

- **What**: Guard Website E2E report deploy, Vercel website preview
deploy, cloud build dispatch, cloud cleanup dispatch, and Storybook
Chromatic deploy so PR paths only run for same-repo PRs.
- **Dependencies**: None

## Why

Fork `pull_request` runs do not receive repository secrets. Several CI
jobs already separated normal validation from privileged follow-up work,
but some deploy or dispatch steps could still run on fork PRs and fail
only because their secret-backed integration token was empty.

The existing Website E2E fork guard only protected the PR comment job.
It did not protect the earlier Cloudflare report deploy step inside
`website-e2e`, which uses `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`.

The same failure mode existed in these CI jobs:

- `ci-vercel-website-preview.yaml`: preview deploy uses Vercel and
website API secrets.
- `cloud-dispatch-build.yaml`: preview dispatch uses
`CLOUD_DISPATCH_TOKEN` to call `Comfy-Org/cloud`.
- `cloud-dispatch-cleanup.yaml`: preview cleanup dispatch uses
`CLOUD_DISPATCH_TOKEN`.
- `ci-tests-storybook.yaml`: Chromatic deploy uses
`CHROMATIC_PROJECT_TOKEN`.

`ci-website-build.yaml` was left unchanged. Its Ashby and Cloud nodes
integrations intentionally fall back to committed snapshots when secrets
are missing for preview/local builds, so it is not the same class of
fork-secret failure.

## Review Focus

Confirm fork PRs still run the unprivileged validation/build paths,
while same-repo PRs and non-PR events keep the existing deploy or
dispatch behavior.

## Validation PRs

Both validation PRs compare against `main`.

- Fork PR from `shihchi`:
[#13309](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13309)
- Same-repo PR from `origin`:
[#13310](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13310)

| Workflow | Guarded job or step | Fork #13309 | Same-repo #13310 |
| --- | --- | --- | --- |
| CI: Website E2E | `Upload test report` | success  | success  |
| CI: Website E2E | `Deploy report to Cloudflare` | skipped  | success
 |
| CI: Vercel Website Preview | `deploy-preview` | skipped  | success 
|
| Cloud Frontend Build Dispatch | `dispatch` | skipped  | success  |
| CI: Tests Storybook | `chromatic-deployment` | skipped  | success  |

Expected result: fork PRs still keep the useful validation artifact
path, but skip secret-backed deploy and dispatch work. Same-repo PRs
keep the privileged behavior.

## Screenshots (if applicable)

N/A, CI-only.

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Workflow `if` condition changes only; no application code. Same-repo
PR behavior is unchanged when secrets are available.
> 
> **Overview**
> Adds **`github.event.pull_request.head.repo.fork == false`** guards so
fork PRs no longer run steps that need repo secrets or trigger external
deploys.
> 
> **Website E2E** — the Cloudflare Playwright report deploy step now
runs only on non-PR events or same-repo PRs, so fork runs can still pass
tests and upload artifacts without failing on missing `CLOUDFLARE_*`
secrets.
> 
> **Vercel website preview** — the preview deploy job is skipped
entirely for fork PRs (Vercel tokens).
> 
> **Storybook Chromatic** — Chromatic deployment on `version-bump-*` PRs
is limited to non-fork PRs (`CHROMATIC_PROJECT_TOKEN`).
> 
> **Cloud dispatch** — build and cleanup dispatches to the cloud repo
for preview labels no longer run for fork PRs, aligning with the
existing fork-guard comment in those workflows.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
027aabc9e3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:29:47 +00:00
Benjamin Lu
471236e08d feat: track subscription cancellation intent and resubscribe clicks (#13368)
## Summary

Instruments the churn funnel: cancellation intent, attempt, abandonment,
and request failure, plus resubscribe clicks — all client-observed from
existing request/response flows, no watchers or polling added. Covers
both billing paths: the mainline (`/customers/*` + Stripe portal) path
via the "Manage subscription" click, and the workspace path via its
in-app cancel dialog.

## Changes

- **What**:
- New events: `app:subscription_cancel_flow_opened` / `_confirmed` /
`_abandoned` / `_failed` and `app:resubscribe_button_clicked`, via
`trackSubscriptionCancellation(stage, metadata)` and
`trackResubscribeClicked` (registry, PostHog, host sink)
  - All cancellation events carry a `source` discriminator:
- `manage_subscription_button` — the mainline path. Legacy users can
only cancel inside the Stripe billing portal, and in-app UI already
covers plan changes, so this click is the closest observable
cancel-intent signal for ~all production users. Only `flow_opened` fires
here (everything past the click happens in Stripe's UI). Probable, not
certain, intent — the portal also serves card updates/invoices.
- `cancel_plan_menu` — the workspace in-app dialog (allowlist-gated
pilot): `flow_opened` on mount, `confirmed` before the API call (failed
attempts still register), `failed` with the error message, `abandoned`
on "Keep subscription"/close. Successful cancels close via a different
path and never emit `abandoned`.
- Metadata carries `current_tier`, billing `cycle`, and (dialog path)
the `end_date` shown to the user
- Resubscribe clicks tracked at both call sites with `source`:
`pricing_dialog` (`useSubscriptionCheckout`, also carrying the dialog's
`payment_intent_source` from #13363) and `settings_billing_panel`
(`useResubscribe`)
- Not instrumented on purpose: the workspace "Manage billing" button and
the "Invoice history" footer link (portal opens without cancel
connotation)

## Review Focus

- Deliberately **no** client-side "cancel succeeded" event: outcome
truth is server-side. Mainline already has it
(`billing:subscription_deleted` from the Stripe webhook in comfy-api);
the workspace path needs a `subscription_cancelled` billing event type
(separate cloud-repo change). The legacy
`useSubscriptionCancellationWatcher` poller emits an undercounted
`app:monthly_subscription_cancelled`; analysis should prefer the server
event.
- `confirmed` fires before the request; growth can join
`flow_opened`/`confirmed` → server-side cancelled events by user +
timestamp.
2026-07-02 14:51:12 -07:00
Mobeen Abdullah
4cc0402325 revert(website): remove Creative Campus customer stories (#13370) (#13407)
## Summary

Reverts #13370 (the five Creative Campus customer stories) from `main`.
These are education-tied stories, and the "Education Program is live"
CTA links to the education page, which is not live yet, so they should
not be public before the education launch.

This is a clean `git revert` of the squash commit `49a90d4e2` (no
history rewrite, no force-push). No work is lost: the story branch
(`feat/website-customer-stories-education`) is intact, and the stories
will relaunch together with pricing and the education page via #13406.

## Changes

- **What**: Reverts the 5 new story MDX files, the new article block
components, and the related changes to `CustomerArticle.astro`,
`global.css`, `Figure`/`Quote`/`Contributors`, the content test, and the
e2e spec. The existing five stories and the customers pages are
unaffected.
- **Breaking**: none.

## Review Focus

- Pure inverse of #13370; the diff is `-858/+11` mirroring the original
merge.
- Files touched by #13370 are disjoint from the education-page work in
#13406, so this does not conflict with that branch.

## Verification

- Build: 497 pages (down 5 en story pages). Unit: 156/156. Typecheck: 0
errors. format:check and knip clean.

## Next steps

- Stories move into the education bundle (#13406) via a separate PR.
- When the education page and its auth (FE-1174) are ready, pricing +
customer stories + education launch together.
2026-07-03 01:49:47 +05:00
Wei Hai
a2adfe5124 fix(ci): drop unsupported 'range' genhtml ignore-errors category (#13396)
## Summary
- `CI: E2E Coverage`'s `Generate HTML coverage report` step fails on
every run with `genhtml: ERROR: unknown argument for --ignore-errors:
'range'`
- The runner's `apt-get install lcov` resolves to lcov 2.0-4ubuntu2
(Ubuntu 24.04/noble), but the `range` ignore-errors category was only
added in lcov 2.1
- lcov 2.0 already reports the out-of-range-line condition under the
`source` category, which is already in the ignore list, so `range` was
both unsupported and redundant on this runner

## Test plan
- [x] Confirmed lcov 2.0-4ubuntu2 is what `apt-get install lcov`
resolves to on `ubuntu-latest`
- [x] Confirmed via lcov's `lcovutil.pm` source that `range`
(`$ERROR_RANGE`) is only registered as of v2.1, and in v2.0 the
equivalent out-of-range case falls under `$ERROR_SOURCE`
- [ ] CI: E2E Coverage run on this branch's merge should pass the
"Generate HTML coverage report" step
2026-07-02 20:08:47 +00:00
Mobeen Abdullah
49a90d4e2e feat(website): add five Creative Campus customer stories (#13370)
## Summary

Add the five new Comfy Education Initiative (Creative Campus) customer
stories to `/customers`, each with its own detail page, reusing the
existing Astro content-collection pattern. Brings the listing to ten
stories. Linear: FE-1161.

## Changes

- **What**: Five new English MDX stories (Xindi Zhang, Ina Conradi,
Golan Levin, Kathy Smith, and the UAL CCI partnership) added to the
customers collection, ordered after the existing five. Adds a small set
of reusable article blocks these stories need: `Embed` (Vimeo), `Video`
(wraps the existing `VideoPlayer`), `Download` (workflow JSON),
`AuthorBio`, `EducationCta`, `AtAGlance`, a styled inline `Link`, and
`Heading4`. `Quote`'s `name` is now optional for unattributed
pull-quotes; `Figure` gained an optional rich-caption slot (for captions
that contain links); `AuthorBio` supports a single-author bio via slot.
- **Breaking**: none. All additions are backward compatible; the
existing five stories and their pages are untouched.
- **Dependencies**: none.

## Review Focus

- The logic to review is small and isolated: the new block components in
`components/customers/content/` and their registration in
`CustomerArticle.astro`. The rest of the diff is MDX content.
- **Story copy is transcribed verbatim from the source docs**;
punctuation (em/en dashes, curly quotes) is preserved as written and is
intentional, not a formatting slip.
- **Downloads (cross-origin):** the workflow JSON files are on
media.comfy.org, so the HTML `download` attribute is ignored by
browsers. The real download is forced server-side with
`Content-Disposition: attachment` on the storage objects. Xindi's two
workflow files are served from a cache-fresh `.../workflows/` path (with
an explicit `filename=`) so the CDN serves the attachment header
immediately.
- **Embed hardening:** the Vimeo `Embed` iframe carries
`referrerpolicy="strict-origin-when-cross-origin"` and a scoped
`sandbox` (`allow-scripts allow-same-origin allow-presentation
allow-popups`); the player was verified to still load and play.
- All media (card covers, inline images, one video with a poster frame,
workflow JSON/PNG downloads) is hosted on media.comfy.org. No local
assets are committed. Golan's workflow files are re-hosted there; his
lesson-plan and demo-project links intentionally stay on GitHub/p5.js as
view-only.
- English-first: Chinese versions will be added later through a separate
translation service. The listing and detail pages already handle a
locale that only has English entries, so no page-code changes were
needed.
- Tags: "Creative Campus Showcase" for the four teaching stories, and
"Creative Campus Partnership" for the UAL announcement.

## Verification

- Unit `176/176`, typecheck (astro check) `0 errors`, build `502 pages`,
`format:check`, `knip`, and `eslint` all pass.
- e2e customer specs `6/6` pass (includes a new test asserting the
Creative Campus education blocks render).
- Visual pass on all ten stories at desktop (1440) and mobile (390): no
horizontal overflow, the Vimeo player plays, and all downloads resolve
to media.comfy.org.

## Screenshots (if applicable)

Easiest way to review is the Vercel preview:
https://comfy-website-preview-pr-13370.vercel.app/customers then open
the five new stories. Verified on desktop (1440) and mobile (390).
2026-07-03 00:34:20 +05:00
372 changed files with 1930 additions and 59530 deletions

View File

@@ -121,7 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--ignore-errors source,unmapped \
--synthesize-missing
- name: Upload HTML report artifact

View File

@@ -95,6 +95,7 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -67,7 +67,15 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -32,12 +32,13 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,6 +21,7 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

View File

@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">

View File

@@ -33,36 +33,41 @@ useHeroAnimation({
</script>
<template>
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
<section
ref="sectionRef"
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
>
<!-- Left column: intro + image -->
<div class="lg:w-1/2">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<div class="lg:max-w-xl">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<h1
ref="headingRef"
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<div ref="descRef">
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('description'), locale) }}
</p>
<div ref="descRef">
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('description'), locale) }}
</p>
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
</div>
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">

View File

@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
label: t('nav.products', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
cta: {
label: t('cta.tryWorkflow', locale),
label: t('cta.getStarted', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
href: routes.mcp
}
},
columns: [

View File

@@ -26,6 +26,10 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.getStarted': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
@@ -2196,16 +2200,16 @@ const translations = {
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
'nav.featuredProductsTitle': {
en: 'New Release: Seedance 2.0',
'zh-CN': '全新发布:Seedance 2.0'
en: 'NEW: COMFY MCP',
'zh-CN': '全新发布:Comfy MCP'
},
'nav.featuredProductsAlt': {
en: 'Seedance 2.0 release feature image',
'zh-CN': 'Seedance 2.0 发布精选图片'
en: 'Comfy MCP feature image',
'zh-CN': 'Comfy MCP 精选图片'
},
'nav.featuredProductsCtaAria': {
en: 'Try the Seedance 2.0 workflow',
'zh-CN': '试用 Seedance 2.0 工作流'
en: 'Get started with Comfy MCP',
'zh-CN': '开始使用 Comfy MCP'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',

View File

@@ -537,7 +537,6 @@ export const comfyPageFixture = base.extend<{
'Comfy.TutorialCompleted': true,
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -46,6 +46,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -691,7 +691,8 @@ test(
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
await stepsSlot.hover()
const { width, height } = (await stepsSlot.boundingBox())!
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1238,7 +1238,7 @@ test(
{ tag: '@vue-nodes' },
async ({ comfyMouse, comfyPage }) => {
async function performDisconnect(slot: Locator, isFast: boolean) {
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
if (!isFast) {
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
@@ -1251,7 +1251,7 @@ test(
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
const ksampler = new VueNodeFixture(ksamplerLocator)
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
await test.step('Disconnection with normal links', async () => {
await performDisconnect(ksampler.getSlot('model'), true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -234,7 +234,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')

View File

@@ -14,7 +14,8 @@ const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const getHeaderPos = async (
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number; width: number; height: number }> => {
): Promise<{ x: number; y: number }> => {
const box = await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
.first()
.boundingBox()
if (!box) throw new Error(`${title} header not found`)
return box
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
@@ -84,29 +84,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
test('should allow moving nodes by dragging', async ({
comfyPage,
comfyMouse
}) => {
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expectPosChanged(initialHeaderPos, newHeaderPos)
})
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
comfyPage,
comfyMouse
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
@@ -295,14 +293,12 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
// Re-fetch drag source after clicks in case the header reflowed.
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
const centerX = dragSrc.x + dragSrc.width / 2
const centerY = dragSrc.y + dragSrc.height / 2
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
steps: 20
})
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -42,7 +42,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await expect(pinIndicator2).toBeHidden()
})
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
test('should not allow dragging pinned nodes', async ({
comfyMouse,
comfyPage
}) => {
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
@@ -50,10 +53,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
// Try to drag the node
const headerPos = await checkpointNodeHeader.boundingBox()
if (!headerPos) throw new Error('Failed to get header position')
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Verify the node is not dragged (same position before and after click-and-drag)
await expect
@@ -64,11 +64,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
// Try to drag the node again
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Verify the node is dragged
await expect

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -5,12 +5,7 @@ import {
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
// Add a PreviewAny node which has a read-only textarea with a copy button
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')
window.app!.graph.add(node)
})
await comfyPage.searchBoxV2.addNode('Preview as Text')
await comfyPage.vueNodes.waitForNodes()
})

View File

@@ -336,6 +336,38 @@ export default defineConfig([
'testing-library/no-debugging-utils': 'error'
}
},
{
files: ['**/*.test.ts'],
// browser_tests is excluded so this warn-level entry does not override the
// error-level no-restricted-syntax guards defined above for those paths
ignores: ['browser_tests/**'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector: 'TSAsExpression > TSAsExpression.expression',
message:
'Double type assertion. Use fromPartial<T>() from @total-typescript/shoehorn instead.'
},
{
selector: "ImportSpecifier[imported.name='fromAny']",
message: 'fromAny erases type checking. Use fromPartial<T>() instead.'
},
{
selector:
"CallExpression[callee.object.name='vi'][callee.property.name='mock'] > Literal[value='vue-i18n']",
message:
'Do not mock vue-i18n. Use a real createI18n instance (see src/components/searchbox/v2/__test__/testUtils.ts).'
},
{
selector:
"CallExpression[callee.object.name='vi'][callee.property.name='mock'] > ImportExpression > Literal[value='vue-i18n']",
message:
'Do not mock vue-i18n. Use a real createI18n instance (see src/components/searchbox/v2/__test__/testUtils.ts).'
}
]
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {

View File

@@ -197,7 +197,7 @@
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-ash-800);
--node-component-header-surface: var(--color-smoke-400);
--node-component-header-surface: var(--color-smoke-200);
--node-component-outline: var(--color-black);
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
--node-component-slot-dot-outline-opacity-mult: 1;
@@ -343,7 +343,7 @@
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-smoke-800);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-header-surface: var(--color-charcoal-700);
--node-component-outline: var(--color-white);
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
--node-component-slot-dot-outline-opacity: 10%;
@@ -727,14 +727,14 @@ body {
/* Shared markdown content styling for consistent rendering across components */
.comfy-markdown-content {
/* Typography */
font-size: 0.875rem; /* text-sm */
font-size: var(--comfy-textarea-font-size);
line-height: 1.6;
word-wrap: break-word;
}
/* Headings */
.comfy-markdown-content h1 {
font-size: 22px; /* text-[22px] */
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
@@ -745,7 +745,7 @@ body {
}
.comfy-markdown-content h2 {
font-size: 18px; /* text-[18px] */
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
@@ -756,7 +756,7 @@ body {
}
.comfy-markdown-content h3 {
font-size: 16px; /* text-[16px] */
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */

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

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

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

@@ -22,7 +22,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { promptRenameWidget } from '@/utils/widgetUtil'
@@ -50,6 +50,7 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
)
provide(HideLayoutFieldKey, true)
provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
const resolvedInputs = useResolvedSelectedInputs()
@@ -236,7 +237,7 @@ defineExpose({ handleDragDrop })
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
)
"

View File

@@ -1,20 +1,26 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
:class="
cn(
'flex overflow-hidden rounded-md bg-component-node-widget-background text-xs text-component-node-foreground',
useWidgetHeight()
)
"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
variant="muted-textonly"
size="unset"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
>
<i class="pi pi-minus" />
<i class="icon-[lucide--minus]" />
</Button>
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
<input
@@ -24,7 +30,7 @@
:disabled
:class="
cn(
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
'absolute inset-0 truncate border-0 bg-transparent p-1 text-xs focus:outline-0'
)
"
inputmode="decimal"
@@ -54,13 +60,14 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
variant="muted-textonly"
size="unset"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
>
<i class="pi pi-plus" />
<i class="icon-[lucide--plus]" />
</Button>
</div>
</template>
@@ -71,6 +78,7 @@ import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWidgetHeight } from '@/types/widgetTypes'
import { cn } from '@comfyorg/tailwind-utils'
const {

View File

@@ -42,22 +42,34 @@ function withStrictMillisecondParser<T>(run: () => T): T {
}
const mockSubscription = vi.hoisted(() => ({
value: null as { endDate: string | null } | null
value: null as {
endDate: string | null
duration?: 'ANNUAL' | 'MONTHLY' | null
} | null
}))
const mockCancelSubscription = vi.hoisted(() => vi.fn())
const mockFetchStatus = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockToastAdd = vi.hoisted(() => vi.fn())
const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null }))
const mockTrackCancellation = vi.hoisted(() => vi.fn())
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
cancelSubscription: mockCancelSubscription,
fetchStatus: mockFetchStatus,
subscription: mockSubscription
subscription: mockSubscription,
tier: mockTier
}))
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackSubscriptionCancellation: mockTrackCancellation
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: mockCloseDialog
@@ -94,6 +106,95 @@ function renderComponent(props: { cancelAt?: string } = {}) {
describe('CancelSubscriptionDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTier.value = 'STANDARD'
})
describe('cancellation telemetry', () => {
it('tracks flow_opened with tier and end date when the dialog mounts', () => {
mockSubscription.value = { endDate: '2026-08-01T00:00:00.000Z' }
renderComponent()
expect(mockTrackCancellation).toHaveBeenCalledWith('flow_opened', {
source: 'cancel_plan_menu',
current_tier: 'standard',
end_date: '2026-08-01T00:00:00.000Z'
})
})
it('tracks confirmed before the cancel request and no abandoned on success', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() => expect(mockCloseDialog).toHaveBeenCalled())
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'confirmed',
expect.objectContaining({ current_tier: 'standard' })
)
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
'abandoned',
expect.anything()
)
})
it('tracks confirmed and failed with message-carrying rejection values', async () => {
mockSubscription.value = null
mockCancelSubscription.mockRejectedValueOnce({ message: 'timed out' })
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockTrackCancellation).toHaveBeenCalledWith(
'failed',
expect.objectContaining({ error_message: 'timed out' })
)
)
expect(mockTrackCancellation).toHaveBeenCalledWith(
'confirmed',
expect.anything()
)
})
it('tracks abandoned when the user keeps the subscription', async () => {
mockSubscription.value = null
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /keep subscription/i })
)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'abandoned',
expect.objectContaining({ current_tier: 'standard' })
)
expect(mockCancelSubscription).not.toHaveBeenCalled()
})
it('tracks abandoned when the dialog is dismissed by the shell', () => {
mockSubscription.value = null
const { unmount } = renderComponent()
mockTrackCancellation.mockClear()
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'abandoned',
expect.objectContaining({ current_tier: 'standard' })
)
})
})
describe('cancel flow', () => {
@@ -138,6 +239,35 @@ describe('CancelSubscriptionDialogContent', () => {
expect.objectContaining({ severity: 'success' })
)
})
it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
mockFetchStatus.mockRejectedValueOnce(new Error('Refresh failed'))
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
expect(
mockTrackCancellation.mock.calls.some(([stage]) => stage === 'failed')
).toBe(false)
unmount()
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
'abandoned',
expect.anything()
)
})
})
describe('formattedEndDate fallbacks', () => {

View File

@@ -45,13 +45,16 @@
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types'
import { useDialogStore } from '@/stores/dialogStore'
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
import { getErrorMessage } from '@/utils/errorUtil'
const props = defineProps<{
cancelAt?: string
@@ -60,9 +63,41 @@ const props = defineProps<{
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const { cancelSubscription, fetchStatus, subscription, tier } =
useBillingContext()
const telemetry = useTelemetry()
const isLoading = ref(false)
const didCancelSucceed = ref(false)
function cancellationMetadata(): SubscriptionCancellationMetadata {
const endDate = props.cancelAt ?? subscription.value?.endDate
return {
source: 'cancel_plan_menu' as const,
current_tier: tier.value?.toLowerCase(),
...(subscription.value?.duration
? {
cycle:
subscription.value.duration === 'ANNUAL'
? ('yearly' as const)
: ('monthly' as const)
}
: {}),
...(endDate ? { end_date: endDate } : {})
}
}
onMounted(() => {
telemetry?.trackSubscriptionCancellation(
'flow_opened',
cancellationMetadata()
)
})
onUnmounted(() => {
if (didCancelSucceed.value || isLoading.value) return
telemetry?.trackSubscriptionCancellation('abandoned', cancellationMetadata())
})
const formattedEndDate = computed(() => {
const date = parseIsoDateSafe(props.cancelAt ?? subscription.value?.endDate)
@@ -84,24 +119,37 @@ function onClose() {
}
async function onConfirmCancel() {
telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
isLoading.value = true
try {
await cancelSubscription()
await fetchStatus()
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
} catch (error) {
const errorMessage = getErrorMessage(error)
telemetry?.trackSubscriptionCancellation('failed', {
...cancellationMetadata(),
error_message: errorMessage ?? String(error)
})
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: errorMessage ?? t('g.unknownError')
})
} finally {
isLoading.value = false
return
}
didCancelSucceed.value = true
try {
await fetchStatus()
} catch {
// Cancellation already succeeded; stale local subscription status should not report failure.
}
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
isLoading.value = false
}
</script>

View File

@@ -31,7 +31,7 @@ import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
@@ -135,6 +135,7 @@ watchDebounced(
onBeforeUnmount(() => draggableList.value?.dispose())
provide(HideLayoutFieldKey, true)
provide(WidgetHeightKey, 'h-7')
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()

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,217 +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
}))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: true
}))
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
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('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', () => {
const { result, root } = mountTerminal()
const fitAddon = mockXterm.fitAddonInstances[0]
fitAddon.proposeDimensions.mockReturnValue({
cols: Number.NaN,
rows: undefined
})
const { resize } = result.useAutoSize({ root, minCols: 30, minRows: 10 })
const terminal = mockXterm.terminalInstances[0]
resize()
expect(terminal.resize).toHaveBeenLastCalledWith(30, 10)
})
it('keeps existing terminal dimensions when auto sizing is disabled', () => {
const { result, root } = mountTerminal()
const terminal = mockXterm.terminalInstances[0]
terminal.cols = 90
terminal.rows = 30
const { resize } = result.useAutoSize({
root,
autoCols: false,
autoRows: false,
minCols: 10,
minRows: 10
})
resize()
expect(terminal.resize).toHaveBeenLastCalledWith(90, 30)
})
})

View File

@@ -5,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'
@@ -36,26 +35,11 @@ const ctx = {
lineWidth: 0
} 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
})
el.getContext = (() =>
options.context === undefined
? ctx
: options.context) as unknown 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,
@@ -112,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,
@@ -175,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))
@@ -237,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', () => {
@@ -360,60 +214,6 @@ 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({
key: 'Backspace',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('ignores unrelated keys and key events while drawing', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Enter',
preventDefault: () => {
throw new Error('should not prevent')
},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
c.onPointerDown(pe(80, 80))
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {
throw new Error('should not prevent while drawing')
},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
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({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.activeRegion.value).not.toBeNull()
})
})
describe('useBoundingBoxes inline editor', () => {
@@ -437,86 +237,6 @@ describe('useBoundingBoxes inline editor', () => {
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 unknown 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 unknown 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 unknown 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 unknown 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 unknown 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 unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
@@ -527,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,118 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
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', fromAny<LGraphNode, unknown>(node)]])
)
expect(getNodeByExecutionId).not.toHaveBeenCalled()
expect(canvasStore.canvas.setGraph).not.toHaveBeenCalled()
expect(canvasStore.canvas.animateToBounds).toHaveBeenCalledWith(bounds)
})
})

View File

@@ -1,6 +1,6 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h, markRaw, nextTick, ref } from 'vue'
import { defineComponent, h, markRaw, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
@@ -12,35 +12,19 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { toNodeId } from '@/types/nodeId'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
const mockApp = vi.hoisted(() => ({
canvas: null
}))
const mockFeatureFlags = vi.hoisted(() => ({
refs: null as null | {
shouldRenderVueNodes: { value: boolean }
}
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/composables/useVueFeatureFlags', async () => {
const { ref } = await import('vue')
const shouldRenderVueNodes = ref(false)
mockFeatureFlags.refs = {
shouldRenderVueNodes
}
return {
useVueFeatureFlags: () => ({
shouldRenderVueNodes
})
}
})
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: { value: false }
})
}))
describe('useSelectionToolboxPosition', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
@@ -48,39 +32,28 @@ describe('useSelectionToolboxPosition', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
layoutStore.initializeFromLiteGraph([])
layoutStore.isDraggingVueNodes.value = false
if (mockFeatureFlags.refs) {
mockFeatureFlags.refs.shouldRenderVueNodes.value = false
}
})
function renderToolboxForSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {},
ds: Partial<LGraphCanvas['ds']> = {}
) {
function renderToolboxForSelection(item: Positionable) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: ds.offset ?? [0, 0],
scale: ds.scale ?? 1
offset: [0, 0],
scale: 1
},
selectedItems: new Set(items),
selectedItems: new Set([item]),
state: {
draggingItems: false,
selectionChanged: true,
...state
selectionChanged: true
}
} as Partial<LGraphCanvas> as LGraphCanvas)
let toolbox: HTMLElement | undefined
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
const TestHarness = defineComponent({
setup() {
const toolboxRef = ref<HTMLElement>(document.createElement('div'))
toolbox = toolboxRef.value
;({ visible } = useSelectionToolboxPosition(toolboxRef))
useSelectionToolboxPosition(toolboxRef)
return () => h('div')
}
})
@@ -88,28 +61,7 @@ describe('useSelectionToolboxPosition', () => {
const wrapper = render(TestHarness)
if (!toolbox) throw new Error('Toolbox element was not initialized')
if (!visible) throw new Error('Visible state was not initialized')
return { toolbox, unmount: wrapper.unmount, visible }
}
function setCanvasSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {}
) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: [0, 0],
scale: 1
},
selectedItems: new Set(items),
state: {
draggingItems: false,
selectionChanged: true,
...state
}
} as Partial<LGraphCanvas> as LGraphCanvas)
return { toolbox, unmount: wrapper.unmount }
}
it('positions groups from their unchanged bounds', () => {
@@ -117,7 +69,7 @@ describe('useSelectionToolboxPosition', () => {
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group])
const { toolbox, unmount } = renderToolboxForSelection(group)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
@@ -129,221 +81,11 @@ describe('useSelectionToolboxPosition', () => {
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([node])
const { toolbox, unmount } = renderToolboxForSelection(node)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('does not set coordinates when selection is empty', () => {
const { toolbox, unmount } = renderToolboxForSelection([])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not update when selection state is unchanged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, visible, unmount } = renderToolboxForSelection([group], {
selectionChanged: false
})
expect(visible.value).toBe(false)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not set coordinates while selected items are being dragged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group], {
draggingItems: true
})
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('positions multiple selected items from their union bounds', () => {
const first = new LGraphGroup('First', 1)
first.pos = [100, 200]
first.size = [100, 40]
const second = new LGraphGroup('Second', 2)
second.pos = [300, 260]
second.size = [50, 40]
const { toolbox, unmount } = renderToolboxForSelection([first, second])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
})
it('applies canvas scale and offset to screen coordinates', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [100, 40]
const { toolbox, unmount } = renderToolboxForSelection(
[group],
{},
{ offset: [10, 20], scale: 2 }
)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
unmount()
})
it('uses Vue layout bounds when Vue node rendering is enabled', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const node = new LGraphNode('Node')
node.id = toNodeId(12)
node.pos = [100, 200]
node.size = [160, 80]
layoutStore.initializeFromLiteGraph([
{
id: node.id,
pos: [300, 400],
size: [200, 120]
}
])
const { toolbox, unmount } = renderToolboxForSelection([node])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('400px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${390 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('falls back to LiteGraph node bounds when Vue layout is missing', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const node = new LGraphNode('Node')
node.id = toNodeId(13)
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([node])
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('hides the toolbox while Vue nodes are being dragged', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
layoutStore.isDraggingVueNodes.value = true
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('ignores selected items that are not nodes or groups', () => {
const item = createMockPositionable({
id: toNodeId(52),
pos: [100, 200],
size: [160, 80],
boundingRect: [100, 200, 160, 80]
})
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
expect(visible.value).toBe(true)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('ignores selected items without valid ids', () => {
const item = {
id: null,
pos: [100, 200],
size: [160, 80],
boundingRect: [100, 200, 160, 80]
} as unknown as Positionable
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
expect(visible.value).toBe(true)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('stays visible without mutating style when the toolbox ref is empty', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
setCanvasSelection([group])
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
const TestHarness = defineComponent({
setup() {
;({ visible } = useSelectionToolboxPosition(ref()))
return () => h('div')
}
})
const wrapper = render(TestHarness)
expect(visible.value).toBe(true)
wrapper.unmount()
})
it('hides and restores around Vue node drag state changes', async () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0)
)
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
clearTimeout(handle)
})
const { visible, unmount } = renderToolboxForSelection([group])
expect(visible.value).toBe(true)
layoutStore.isDraggingVueNodes.value = true
await nextTick()
expect(visible.value).toBe(false)
layoutStore.isDraggingVueNodes.value = false
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(visible.value).toBe(true)
unmount()
})
})

View File

@@ -1,100 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
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'
})
})
})

View File

@@ -1,86 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => {
const canvas = {
canvas: {},
ds: {
offset: [10, 20],
scale: 2
}
} as unknown 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

@@ -20,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'
@@ -131,39 +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'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 12, true, null, fromAny(null))
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears errors for pure input slots without widget property', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
@@ -286,36 +252,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
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)
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('clears missing media when an upload emits onWidgetChanged', () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
@@ -455,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)
@@ -916,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', () => {
@@ -1153,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', () => {
@@ -1312,167 +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')
node.type = fromAny<LGraphNode['type'], unknown>(undefined)
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()

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 { fromAny } 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,535 +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)
input.link = fromAny(123)
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(
fromAny<IBaseWidget, unknown>({
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)
node.addCustomWidget(
fromAny<IBaseWidget, 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'
})
)
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 badWidget = fromAny<IBaseWidget, unknown>({
name: 'broken',
type: 'custom',
value: 'x',
get options() {
throw new Error('bad options')
}
})
node.widgets = [badWidget]
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 badWidget = fromAny<IBaseWidget, unknown>({
value: 'x',
get options() {
throw new Error('bad options')
}
})
node.widgets = [badWidget]
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 widget = {
linkedWidgets: [linkedControl]
} as unknown as IBaseWidget
const control = getControlWidget(widget)
expect(control?.value).toBe('fixed')
control?.update('unexpected')
expect(linkedControl.value).toBe('randomize')
})
})

View File

@@ -1,215 +0,0 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
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
})
}))
function group(over: Record<string, unknown> = {}): LGraphGroup {
return {
recomputeInsideNodes: vi.fn(),
resizeTo: vi.fn(),
children: [],
graph: { change: vi.fn() },
nodes: [],
...over
} as unknown as LGraphGroup
}
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()
useGroupMenuOptions().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')
})
})
useGroupMenuOptions().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 = useGroupMenuOptions().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()
useGroupMenuOptions()
.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()
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).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
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#eee')
expect(bump).toHaveBeenCalled()
})
it('returns no mode options for an empty group', () => {
expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual(
[]
)
})
it('returns no mode options when a group has no nodes array', () => {
expect(
useGroupMenuOptions().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 = useGroupMenuOptions().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 = useGroupMenuOptions().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 = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers two alternate modes when all nodes are BYPASS', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers all three modes when nodes have mixed modes', () => {
const options = useGroupMenuOptions().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 = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: 999 }] }),
vi.fn()
)
expect(options).toHaveLength(3)
})
})

View File

@@ -1,7 +1,6 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
@@ -20,11 +19,6 @@ 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,
@@ -33,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 {
@@ -60,13 +45,8 @@ function createImageNode(
}
describe('useImageMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('getImageMenuOptions', () => {
@@ -95,12 +75,6 @@ describe('useImageMenuOptions', () => {
expect(getImageMenuOptions(node)).toEqual([])
})
it('returns empty array when node image capabilities are absent', () => {
const { getImageMenuOptions } = useImageMenuOptions()
expect(getImageMenuOptions(fromPartial<LGraphNode>({}))).toEqual([])
})
it('returns only Paste Image when node has no images but supports paste', () => {
const node = createMockLGraphNode({
imgs: [],
@@ -208,225 +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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
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 } = useImageMenuOptions()
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(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(null)
}
)
const { getImageMenuOptions } = useImageMenuOptions()
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(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
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(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
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,175 +0,0 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
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 })
}))
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 } = useNodeCustomization()
expect(colorOptions.length).toBeGreaterThan(1)
expect(shapeOptions.length).toBeGreaterThan(0)
})
it('reflects the active palette light-theme flag', () => {
palette.light_theme = true
expect(useNodeCustomization().isLightTheme.value).toBe(true)
})
it('clears color on all colorable items for the no-color option', () => {
const item = colorable()
selection.items = [item]
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 } = 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]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('returns null current color for an empty selection', () => {
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('returns null current color when no selected item is colorable', () => {
selection.items = [{}]
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('reports a recognized current color', () => {
const { colorOptions, getCurrentColor } = 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 = useNodeCustomization().getCurrentColor()
expect(result?.name).toBe('noColor')
})
it('no-ops shape changes when no graph nodes are selected', () => {
selection.items = [colorable()]
const { applyShape, shapeOptions } = useNodeCustomization()
applyShape(shapeOptions[0])
expect(refreshCanvas).not.toHaveBeenCalled()
})
it('returns null current shape with no nodes selected', () => {
expect(useNodeCustomization().getCurrentShape()).toBeNull()
})
it('applies a shape to selected graph nodes and refreshes', () => {
const node = new LGraphNode('Test')
selection.items = [node]
const { applyShape, shapeOptions } = 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 } = 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 } = 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 } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
})

View File

@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
const { actions, customization } = vi.hoisted(() => ({
actions: {
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
},
customization: {
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
colorOptions: [] as Array<{
name: string
localizedName: string
value: { dark: string; light: string }
}>,
applyShape: vi.fn(),
applyColor: vi.fn(),
isLightTheme: { value: false }
}
}))
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => actions
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
}))
const i18n = createI18n({
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
function readNodeMenuOptions<T>(
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
): T {
const unread = Symbol('unread')
const result: { value: T | typeof unread } = { value: unread }
const Wrapper = defineComponent({
setup() {
result.value = read(useNodeMenuOptions())
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
if (result.value === unread) throw new Error('Composable was not read')
return result.value
}
describe('useNodeMenuOptions', () => {
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
])
).toBe('contextMenu.Bypass')
})
it('labels visual node options from the collapsed state and bumps after action', () => {
const expandBump = vi.fn()
const expand = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
)
expect(expand).toMatchObject({
label: 'contextMenu.Expand Node',
icon: 'icon-[lucide--maximize-2]'
})
expand.action?.()
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
expect(expandBump).toHaveBeenCalledTimes(1)
const minimize = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
)
expect(minimize).toMatchObject({
label: 'contextMenu.Minimize Node',
icon: 'icon-[lucide--minimize-2]'
})
})
it('labels pin options from the pinned state and bumps after action', () => {
const bump = vi.fn()
const unpin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: true }, bump)
)
expect(unpin).toMatchObject({
label: 'contextMenu.Unpin',
icon: 'icon-[lucide--pin-off]'
})
unpin.action?.()
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
expect(bump).toHaveBeenCalledTimes(1)
const pin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: false }, vi.fn())
)
expect(pin).toMatchObject({
label: 'contextMenu.Pin',
icon: 'icon-[lucide--pin]'
})
})
it('builds shape and color submenus and applies selected values', () => {
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
customization.colorOptions = [
{
name: 'noColor',
localizedName: 'No Color',
value: { dark: '#000', light: '#fff' }
},
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
visualOptions: options.getNodeVisualOptions(
{ collapsed: false, pinned: false },
vi.fn()
),
colorSubmenu: options.colorSubmenu.value
}))
expect(visualOptions[1].submenu).toEqual([
expect.objectContaining({ label: 'Box' })
])
visualOptions[1].submenu?.[0].action()
expect(customization.applyShape).toHaveBeenCalledWith(
customization.shapeOptions[0]
)
expect(colorSubmenu).toEqual([
expect.objectContaining({ label: 'No Color', color: '#000' }),
expect.objectContaining({ label: 'Red', color: '#111' })
])
colorSubmenu[0].action()
colorSubmenu[1].action()
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
expect(customization.applyColor).toHaveBeenNthCalledWith(
2,
customization.colorOptions[1]
)
})
it('uses light-theme colors for the color submenu', () => {
customization.isLightTheme.value = true
customization.colorOptions = [
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
expect(
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
).toBe('#eee')
})
})

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

@@ -4,45 +4,34 @@ import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
selectedItems: [] as unknown[],
getSelectedNodes: vi.fn((): unknown[] => []),
getCanvas: vi.fn(),
updateSelectedItems: vi.fn(),
revokeSubgraphPreviews: vi.fn(),
activeWorkflow: null as null | {
changeTracker?: {
captureCanvasState: () => void
}
}
selectedItems: [] as unknown[]
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
getSelectedNodes: mocks.getSelectedNodes
getSelectedNodes: vi.fn(() => [])
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: mocks.getCanvas,
getCanvas: vi.fn(),
get selectedItems() {
return mocks.selectedItems
},
updateSelectedItems: mocks.updateSelectedItems
updateSelectedItems: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mocks.activeWorkflow
}
activeWorkflow: null
})
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokeSubgraphPreviews: mocks.revokeSubgraphPreviews
revokeSubgraphPreviews: vi.fn()
})
}))
@@ -61,36 +50,10 @@ function createRegularNode(): LGraphNode {
return new LGraphNode('testnode')
}
function createCanvas({
graph,
subgraph,
selectedItems = []
}: {
graph?: {
convertToSubgraph?: ReturnType<typeof vi.fn>
unpackSubgraph?: ReturnType<typeof vi.fn>
}
subgraph?: {
convertToSubgraph?: ReturnType<typeof vi.fn>
unpackSubgraph?: ReturnType<typeof vi.fn>
}
selectedItems?: unknown[]
} = {}) {
return {
graph,
subgraph,
selectedItems: new Set(selectedItems),
select: vi.fn()
}
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.selectedItems = []
mocks.getSelectedNodes.mockReturnValue([])
mocks.getCanvas.mockReturnValue(createCanvas())
mocks.activeWorkflow = null
})
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
@@ -140,126 +103,4 @@ describe('useSubgraphOperations', () => {
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('reports selected subgraph and selectable node state', async () => {
mocks.selectedItems = [createRegularNode()]
mocks.getSelectedNodes.mockReturnValue([])
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { isSubgraphSelected, hasSelectableNodes } = useSubgraphOperations()
expect(isSubgraphSelected()).toBe(false)
expect(hasSelectableNodes()).toBe(false)
mocks.selectedItems = [createSubgraphNode()]
mocks.getSelectedNodes.mockReturnValue([createRegularNode()])
expect(isSubgraphSelected()).toBe(true)
expect(hasSelectableNodes()).toBe(true)
})
it('converts selected items to a subgraph and captures workflow state', async () => {
const captureCanvasState = vi.fn()
const node = createSubgraphNode()
const graph = {
convertToSubgraph: vi.fn(() => ({ node })),
unpackSubgraph: vi.fn()
}
const canvas = createCanvas({
graph,
selectedItems: [createRegularNode()]
})
mocks.getCanvas.mockReturnValue(canvas)
mocks.activeWorkflow = {
changeTracker: {
captureCanvasState
}
}
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { convertToSubgraph } = useSubgraphOperations()
convertToSubgraph()
expect(graph.convertToSubgraph).toHaveBeenCalledWith(canvas.selectedItems)
expect(canvas.select).toHaveBeenCalledWith(node)
expect(mocks.updateSelectedItems).toHaveBeenCalledOnce()
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not select or capture when conversion has no graph or no result', async () => {
const graph = {
convertToSubgraph: vi.fn(() => null),
unpackSubgraph: vi.fn()
}
const canvas = createCanvas({ graph })
mocks.getCanvas
.mockReturnValueOnce(createCanvas())
.mockReturnValueOnce(canvas)
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { convertToSubgraph } = useSubgraphOperations()
expect(convertToSubgraph()).toBeNull()
expect(convertToSubgraph()).toBeUndefined()
expect(canvas.select).not.toHaveBeenCalled()
expect(mocks.updateSelectedItems).not.toHaveBeenCalled()
})
it('unpacks selected subgraph nodes from the active graph and revokes previews', async () => {
const captureCanvasState = vi.fn()
const subgraphNode = createSubgraphNode()
const graph = {
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn()
}
mocks.getCanvas.mockReturnValue(
createCanvas({
subgraph: graph,
selectedItems: [subgraphNode, createRegularNode()]
})
)
mocks.activeWorkflow = {
changeTracker: {
captureCanvasState
}
}
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { unpackSubgraph } = useSubgraphOperations()
unpackSubgraph()
expect(mocks.revokeSubgraphPreviews).toHaveBeenCalledWith(subgraphNode)
expect(graph.unpackSubgraph).toHaveBeenCalledWith(subgraphNode, {
skipMissingNodes: true
})
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not unpack when no graph or no subgraph nodes are selected', async () => {
const graph = {
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn()
}
mocks.getCanvas
.mockReturnValueOnce(createCanvas())
.mockReturnValueOnce(
createCanvas({ graph, selectedItems: [createRegularNode()] })
)
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { unpackSubgraph } = useSubgraphOperations()
unpackSubgraph()
unpackSubgraph()
expect(graph.unpackSubgraph).not.toHaveBeenCalled()
expect(mocks.revokeSubgraphPreviews).not.toHaveBeenCalled()
})
})

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