Compare commits

..

19 Commits

Author SHA1 Message Date
huang47
78fb7da4b6 test: address review feedback on core settings tests 2026-07-03 21:19:24 -07:00
huang47
0e3610b4ee test: address review feedback on settings and badge coverage
- unstub window.innerWidth after each coreSettings test
- derive SettingDialog mock types from SettingTreeNode/SettingParams/ISettingGroup
- assert dynamic-pricing dependency reads in usePartitionedBadges test
2026-07-03 15:22:40 -07:00
huang47
182229c157 test: replace fromAny/double-casts/vue-i18n mocks with real i18n and type-safe patterns
- Swap vi.mock('vue-i18n') in 4 component tests and useSettingUI.test.ts
  for createI18n({legacy:false, locale:'en', messages:{en:{}}}) passed via
  global.plugins; removes all five vue-i18n-mock warnings
- Replace fromAny({$t:...}) globalProperties hack with the real i18n plugin
- Fix keybinding migration test: drop unnecessary double cast by passing
  the array directly to the migrateDeprecatedValue(val: unknown) function
- Fix useSettingUI fallback test: use 'subscription' (a valid but absent
  SettingPanelType) instead of a casted 'missing' string literal
- Add missing `type` ref to useBillingContext mock in useSettingUI.test.ts
  so the mock matches the full production BillingContext contract
- Wrap useSettingUI() calls in a runSettingUI helper that mounts the
  composable inside a minimal component with i18n installed
2026-07-03 14:48:27 -07:00
huang47
1989d76bbd test: cover core settings and renderer 2026-07-03 14:47:51 -07:00
Robin Huang
d85ce2bf9e feat: add custom nodes waitlist survey to Manager button on Cloud (#13135)
## Summary

On Comfy Cloud, show the Manager button and open a hosted survey in the
manager modal (in place of the local node manager) so we can gauge
demand for custom nodes on Cloud.

## Changes

- **What**: `TopMenuSection` shows the Manager button when `isCloud`;
clicking it opens `ManagerSurveyDialog`, which embeds a PostHog hosted
survey via iframe. The survey URL comes per-environment from cloud
config (`manager_survey_url`), with the logged-in user's `distinct_id`
appended so responses link to the user. Includes loading/error states
and PostHog's `posthog:survey:height` iframe auto-resize.
- **Dependencies**: none

## Review Focus

- Survey URL is sourced from `remoteConfig.manager_survey_url` (must be
set per environment in cloud config); falls back to an error state when
unset or malformed.
- iframe embedding requires the PostHog survey to be `external_survey`
type with embedding enabled.
2026-07-03 18:12:34 +00:00
Benjamin Lu
fa87c46f90 chore: remove PreToolUse pnpm-enforcement hooks (#13422)
## Summary

Removes the Claude Code PreToolUse hooks added in #11201. Their `if`
patterns blocked any bash command the static pattern parser could not
fully resolve — loop variables, `$(...)`/backtick substitution,
heredocs, `${...}` expansions — with a misleading error naming a random
unrelated tool. Transcript analysis across a month of sessions found 37
hook firings: 1 true positive, 36 false positives (~97%), including
blocking `pnpm typecheck ... | tail` itself.

## Changes

- **What**: Delete `.claude/settings.json` (it contained only the hook
config) and the script-based replacement from earlier revisions of this
PR.
- Earlier revisions replaced the hooks with a stdin-inspecting matcher
script, but the hooks' original rationale — protecting Nx task
orchestration back when `test:unit` was `nx run test` — disappeared when
Nx was removed in #12355 and the pnpm scripts became direct tool
invocations. The remaining value (nudging agents toward pnpm scripts)
does not justify maintaining a bash-parsing matcher with its own edge
cases.

## Review Focus

- Agents can now run `npx tsc` / `npx vitest` etc. without being
redirected; the pnpm-script convention remains documented in AGENTS.md,
which is what agents follow in practice.
2026-07-03 06:57:05 +00:00
Benjamin Lu
941f151520 fix: avoid duplicate unit coverage execution (#13423)
## Summary

Stop running the full Vitest suite twice in unit CI. The critical
coverage gate is now a glob-keyed `coverage.thresholds` entry enforced
during the single `pnpm test:coverage` run, instead of a second
`COVERAGE_CRITICAL=true vitest run --coverage` pass.

## Changes

- **What**: All critical directories form one brace-expanded glob key in
`coverage.thresholds`; Vitest aggregates the matching files into a
single bucket and checks the existing thresholds (69/60/67/70) against
it during the normal coverage run. Untested files matching
`coverage.include` are counted at 0%, preserving the previous gate's
semantics.
- **What**: Narrows the litegraph coverage exclusion from a blanket
`src/lib/litegraph/**` to the non-critical subfolders, so the critical
litegraph folders (`node`, `subgraph`, `utils`) are present in the
coverage report the thresholds read.
- **What**: Removes the `test:coverage:critical` script, the
`COVERAGE_CRITICAL` env branch in `vite.config.mts`, and the separate CI
gate step.

## Notes

- The normal coverage report now includes the critical litegraph
folders, so the Codecov `unit` flag and the coverage Slack baseline will
show a one-time shift.
- Filtered local runs (`pnpm test:coverage <file>`) fail the gate since
most critical files are uncovered; a full `pnpm test:coverage`
reproduces CI exactly.

Validation:
- `pnpm typecheck`, `pnpm exec eslint vite.config.mts`, `pnpm
format:check`, `pnpm knip` (pre-push)
- Smoke: `pnpm vitest run --coverage src/utils/colorUtil.test.ts` —
tests pass, then the gate fails all four metrics against the critical
bucket and exits 1, confirming enforcement happens inside the single run
- Full-suite gate numbers should be confirmed in CI
2026-07-03 05:43:05 +00:00
Benjamin Lu
df5f5b3367 feat: identify auth users to Syft (#13311)
## Summary

Identifies Cloud auth users to Syft via the required `identify(email, {
source })` handoff so Syft enrichment reliably attaches to signup/login
users instead of relying only on GTM page-load capture.

## Changes

- **What**: Adds a cloud-only Syft telemetry provider that reads
`syftdata_source_id` from `remoteConfig`, lazy-loads the Syft SDK
(reusing an already-loaded GTM Syft client when present), and calls
`identify` with `source: 'signup'` or `source: 'login'` on auth and on
session restore. `trackUserLoggedIn()` dedupes against the email already
handled by `trackAuth()` so a fresh login is not identified twice.
- **Dependencies**: None.

## Review Focus

- Preserves the FE-945 startup-blocker fix: the constructor reads only
the `remoteConfig` ref (a plain reactive ref, not Pinia) and never
touches current-user state; the user-email lookup happens only in
`trackUserLoggedIn()`, after app/auth setup. The source id is present at
construction because `main.ts` awaits the anonymous
`refreshRemoteConfig` before `initTelemetry`, and a later authenticated
refresh is picked up reactively on the next `ensureSyftClient()` call.
- SDK loader is idempotent with the current GTM Syft tag during rollout
(one script per `SYFT_SRC`). On load failure it clears its own stub —
guarded by an identity check so it never evicts a real client another
loader installed — letting a subsequent call retry. Long-term cleanup is
to keep one loader path per surface.
- Acceptance should include staging Network verification for
`https://e2.sy-d.io/events` payloads containing an `identify` event for
Google, GitHub, and email auth.

Linear: GTM-168
2026-07-03 05:10:52 +00: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
137 changed files with 4514 additions and 707 deletions

View File

@@ -1,86 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
]
}
}

View File

@@ -134,6 +134,27 @@ jobs:
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Syft telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Syft references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)syft' \
-e '(?i)sy-d\.io' \
dist; then
echo '❌ ERROR: Syft references found in dist assets!'
echo 'Syft must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Syft references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail

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

@@ -55,6 +55,3 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Enforce critical coverage gate
run: pnpm test:coverage:critical

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()
})

25
global.d.ts vendored
View File

@@ -41,6 +41,29 @@ interface GtagFunction {
(...args: unknown[]): void
}
type SyftDataTraits = Record<string, string | number | null | undefined>
interface SyftDataPendingFetch {
args: unknown[]
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
}
interface SyftDataClient {
identify(email: string, traits?: SyftDataTraits): void
signup(email: string, traits?: SyftDataTraits): void
track(event: string, traits?: SyftDataTraits): void
page(...args: unknown[]): void
q?: unknown[][]
fi?: SyftDataPendingFetch[]
fetchID?: (...args: unknown[]) => Promise<unknown>
}
/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */
interface SyftDisabledClient {
enable: () => void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
@@ -78,6 +101,8 @@ interface Window {
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
syft?: SyftDataClient | SyftDisabledClient
syftc?: { sourceId?: string; enabled?: boolean }
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction

View File

@@ -53,7 +53,6 @@
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

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

@@ -13,7 +13,7 @@
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
@@ -163,6 +163,7 @@ import {
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useManagerSurveyDialog } from '@/workbench/extensions/manager/composables/useManagerSurveyDialog'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@comfyorg/tailwind-utils'
@@ -170,6 +171,7 @@ const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const managerSurveyDialog = useManagerSurveyDialog()
const { flags } = useFeatureFlags()
const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
@@ -337,6 +339,10 @@ onBeforeUnmount(() => {
})
const openCustomNodeManager = async () => {
if (isCloud) {
managerSurveyDialog.show()
return
}
try {
await managerState.openManager({
initialTab: ManagerTab.All,

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

@@ -101,7 +101,7 @@
"color": "Color",
"error": "Error",
"enter": "Enter",
"enterSubgraph": "Enter Subgraph",
"enterSubgraph": "Enter subgraph",
"inSubgraph": "in subgraph '{name}'",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
@@ -429,6 +429,11 @@
},
"manager": {
"title": "Nodes Manager",
"survey": {
"title": "Join the waitlist",
"intro": "Installing custom nodes is coming soon to Comfy Cloud. Answer a few quick questions to join the waitlist and help shape this feature.",
"error": "This survey couldn't be loaded. Please try again later."
},
"nodePackInfo": "Node Pack Info",
"basicInfo": "Basic Info",
"actions": "Actions",

View File

@@ -426,10 +426,6 @@
"Comfy_Validation_Workflows": {
"name": "Validate workflows"
},
"Comfy_VueNodes_AutoScaleLayout": {
"name": "Auto-scale layout (Nodes 2.0)",
"tooltip": "Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap"
},
"Comfy_VueNodes_Enabled": {
"name": "Modern Node Design (Nodes 2.0)",
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."

View File

@@ -0,0 +1,136 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SubscriptionPanelContentLegacy from './SubscriptionPanelContentLegacy.vue'
const mockAccessBillingPortal = vi.fn()
const mockTrackSubscriptionCancellation = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockHandleRefresh = vi.fn()
const mockIsActiveSubscription = ref(true)
const mockIsCancelled = ref(false)
const mockIsFreeTier = ref(false)
const mockSubscriptionTier = ref<'STANDARD' | 'CREATOR' | 'PRO' | null>(
'STANDARD'
)
const mockIsYearlySubscription = ref(true)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackSubscriptionCancellation: mockTrackSubscriptionCancellation
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isCancelled: computed(() => mockIsCancelled.value),
isFreeTier: computed(() => mockIsFreeTier.value),
formattedRenewalDate: computed(() => '2026-08-01'),
formattedEndDate: computed(() => '2026-08-01'),
subscriptionTier: computed(() => mockSubscriptionTier.value),
subscriptionTierName: computed(() => 'Standard'),
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => ({
handleRefresh: mockHandleRefresh
})
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: mockShowSubscriptionDialog
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
perMonth: '/ month',
manageSubscription: 'Manage subscription',
upgradePlan: 'Upgrade plan',
subscribeNow: 'Subscribe now',
yourPlanIncludes: 'Your plan includes',
viewMoreDetailsPlans: 'View more details',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}',
monthlyCreditsLabel: 'monthly credits',
maxDurationLabel: 'max duration',
gpuLabel: 'GPU access',
addCreditsLabel: 'Add credits',
customLoRAsLabel: 'Custom LoRAs',
maxDuration: {
standard: '30 min'
}
}
}
}
})
function renderComponent() {
return render(SubscriptionPanelContentLegacy, {
global: {
plugins: [i18n],
stubs: {
CreditsTile: true,
SubscribeButton: true,
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>',
emits: ['click']
}
}
}
})
}
describe('SubscriptionPanelContentLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAccessBillingPortal.mockResolvedValue(undefined)
mockIsActiveSubscription.value = true
mockIsCancelled.value = false
mockIsFreeTier.value = false
mockSubscriptionTier.value = 'STANDARD'
mockIsYearlySubscription.value = true
})
it('tracks cancel intent before opening the billing portal', async () => {
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /manage subscription/i })
)
expect(mockTrackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
'flow_opened',
{
source: 'manage_subscription_button',
current_tier: 'standard',
cycle: 'yearly'
}
)
expect(mockAccessBillingPortal).toHaveBeenCalledOnce()
})
})

View File

@@ -36,11 +36,7 @@
v-if="isActiveSubscription && !isFreeTier"
variant="secondary"
class="ml-auto rounded-lg bg-interface-menu-component-surface-selected px-4 py-2 text-sm font-normal text-text-primary"
@click="
async () => {
await authActions.accessBillingPortal()
}
"
@click="handleManageSubscription"
>
{{ $t('subscription.manageSubscription') }}
</Button>
@@ -125,6 +121,7 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
@@ -160,6 +157,18 @@ const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
// The portal is the only place a legacy user can cancel (in-app UI already
// covers plan changes), so this click is the closest observable cancel-intent
// signal on the mainline path.
async function handleManageSubscription() {
useTelemetry()?.trackSubscriptionCancellation('flow_opened', {
source: 'manage_subscription_button',
current_tier: subscriptionTier.value?.toLowerCase(),
cycle: isYearlySubscription.value ? 'yearly' : 'monthly'
})
await authActions.accessBillingPortal()
}
const tierBenefits = computed((): TierBenefit[] =>
getCommonTierBenefits(tierKey.value, t, n)
)

View File

@@ -1,113 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
computeActiveGraphIds,
computeAncestorExecutionIds,
createVerificationAbortController
} from './missingCandidateHelpers'
const mocks = vi.hoisted(() => ({
rootGraph: null as unknown,
getActiveGraphNodeIds: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
get rootGraph() {
return mocks.rootGraph
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getActiveGraphNodeIds: mocks.getActiveGraphNodeIds
}))
describe('createVerificationAbortController', () => {
it('create returns a fresh, non-aborted controller', () => {
const manager = createVerificationAbortController()
const controller = manager.create()
expect(controller.signal.aborted).toBe(false)
})
it('create aborts the previously issued controller', () => {
const manager = createVerificationAbortController()
const first = manager.create()
manager.create()
expect(first.signal.aborted).toBe(true)
})
it('abort aborts the current controller', () => {
const manager = createVerificationAbortController()
const controller = manager.create()
manager.abort()
expect(controller.signal.aborted).toBe(true)
})
it('abort after abort is a no-op (no current controller)', () => {
const manager = createVerificationAbortController()
manager.create()
manager.abort()
expect(() => manager.abort()).not.toThrow()
})
})
describe('computeAncestorExecutionIds', () => {
it('expands each node id into its execution-id prefixes, inclusive', () => {
const result = computeAncestorExecutionIds(['65:70:63'])
expect([...result]).toEqual(['65', '65:70', '65:70:63'])
})
it('deduplicates shared ancestor prefixes across node ids', () => {
const result = computeAncestorExecutionIds(['65:70', '65:71'])
expect([...result]).toEqual(['65', '65:70', '65:71'])
})
it('returns an empty set for no node ids', () => {
expect(computeAncestorExecutionIds([]).size).toBe(0)
})
})
describe('computeActiveGraphIds', () => {
beforeEach(() => {
mocks.rootGraph = null
mocks.getActiveGraphNodeIds.mockReset()
})
it('returns an empty set when the root graph is unavailable', () => {
const result = computeActiveGraphIds(null, new Set())
expect(result.size).toBe(0)
expect(mocks.getActiveGraphNodeIds).not.toHaveBeenCalled()
})
it('delegates to getActiveGraphNodeIds with the current graph', () => {
const rootGraph = { id: 'root' }
const currentGraph = { id: 'current' }
mocks.rootGraph = rootGraph
mocks.getActiveGraphNodeIds.mockReturnValue(new Set(['1']))
const ancestors = computeAncestorExecutionIds(['65'])
const result = computeActiveGraphIds(currentGraph as never, ancestors)
expect(result).toEqual(new Set(['1']))
expect(mocks.getActiveGraphNodeIds).toHaveBeenCalledWith(
rootGraph,
currentGraph,
ancestors
)
})
it('falls back to the root graph when no current graph is given', () => {
const rootGraph = { id: 'root' }
mocks.rootGraph = rootGraph
mocks.getActiveGraphNodeIds.mockReturnValue(new Set<string>())
computeActiveGraphIds(null, new Set())
expect(mocks.getActiveGraphNodeIds).toHaveBeenCalledWith(
rootGraph,
rootGraph,
expect.any(Set)
)
})
})

View File

@@ -1,55 +0,0 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
interface VerificationAbortController {
create(): AbortController
abort(): void
}
export function createVerificationAbortController(): VerificationAbortController {
let controller: AbortController | null = null
return {
create() {
controller?.abort()
controller = new AbortController()
return controller
},
abort() {
controller?.abort()
controller = null
}
}
}
/**
* Set of all execution ID prefixes derived from the given node IDs,
* including the nodes themselves.
*
* Example: node "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
export function computeAncestorExecutionIds(
nodeIds: Iterable<string>
): Set<NodeExecutionId> {
const ids = new Set<NodeExecutionId>()
for (const nodeId of nodeIds) {
for (const id of getAncestorExecutionIds(nodeId)) {
ids.add(id)
}
}
return ids
}
export function computeActiveGraphIds(
currentGraph: LGraph | null,
ancestorExecutionIds: Set<NodeExecutionId>
): Set<string> {
if (!app.rootGraph) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
currentGraph ?? app.rootGraph,
ancestorExecutionIds
)
}

View File

@@ -3,12 +3,11 @@ import { computed, ref } from 'vue'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import {
computeActiveGraphIds,
computeAncestorExecutionIds,
createVerificationAbortController
} from '@/platform/missing/missingCandidateHelpers'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
/**
@@ -32,18 +31,38 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
new Set(missingMediaCandidates.value?.map((m) => String(m.nodeId)) ?? [])
)
const missingMediaAncestorExecutionIds = computed(() =>
computeAncestorExecutionIds(missingMediaNodeIds.value)
/**
* Set of all execution ID prefixes derived from missing media node IDs,
* including the missing media nodes themselves.
*/
const missingMediaAncestorExecutionIds = computed<Set<NodeExecutionId>>(
() => {
const ids = new Set<NodeExecutionId>()
for (const nodeId of missingMediaNodeIds.value) {
for (const id of getAncestorExecutionIds(nodeId)) {
ids.add(id)
}
}
return ids
}
)
const activeMissingMediaGraphIds = computed(() =>
computeActiveGraphIds(
canvasStore.currentGraph,
const activeMissingMediaGraphIds = computed<Set<string>>(() => {
if (!app.rootGraph) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingMediaAncestorExecutionIds.value
)
)
})
const verificationAbortController = createVerificationAbortController()
let _verificationAbortController: AbortController | null = null
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
return _verificationAbortController
}
function setMissingMedia(media: MissingMediaCandidate[]) {
missingMediaCandidates.value = media.length ? media : null
@@ -113,7 +132,8 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
}
function clearMissingMedia() {
verificationAbortController.abort()
_verificationAbortController?.abort()
_verificationAbortController = null
missingMediaCandidates.value = null
}
@@ -131,7 +151,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
removeMissingMediaByNodeId,
removeMissingMediaByPrefix,
clearMissingMedia,
createVerificationAbortController: verificationAbortController.create,
createVerificationAbortController,
isContainerWithMissingMedia
}

View File

@@ -9,12 +9,9 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
computeActiveGraphIds,
computeAncestorExecutionIds,
createVerificationAbortController
} from '@/platform/missing/missingCandidateHelpers'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
/**
* Missing model error state and interaction state.
@@ -54,16 +51,32 @@ export const useMissingModelStore = defineStore('missingModel', () => {
return keys
})
const missingModelAncestorExecutionIds = computed(() =>
computeAncestorExecutionIds(missingModelNodeIds.value)
/**
* Set of all execution ID prefixes derived from missing model node IDs,
* including the missing model nodes themselves.
*
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
() => {
const ids = new Set<NodeExecutionId>()
for (const nodeId of missingModelNodeIds.value) {
for (const id of getAncestorExecutionIds(nodeId)) {
ids.add(id)
}
}
return ids
}
)
const activeMissingModelGraphIds = computed(() =>
computeActiveGraphIds(
canvasStore.currentGraph,
const activeMissingModelGraphIds = computed<Set<string>>(() => {
if (!app.rootGraph) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingModelAncestorExecutionIds.value
)
)
})
// Persists across component re-mounts so that download progress
// survives tab switches within the right-side panel.
@@ -73,7 +86,13 @@ export const useMissingModelStore = defineStore('missingModel', () => {
const folderPaths = ref<Record<string, string[]>>({})
const fileSizes = ref<Record<string, number>>({})
const verificationAbortController = createVerificationAbortController()
let _verificationAbortController: AbortController | null = null
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
return _verificationAbortController
}
function setMissingModels(models: MissingModelCandidate[]) {
missingModelCandidates.value = models.length ? models : null
@@ -227,7 +246,8 @@ export const useMissingModelStore = defineStore('missingModel', () => {
}
function clearMissingModels() {
verificationAbortController.abort()
_verificationAbortController?.abort()
_verificationAbortController = null
missingModelCandidates.value = null
modelExpandState.value = {}
selectedLibraryModel.value = {}
@@ -278,7 +298,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
removeMissingModelsBySourceScope,
clearMissingModels,
refreshMissingModels,
createVerificationAbortController: verificationAbortController.create,
createVerificationAbortController,
hasMissingModelOnNode,
isWidgetMissingModel,

View File

@@ -82,6 +82,7 @@ export type RemoteConfig = {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Partial<PostHogConfig>
syftdata_source_id?: string
customer_io?: {
write_key?: string
site_id?: string
@@ -101,6 +102,8 @@ export type RemoteConfig = {
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
onboarding_survey?: OnboardingSurvey
/** Full hosted (external) survey URL embedded in the Nodes Manager modal on Cloud. */
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean

View File

@@ -0,0 +1,176 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ColorPaletteMessage from './ColorPaletteMessage.vue'
import type * as Pinia from 'pinia'
const testI18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const mockSettingStore = vi.hoisted(() => ({
set: vi.fn()
}))
const mockColorPaletteService = vi.hoisted(() => ({
exportColorPalette: vi.fn(),
importColorPalette: vi.fn(),
deleteCustomColorPalette: vi.fn()
}))
const mockColorPaletteState = vi.hoisted(() => ({
refs: null as null | {
palettes: {
value: Array<{ id: string; name: string }>
}
activePaletteId: {
value: string
}
},
customPaletteIds: new Set<string>()
}))
vi.mock('pinia', async (importOriginal: () => Promise<typeof Pinia>) => {
const actual = await importOriginal()
return {
...actual,
storeToRefs: (store: object) => store
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/services/colorPaletteService', () => ({
useColorPaletteService: () => mockColorPaletteService
}))
vi.mock('@/stores/workspace/colorPaletteStore', async () => {
const { ref } = await import('vue')
const palettes = ref([
{ id: 'builtin', name: 'Builtin' },
{ id: 'custom', name: 'Custom' }
])
const activePaletteId = ref('builtin')
mockColorPaletteState.refs = {
palettes,
activePaletteId
}
return {
useColorPaletteStore: () => ({
palettes,
activePaletteId,
isCustomPalette: (paletteId: string) =>
mockColorPaletteState.customPaletteIds.has(paletteId)
})
}
})
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
props: ['title', 'disabled'],
emits: ['click'],
template: `
<button
type="button"
:title="title"
:disabled="disabled"
@click="$emit('click')"
>
<slot />
</button>
`
}
}))
vi.mock('primevue/message', () => ({
default: {
template: '<section><slot /></section>'
}
}))
vi.mock('primevue/select', () => ({
default: {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: `
<select
data-testid="palette-select"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="option in options" :key="option.id" :value="option.id">
{{ option.name }}
</option>
</select>
`
}
}))
function renderMessage() {
return render(ColorPaletteMessage, {
global: {
plugins: [testI18n]
}
})
}
describe('ColorPaletteMessage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStore.set.mockResolvedValue(undefined)
mockColorPaletteService.importColorPalette.mockResolvedValue(null)
mockColorPaletteState.customPaletteIds = new Set(['custom'])
if (mockColorPaletteState.refs) {
mockColorPaletteState.refs.activePaletteId.value = 'builtin'
mockColorPaletteState.refs.palettes.value = [
{ id: 'builtin', name: 'Builtin' },
{ id: 'custom', name: 'Custom' }
]
}
})
it('exports and deletes the active custom palette', async () => {
renderMessage()
await userEvent.click(screen.getByTitle('g.export'))
expect(mockColorPaletteService.exportColorPalette).toHaveBeenCalledWith(
'builtin'
)
expect(screen.getByTitle('g.delete')).toBeDisabled()
await fireEvent.update(screen.getByTestId('palette-select'), 'custom')
await userEvent.click(screen.getByTitle('g.delete'))
expect(
mockColorPaletteService.deleteCustomColorPalette
).toHaveBeenCalledWith('custom')
})
it('persists imported palettes only when import returns a palette', async () => {
renderMessage()
await userEvent.click(screen.getByTitle('g.import'))
expect(mockSettingStore.set).not.toHaveBeenCalled()
mockColorPaletteService.importColorPalette.mockResolvedValue({
id: 'imported',
name: 'Imported'
})
await userEvent.click(screen.getByTitle('g.import'))
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.ColorPalette',
'imported'
)
})
})

View File

@@ -0,0 +1,306 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExtensionPanel from './ExtensionPanel.vue'
const testI18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
interface MockExtension {
name: string
}
const mockSettingStore = vi.hoisted(() => ({
set: vi.fn()
}))
const mockExtensionState = vi.hoisted(() => ({
store: {
extensions: [
{ name: 'core.color' },
{ name: 'custom.pack' },
{ name: 'readonly.pack' }
] as MockExtension[],
inactiveDisabledExtensionNames: ['inactive.pack'],
hasThirdPartyExtensions: true,
enabled: new Set(['core.color', 'custom.pack', 'readonly.pack']),
core: new Set(['core.color']),
readOnly: new Set(['readonly.pack']),
isExtensionEnabled(name: string) {
return this.enabled.has(name)
},
isCoreExtension(name: string) {
return this.core.has(name)
},
isExtensionReadOnly(name: string) {
return this.readOnly.has(name)
}
}
}))
vi.mock('@primevue/core/api', () => ({
FilterMatchMode: {
CONTAINS: 'contains'
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => mockExtensionState.store
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue'],
template: `
<input
data-testid="extension-search"
:value="modelValue"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
/>
`
}
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
props: ['disabled'],
emits: ['click'],
template: `
<button type="button" :disabled="disabled" @click="$emit('click', $event)">
<slot />
</button>
`
}
}))
vi.mock('primevue/message', () => ({
default: {
template: '<section data-testid="extension-message"><slot /></section>'
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: `
<div data-testid="extension-filter">
<button
v-for="option in options"
:key="option.value"
type="button"
@click="$emit('update:modelValue', option.value)"
>
{{ option.label }}
</button>
</div>
`
}
}))
vi.mock('primevue/datatable', () => ({
default: {
props: ['value', 'selection'],
emits: ['update:selection'],
template: `
<section data-testid="extension-table">
<button
type="button"
data-testid="select-visible"
@click="$emit('update:selection', value)"
>
select
</button>
<div v-for="ext in value" :key="ext.name" data-testid="extension-row">
{{ ext.name }}
</div>
<slot />
</section>
`
}
}))
vi.mock('primevue/column', () => ({
default: {
template: '<div><slot name="header" /><slot /></div>'
}
}))
vi.mock('primevue/contextmenu', () => ({
default: {
props: ['model'],
methods: {
show: vi.fn()
},
template: `
<div data-testid="extension-menu">
<button
v-for="item in model.filter((entry) => !entry.separator)"
:key="item.label"
type="button"
:disabled="item.disabled"
@click="item.command?.()"
>
{{ item.label }}
</button>
</div>
`
}
}))
vi.mock('primevue/tag', () => ({
default: {
props: ['value'],
template: '<span>{{ value }}</span>'
}
}))
vi.mock('primevue/toggleswitch', () => ({
default: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue', 'change'],
template: `
<button
type="button"
:disabled="disabled"
data-testid="extension-toggle"
@click="$emit('update:modelValue', !modelValue); $emit('change')"
>
{{ String(modelValue) }}
</button>
`
}
}))
function renderPanel() {
return render(ExtensionPanel, {
global: {
plugins: [testI18n]
}
})
}
describe('ExtensionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStore.set.mockResolvedValue(undefined)
mockExtensionState.store.extensions = [
{ name: 'core.color' },
{ name: 'custom.pack' },
{ name: 'readonly.pack' }
]
mockExtensionState.store.inactiveDisabledExtensionNames = ['inactive.pack']
mockExtensionState.store.hasThirdPartyExtensions = true
mockExtensionState.store.enabled = new Set([
'core.color',
'custom.pack',
'readonly.pack'
])
mockExtensionState.store.core = new Set(['core.color'])
mockExtensionState.store.readOnly = new Set(['readonly.pack'])
})
it('filters extensions by all, core, and custom categories', async () => {
renderPanel()
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'core.color'
)
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'custom.pack'
)
await userEvent.click(screen.getByRole('button', { name: 'g.core' }))
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'core.color'
)
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
'custom.pack'
)
await userEvent.click(screen.getByRole('button', { name: 'g.custom' }))
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
'core.color'
)
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'custom.pack'
)
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'readonly.pack'
)
})
it('applies selected extension commands without changing read-only rows', async () => {
renderPanel()
await userEvent.click(screen.getByTestId('select-visible'))
await userEvent.click(
screen.getByRole('button', { name: 'g.disableSelected' })
)
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack', 'core.color', 'custom.pack']
)
expect(screen.getByTestId('extension-message')).toHaveTextContent(
'core.color'
)
expect(screen.getByTestId('extension-message')).toHaveTextContent(
'custom.pack'
)
expect(screen.getByTestId('extension-message')).not.toHaveTextContent(
'readonly.pack'
)
await userEvent.click(
screen.getByRole('button', { name: 'g.enableSelected' })
)
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack']
)
})
it('applies bulk commands and disables third-party command when unavailable', async () => {
const { unmount } = renderPanel()
await userEvent.click(screen.getByRole('button', { name: 'g.disableAll' }))
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack', 'core.color', 'custom.pack']
)
await userEvent.click(screen.getByRole('button', { name: 'g.enableAll' }))
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack']
)
await userEvent.click(
screen.getByRole('button', { name: 'g.disableThirdParty' })
)
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack', 'custom.pack', 'readonly.pack']
)
unmount()
mockExtensionState.store.hasThirdPartyExtensions = false
renderPanel()
expect(
screen.getByRole('button', { name: 'g.disableThirdParty' })
).toBeDisabled()
})
})

View File

@@ -0,0 +1,317 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ServerConfigPanel from './ServerConfigPanel.vue'
import type * as Pinia from 'pinia'
const testI18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const mockSettingStore = vi.hoisted(() => ({
set: vi.fn()
}))
const mockToastStore = vi.hoisted(() => ({
add: vi.fn()
}))
const mockCopy = vi.hoisted(() => vi.fn())
const mockElectronAPI = vi.hoisted(() => ({
restartApp: vi.fn()
}))
const mockServerConfigStore = vi.hoisted(() => ({
refs: null as null | {
serverConfigsByCategory: {
value: Record<
string,
Array<{
id: string
name: string
value: string
initialValue: string
tooltip?: string
}>
>
}
serverConfigValues: { value: Record<string, string> }
launchArgs: { value: string[] }
commandLineArgs: { value: string }
modifiedConfigs: {
value: Array<{
id: string
name: string
value: string
initialValue: string
}>
}
},
revertChanges: vi.fn()
}))
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal<typeof Pinia>()
return {
...actual,
storeToRefs: (store: object) => store
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => mockToastStore
}))
vi.mock('@/stores/serverConfigStore', async () => {
const { ref } = await import('vue')
const serverConfigsByCategory = ref({
general: [
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false',
tooltip: 'Enable listen mode'
},
{
id: 'preview',
name: 'Preview',
value: 'auto',
initialValue: 'auto'
}
]
})
const serverConfigValues = ref({ listen: 'true' })
const launchArgs = ref(['--listen'])
const commandLineArgs = ref('python main.py --listen')
const modifiedConfigs = ref([
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false'
}
])
mockServerConfigStore.refs = {
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
modifiedConfigs
}
return {
useServerConfigStore: () => ({
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
modifiedConfigs,
revertChanges: mockServerConfigStore.revertChanges
})
}
})
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopy
})
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => mockElectronAPI
}))
vi.mock('@/components/common/FormItem.vue', () => ({
default: {
props: ['id', 'item', 'labelClass'],
template: `
<label
:data-testid="'server-config-' + id"
:data-highlighted="String(Boolean(labelClass?.['text-highlight']))"
:title="item.tooltip"
>
{{ item.name }}={{ item.value }}
</label>
`
}
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
props: ['ariaLabel'],
emits: ['click'],
template: `
<button type="button" :aria-label="ariaLabel" @click="$emit('click')">
<slot />
</button>
`
}
}))
vi.mock('primevue/divider', () => ({
default: {
template: '<hr />'
}
}))
vi.mock('primevue/message', () => ({
default: {
template: '<section><slot name="icon" /><slot /></section>'
}
}))
function renderPanel() {
return render(ServerConfigPanel, {
global: {
plugins: [testI18n]
}
})
}
describe('ServerConfigPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStore.set.mockResolvedValue(undefined)
mockCopy.mockResolvedValue(undefined)
mockElectronAPI.restartApp.mockResolvedValue(undefined)
mockServerConfigStore.revertChanges.mockReset()
if (mockServerConfigStore.refs) {
mockServerConfigStore.refs.serverConfigsByCategory.value = {
general: [
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false',
tooltip: 'Enable listen mode'
},
{
id: 'preview',
name: 'Preview',
value: 'auto',
initialValue: 'auto'
}
]
}
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'true' }
mockServerConfigStore.refs.launchArgs.value = ['--listen']
mockServerConfigStore.refs.commandLineArgs.value =
'python main.py --listen'
mockServerConfigStore.refs.modifiedConfigs.value = [
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false'
}
]
}
})
it('renders modified configs, translates form items, and copies command line args', async () => {
const user = userEvent.setup()
renderPanel()
expect(screen.getByText('serverConfig.modifiedConfigs')).toBeInTheDocument()
expect(screen.getByText('Listen: false → true')).toBeInTheDocument()
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
'data-highlighted',
'true'
)
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
'title',
'Enable listen mode'
)
expect(screen.getByTestId('server-config-preview')).toHaveAttribute(
'data-highlighted',
'false'
)
await user.click(screen.getByLabelText('g.copyToClipboard'))
expect(mockCopy).toHaveBeenCalledWith('python main.py --listen')
})
it('reverts, restarts, and suppresses the unmount warning after restart', async () => {
const user = userEvent.setup()
const { unmount } = renderPanel()
await user.click(
screen.getByRole('button', { name: 'serverConfig.revertChanges' })
)
expect(mockServerConfigStore.revertChanges).toHaveBeenCalledTimes(1)
await user.click(
screen.getByRole('button', { name: 'serverConfig.restart' })
)
expect(mockElectronAPI.restartApp).toHaveBeenCalledTimes(1)
unmount()
expect(mockToastStore.add).not.toHaveBeenCalled()
})
it('persists launch args and server config values through watchers', async () => {
renderPanel()
if (!mockServerConfigStore.refs) {
throw new Error('server config refs were not initialized')
}
mockServerConfigStore.refs.launchArgs.value = ['--cpu']
await nextTick()
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'false' }
await nextTick()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Server.LaunchArgs',
['--cpu']
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Server.ServerConfigValues',
{ listen: 'false' }
)
})
it('warns on unmount only when modified configs remain', () => {
if (!mockServerConfigStore.refs) {
throw new Error('server config refs were not initialized')
}
mockServerConfigStore.refs.modifiedConfigs.value = []
const empty = renderPanel()
empty.unmount()
expect(mockToastStore.add).not.toHaveBeenCalled()
mockServerConfigStore.refs.modifiedConfigs.value = [
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false'
}
]
const modified = renderPanel()
modified.unmount()
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'warn',
summary: 'serverConfig.restartRequiredToastSummary',
detail: 'serverConfig.restartRequiredToastDetail',
life: 10_000
})
})
})

View File

@@ -0,0 +1,543 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import SettingDialog from './SettingDialog.vue'
const testI18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
type MockSettingData = Omit<SettingParams, 'id' | 'type' | 'defaultValue'> & {
id: string
}
type MockSettingTreeNode = Omit<SettingTreeNode, 'data' | 'children'> & {
data?: MockSettingData
children?: MockSettingTreeNode[]
}
type MockSettingGroup = Omit<ISettingGroup, 'settings'> & {
settings: MockSettingData[]
}
const mockFetchBalance = vi.hoisted(() => vi.fn())
const mockSettingUI = vi.hoisted(() => ({
defaultPanel: undefined as string | undefined,
refs: null as null | {
settingCategories: {
value: MockSettingTreeNode[]
}
navGroups: {
value: Array<{
title: string
items: Array<{
id: string
label: string
icon?: string
badge?: string
}>
}>
}
}
}))
const mockSettingSearch = vi.hoisted(() => ({
refs: null as null | {
searchQuery: { value: string }
inSearch: { value: boolean }
searchResultsCategories: { value: Set<string> }
matchedNavItemKeys: { value: Set<string> }
results: { value: MockSettingGroup[] }
},
handleSearch: vi.fn()
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockFetchBalance
})
}))
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
useSearchQueryTracking: vi.fn()
}))
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
default: {
template: `
<section data-testid="settings-dialog">
<header data-testid="left-title"><slot name="leftPanelHeaderTitle" /></header>
<aside data-testid="left-panel"><slot name="leftPanel" /></aside>
<div data-testid="header"><slot name="header" /></div>
<div data-testid="header-actions"><slot name="header-right-area" /></div>
<main data-testid="content"><slot name="content" /></main>
</section>
`
}
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
props: ['modelValue', 'placeholder', 'autofocus'],
emits: ['update:modelValue', 'search'],
template: `
<input
data-testid="settings-search"
:value="modelValue"
:placeholder="placeholder"
:data-autofocus="String(autofocus)"
@input="$emit('update:modelValue', $event.target.value)"
@change="$emit('search', $event.target.value)"
/>
`
}
}))
vi.mock('@/components/widget/nav/NavTitle.vue', () => ({
default: {
props: ['title'],
template: '<h3>{{ title }}</h3>'
}
}))
vi.mock('@/components/widget/nav/NavItem.vue', () => ({
default: {
props: ['icon', 'badge', 'active'],
emits: ['click'],
template: `
<button
type="button"
:data-nav-id="$attrs['data-nav-id']"
:data-active="String(active)"
@click="$emit('click')"
>
<slot />
</button>
`
}
}))
vi.mock('@/components/dialog/content/setting/CurrentUserMessage.vue', () => ({
default: {
template: '<p data-testid="current-user-message">current user</p>'
}
}))
vi.mock('@/platform/settings/components/ColorPaletteMessage.vue', () => ({
default: {
template: '<p data-testid="color-palette-message">palette</p>'
}
}))
vi.mock('@/platform/settings/components/SettingsPanel.vue', () => ({
default: {
props: ['settingGroups'],
template: `
<div data-testid="settings-panel">
<section v-for="group in settingGroups" :key="group.label">
<h4>{{ group.label }}</h4>
<span v-for="setting in group.settings" :key="setting.id">
{{ setting.id }}
</span>
</section>
</div>
`
}
}))
vi.mock('@/platform/settings/composables/useSettingUI', async () => {
const { computed, defineComponent, h, ref } = await import('vue')
const settingCategories = ref<MockSettingTreeNode[]>([
{
key: 'Comfy',
label: 'Comfy',
children: [
{
key: 'General',
label: 'General',
children: [
{
key: 'Comfy.High',
label: 'High',
leaf: true,
data: { id: 'Comfy.High', name: 'High', sortOrder: 30 }
},
{
key: 'Comfy.Low',
label: 'Low',
leaf: true,
data: { id: 'Comfy.Low', name: 'Low', sortOrder: 10 }
}
]
},
{
key: 'Advanced',
label: 'Advanced',
children: [
{
key: 'Comfy.Advanced',
label: 'Advanced',
leaf: true,
data: { id: 'Comfy.Advanced', name: 'Advanced' }
}
]
}
]
},
{
key: 'Appearance',
label: 'Appearance',
children: [
{
key: 'Palette',
label: 'Palette',
children: [
{
key: 'Appearance.Palette',
label: 'Palette',
leaf: true,
data: {
id: 'Appearance.Palette',
name: 'Palette',
sortOrder: 20
}
}
]
}
]
}
])
const navGroups = ref([
{
title: 'Core',
items: [
{ id: 'Comfy', label: 'Comfy', icon: 'settings' },
{ id: 'Appearance', label: 'Appearance', icon: 'palette' },
{ id: 'keybinding', label: 'Keybinding', icon: 'keyboard' },
{ id: 'credits', label: 'Credits', icon: 'coins' }
]
}
])
const keybindingPanel = {
node: { key: 'keybinding', label: 'Keybinding', children: [] },
component: defineComponent({
name: 'MockKeybindingPanel',
setup: () => () => h('div', { 'data-testid': 'keybinding-panel' }, 'keys')
})
}
mockSettingUI.refs = {
settingCategories,
navGroups
}
return {
useSettingUI: vi.fn((defaultPanel?: string) => ({
defaultCategory: computed(
() =>
settingCategories.value.find((c) => c.key === defaultPanel) ??
settingCategories.value[0]
),
settingCategories,
navGroups,
findCategoryByKey: (key: string) =>
settingCategories.value.find((c) => c.key === key) ?? null,
findPanelByKey: (key: string) =>
key === 'keybinding' ? keybindingPanel : null
}))
}
})
vi.mock('@/platform/settings/composables/useSettingSearch', async () => {
const { computed, ref } = await import('vue')
const searchQuery = ref('')
const inSearch = ref(false)
const searchResultsCategories = ref(new Set<string>())
const matchedNavItemKeys = ref(new Set<string>())
const results = ref<MockSettingGroup[]>([
{
label: 'Search Group',
category: 'Comfy',
settings: [{ id: 'Comfy.SearchResult', name: 'Search Result' }]
}
])
mockSettingSearch.refs = {
searchQuery,
inSearch,
searchResultsCategories,
matchedNavItemKeys,
results
}
mockSettingSearch.handleSearch.mockImplementation(
(query: string, navItems: Array<{ key: string; label: string }> = []) => {
searchQuery.value = query
inSearch.value = query.length > 0
searchResultsCategories.value = query.includes('appearance')
? new Set(['Appearance'])
: new Set()
matchedNavItemKeys.value = new Set(
navItems
.filter((item) => item.label.toLowerCase().includes(query))
.map((item) => item.key)
)
}
)
return {
useSettingSearch: vi.fn(() => ({
searchQuery,
inSearch,
searchResultsCategories: computed(() => searchResultsCategories.value),
matchedNavItemKeys: computed(() => matchedNavItemKeys.value),
handleSearch: mockSettingSearch.handleSearch,
getSearchResults: vi.fn(() => results.value)
}))
}
})
function renderDialog(
props: Partial<InstanceType<typeof SettingDialog>['$props']> = {}
) {
return render(SettingDialog, {
props: {
onClose: vi.fn(),
...props
},
global: {
plugins: [testI18n]
}
})
}
describe('SettingDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchBalance.mockReset()
if (mockSettingSearch.refs) {
mockSettingSearch.refs.searchQuery.value = ''
mockSettingSearch.refs.inSearch.value = false
mockSettingSearch.refs.searchResultsCategories.value = new Set()
mockSettingSearch.refs.matchedNavItemKeys.value = new Set()
}
})
it('renders the default category panel with sorted groups and settings', () => {
renderDialog()
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
expect(screen.getByTestId('settings-panel')).toHaveTextContent('General')
expect(screen.getByTestId('settings-panel')).toHaveTextContent('Advanced')
expect(screen.getByTestId('settings-panel').textContent).toMatch(
/Comfy\.High.*Comfy\.Low/
)
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
'data-active',
'true'
)
})
it('switches category from the nav and fetches credits balance for credits', async () => {
const user = userEvent.setup()
renderDialog()
await user.click(screen.getByRole('button', { name: 'Appearance' }))
expect(screen.getByTestId('color-palette-message')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
'data-active',
'true'
)
await user.click(screen.getByRole('button', { name: 'Credits' }))
await nextTick()
expect(mockFetchBalance).toHaveBeenCalledTimes(1)
})
it('renders panel header slots and disables search autofocus for keybindings', async () => {
const user = userEvent.setup()
renderDialog()
await user.click(screen.getByRole('button', { name: 'Keybinding' }))
await nextTick()
expect(screen.getByTestId('keybinding-panel')).toBeInTheDocument()
expect(screen.getByTestId('header')).not.toBeEmptyDOMElement()
expect(screen.getByTestId('header-actions')).not.toBeEmptyDOMElement()
expect(screen.getByTestId('settings-search')).toHaveAttribute(
'data-autofocus',
'false'
)
})
it('renders search results and activates the first matching nav item', async () => {
const user = userEvent.setup()
renderDialog()
const input = screen.getByTestId('settings-search')
await user.type(input, 'appearance')
await user.tab()
await nextTick()
expect(mockSettingSearch.handleSearch).toHaveBeenCalledWith(
'appearance',
expect.arrayContaining([{ key: 'Appearance', label: 'Appearance' }])
)
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
'Comfy.SearchResult'
)
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
'data-active',
'true'
)
})
it('keeps search mode active when no nav item or category matches', async () => {
const user = userEvent.setup()
renderDialog()
const input = screen.getByTestId('settings-search')
await user.type(input, 'unmatched')
await user.tab()
await nextTick()
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
'Comfy.SearchResult'
)
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
'data-active',
'false'
)
})
it('restores the default category after clearing search', async () => {
const user = userEvent.setup()
renderDialog()
const input = screen.getByTestId('settings-search')
await user.type(input, 'unmatched')
await user.tab()
await nextTick()
await user.clear(input)
await user.tab()
await nextTick()
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
'data-active',
'true'
)
})
it('sorts groups by label when group sort order ties', async () => {
const refs = mockSettingUI.refs
if (!refs) throw new Error('Expected setting UI refs')
const originalCategories = refs.settingCategories.value
const originalNavGroups = refs.navGroups.value
refs.settingCategories.value = [
...originalCategories,
{
key: 'Tie',
label: 'Tie',
children: [
{
key: 'Beta',
label: 'Beta',
children: [
{
key: 'Tie.Beta',
label: 'Beta',
leaf: true,
data: { id: 'Tie.Beta', name: 'Beta', sortOrder: 5 }
}
]
},
{
key: 'Alpha',
label: 'Alpha',
children: [
{
key: 'Tie.Alpha',
label: 'Alpha',
leaf: true,
data: { id: 'Tie.Alpha', name: 'Alpha', sortOrder: 5 }
},
{
key: 'Tie.NoSort',
label: 'NoSort',
leaf: true,
data: { id: 'Tie.NoSort', name: 'NoSort' }
}
]
}
]
}
]
refs.navGroups.value = [
{
title: 'Core',
items: [
...originalNavGroups[0].items,
{ id: 'Tie', label: 'Tie', icon: 'settings' }
]
}
]
try {
renderDialog()
await userEvent.click(screen.getByRole('button', { name: 'Tie' }))
await nextTick()
expect(screen.getByTestId('settings-panel').textContent).toMatch(
/Alpha.*Beta/
)
expect(screen.getByTestId('settings-panel').textContent).toMatch(
/Tie\.Alpha.*Tie\.NoSort/
)
} finally {
refs.settingCategories.value = originalCategories
refs.navGroups.value = originalNavGroups
}
})
it('scrolls to a target setting and removes its highlight after animation', async () => {
const target = document.createElement('div')
target.dataset.settingId = 'Comfy.Target'
const scrollIntoView = vi.fn()
target.scrollIntoView = scrollIntoView
document.body.appendChild(target)
try {
renderDialog({ scrollToSettingId: 'Comfy.Target' })
await nextTick()
await nextTick()
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center'
})
expect(target.classList.contains('setting-highlight')).toBe(true)
target.dispatchEvent(new Event('animationend'))
expect(target.classList.contains('setting-highlight')).toBe(false)
} finally {
target.remove()
}
})
})

View File

@@ -0,0 +1,199 @@
import { nextTick, reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import {
CanvasPointer,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
type SettingValue = boolean | number | string
// The real canvasStore exposes `canvas` via a shallowRef, so the mock must be
// reactive for the composable's watchEffects to re-run when the canvas mounts
// after setup. `vi.hoisted` runs before imports, hence the dynamic import.
const { canvasStore, settings } = await vi.hoisted(async () => {
const { reactive } = await import('vue')
return {
canvasStore: reactive({
canvas: undefined as
| undefined
| {
show_info?: SettingValue
zoom_speed?: SettingValue
auto_pan_speed?: SettingValue
links_render_mode?: SettingValue
min_font_size_for_lod?: SettingValue
linkMarkerShape?: SettingValue
maximumFps?: SettingValue
dragZoomEnabled?: SettingValue
liveSelection?: SettingValue
groupSelectChildren?: SettingValue
draw: ReturnType<typeof vi.fn>
setDirty: ReturnType<typeof vi.fn>
}
}),
settings: {
current: {} as Record<string, SettingValue>
}
}
})
vi.mock('@/lib/litegraph/src/litegraph', () => {
class MockCanvasPointer {
static doubleClickTime = 0
static bufferTime = 0
static maxClickDrift = 0
}
class MockLGraphNode {
static keepAllLinksOnBypass = false
}
return {
CanvasPointer: MockCanvasPointer,
LGraphNode: MockLGraphNode,
LiteGraph: {
Reroute: {},
snaps_for_comfy: false,
snap_highlights_node: false,
middle_click_slot_add_default_node: false,
CANVAS_GRID_SIZE: 0,
alwaysSnapToGrid: false,
context_menu_scaling: 1,
canvasNavigationMode: 'legacy',
macTrackpadGestures: false,
leftMouseClickBehavior: 'select',
mouseWheelScroll: 'zoom',
saveViewportWithGraph: false
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settings.current[key]
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasStore
}))
function makeCanvas() {
return {
draw: vi.fn(),
setDirty: vi.fn()
}
}
beforeEach(() => {
settings.current = reactive({
'Comfy.Graph.CanvasInfo': true,
'Comfy.Graph.ZoomSpeed': 1.25,
'Comfy.Graph.AutoPanSpeed': 0.75,
'Comfy.Node.AutoSnapLinkToSlot': true,
'Comfy.Node.SnapHighlightsNode': true,
'Comfy.Node.BypassAllLinksOnDelete': true,
'Comfy.Node.MiddleClickRerouteNode': true,
'Comfy.LinkRenderMode': 2,
'LiteGraph.Canvas.MinFontSizeForLOD': 9,
'Comfy.Graph.LinkMarkers': 'arrow',
'LiteGraph.Canvas.MaximumFps': 42,
'Comfy.Graph.CtrlShiftZoom': true,
'Comfy.Graph.LiveSelection': true,
'Comfy.Pointer.DoubleClickTime': 250,
'Comfy.Pointer.ClickBufferTime': 80,
'Comfy.Pointer.ClickDrift': 4,
'Comfy.SnapToGrid.GridSize': 16,
'pysssss.SnapToGrid': true,
'LiteGraph.ContextMenu.Scaling': 1.5,
'LiteGraph.Reroute.SplineOffset': 32,
'Comfy.Canvas.NavigationMode': 'standard',
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
'Comfy.Canvas.MouseWheelScroll': 'panning',
'Comfy.EnableWorkflowViewRestore': true,
'LiteGraph.Group.SelectChildrenOnClick': true
})
canvasStore.canvas = reactive(makeCanvas())
})
describe('useLitegraphSettings', () => {
it('applies canvas settings and marks affected layers dirty', () => {
useLitegraphSettings()
expect(canvasStore.canvas?.show_info).toBe(true)
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
expect(canvasStore.canvas?.auto_pan_speed).toBe(0.75)
expect(canvasStore.canvas?.links_render_mode).toBe(2)
expect(canvasStore.canvas?.min_font_size_for_lod).toBe(9)
expect(canvasStore.canvas?.linkMarkerShape).toBe('arrow')
expect(canvasStore.canvas?.maximumFps).toBe(42)
expect(canvasStore.canvas?.dragZoomEnabled).toBe(true)
expect(canvasStore.canvas?.liveSelection).toBe(true)
expect(canvasStore.canvas?.groupSelectChildren).toBe(true)
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(true, true)
})
it('applies global LiteGraph and pointer settings', () => {
useLitegraphSettings()
expect(LiteGraph.snaps_for_comfy).toBe(true)
expect(LiteGraph.snap_highlights_node).toBe(true)
expect(LGraphNode.keepAllLinksOnBypass).toBe(true)
expect(LiteGraph.middle_click_slot_add_default_node).toBe(true)
expect(CanvasPointer.doubleClickTime).toBe(250)
expect(CanvasPointer.bufferTime).toBe(80)
expect(CanvasPointer.maxClickDrift).toBe(4)
expect(LiteGraph.CANVAS_GRID_SIZE).toBe(16)
expect(LiteGraph.alwaysSnapToGrid).toBe(true)
expect(LiteGraph.context_menu_scaling).toBe(1.5)
expect(LiteGraph.Reroute.maxSplineOffset).toBe(32)
expect(LiteGraph.canvasNavigationMode).toBe('standard')
expect(LiteGraph.macTrackpadGestures).toBe(true)
expect(LiteGraph.leftMouseClickBehavior).toBe('panning')
expect(LiteGraph.mouseWheelScroll).toBe('panning')
expect(LiteGraph.saveViewportWithGraph).toBe(true)
})
it('responds when reactive settings change', async () => {
useLitegraphSettings()
settings.current['Comfy.Graph.CanvasInfo'] = false
settings.current['Comfy.Canvas.NavigationMode'] = 'custom'
settings.current['LiteGraph.Group.SelectChildrenOnClick'] = false
await nextTick()
expect(canvasStore.canvas?.show_info).toBe(false)
expect(canvasStore.canvas?.groupSelectChildren).toBe(false)
expect(LiteGraph.canvasNavigationMode).toBe('custom')
expect(LiteGraph.macTrackpadGestures).toBe(false)
})
it('updates global settings when the canvas is not mounted yet', () => {
canvasStore.canvas = undefined
useLitegraphSettings()
expect(LiteGraph.snaps_for_comfy).toBe(true)
expect(CanvasPointer.doubleClickTime).toBe(250)
})
it('applies canvas settings once the canvas mounts after setup', async () => {
canvasStore.canvas = undefined
useLitegraphSettings()
canvasStore.canvas = reactive(makeCanvas())
await nextTick()
expect(canvasStore.canvas?.show_info).toBe(true)
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
expect(canvasStore.canvas?.links_render_mode).toBe(2)
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
})
})

View File

@@ -1,72 +1,80 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import {
getSettingInfo,
useSettingStore
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { useSettingUI } from './useSettingUI'
const env = vi.hoisted(() => {
const state = {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
return state[key]
}
})
return { state, fakeRef }
const testI18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
function runSettingUI(...args: Parameters<typeof useSettingUI>) {
let result!: ReturnType<typeof useSettingUI>
render(
defineComponent({
setup() {
result = useSettingUI(...args)
return {}
},
template: '<div />'
}),
{ global: { plugins: [testI18n] } }
)
return result
}
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
auth: { isLoggedIn: { value: false } },
billing: {
isActiveSubscription: { value: false },
type: { value: 'legacy' as 'legacy' | 'workspace' }
},
dist: { isCloud: false, isDesktop: false },
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
vueFlags: { shouldRenderVueNodes: { value: false } }
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: env.fakeRef('isLoggedIn') })
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: env.fakeRef('isActiveSubscription'),
type: env.fakeRef('billingType')
isActiveSubscription: billing.isActiveSubscription,
type: billing.type
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return env.state.teamWorkspacesEnabled
},
get userSecretsEnabled() {
return env.state.userSecretsEnabled
}
}
flags: featureFlags
})
}))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
useVueFeatureFlags: () => ({
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return env.state.isCloud
return dist.isCloud
},
get isDesktop() {
return env.state.isDesktop
return dist.isDesktop
}
}))
@@ -75,16 +83,10 @@ vi.mock('@/platform/settings/settingStore', () => ({
getSettingInfo: vi.fn()
}))
interface MockSettingParams {
id: string
name: string
type: string
defaultValue: unknown
category?: string[]
}
type SettingFixture = Omit<SettingParams, 'id'> & { id: string }
describe('useSettingUI', () => {
const mockSettings: Record<string, MockSettingParams> = {
const mockSettings: Record<string, SettingFixture> = {
'Comfy.Locale': {
id: 'Comfy.Locale',
name: 'Locale',
@@ -104,23 +106,24 @@ describe('useSettingUI', () => {
defaultValue: 'dark'
}
}
let settingsById: Record<string, SettingFixture>
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
auth.isLoggedIn.value = false
billing.isActiveSubscription.value = false
billing.type.value = 'legacy'
dist.isCloud = false
dist.isDesktop = false
featureFlags.teamWorkspacesEnabled = false
featureFlags.userSecretsEnabled = false
vueFlags.shouldRenderVueNodes.value = false
Object.assign(window, { __CONFIG__: {} })
Object.assign(env.state, {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
})
settingsById = mockSettings
vi.mocked(useSettingStore).mockReturnValue({
settingsById: mockSettings
settingsById
} as ReturnType<typeof useSettingStore>)
vi.mocked(getSettingInfo).mockImplementation((setting) => {
@@ -140,22 +143,22 @@ describe('useSettingUI', () => {
}
it('defaults to first category when no params are given', () => {
const { defaultCategory, settingCategories } = useSettingUI()
const { defaultCategory, settingCategories } = runSettingUI()
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('resolves category from scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
const { defaultCategory, settingCategories } = runSettingUI(
undefined,
'Comfy.Locale'
)
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
expect(comfyCategory).toBeDefined()
expect(defaultCategory.value).toBe(comfyCategory)
expect(defaultCategory.value).toBe(
findCategory(settingCategories.value, 'Comfy')
)
})
it('resolves different category from scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
const { defaultCategory, settingCategories } = runSettingUI(
undefined,
'Appearance.Theme'
)
@@ -163,12 +166,11 @@ describe('useSettingUI', () => {
settingCategories.value,
'Appearance'
)
expect(appearanceCategory).toBeDefined()
expect(defaultCategory.value).toBe(appearanceCategory)
})
it('falls back to first category for unknown scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
const { defaultCategory, settingCategories } = runSettingUI(
undefined,
'NonExistent.Setting'
)
@@ -176,29 +178,210 @@ describe('useSettingUI', () => {
})
it('gives defaultPanel precedence over scrollToSettingId', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
const { defaultCategory } = runSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
it('falls back when defaultPanel is not in the menu', () => {
const { defaultCategory, settingCategories } = runSettingUI('subscription')
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('moves floating settings into Other and hides Vue-node-only settings', () => {
settingsById = {
Floating: {
id: 'Floating',
name: 'Floating',
type: 'boolean',
defaultValue: false
},
'Hidden.Setting': {
id: 'Hidden.Setting',
name: 'Hidden',
type: 'hidden',
defaultValue: false
},
'Vue.Hidden': {
id: 'Vue.Hidden',
name: 'Vue Hidden',
type: 'boolean',
defaultValue: false,
hideInVueNodes: true
}
}
vi.mocked(useSettingStore).mockReturnValue({
settingsById
} as ReturnType<typeof useSettingStore>)
vueFlags.shouldRenderVueNodes.value = true
const { settingCategories } = runSettingUI()
expect(settingCategories.value.map((category) => category.label)).toEqual([
'Other'
])
expect(
settingCategories.value[0].children?.map((node) => node.key)
).toEqual(['root/Floating'])
})
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
auth.isLoggedIn.value = true
billing.isActiveSubscription.value = true
dist.isCloud = true
dist.isDesktop = true
featureFlags.teamWorkspacesEnabled = true
featureFlags.userSecretsEnabled = true
Object.assign(window, { __CONFIG__: { subscription_required: true } })
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
runSettingUI()
expect(panels.value.map((panel) => panel.node.key)).toEqual([
'about',
'credits',
'user',
'workspace',
'keybinding',
'extension',
'server-config',
'subscription',
'secrets'
])
expect(navGroups.value.map((group) => group.title)).toEqual([
'Workspace',
'General'
])
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
expect(findCategoryByKey('missing')).toBeNull()
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
expect(findPanelByKey('missing')).toBeNull()
})
it('builds the legacy account menu from auth and subscription gates', () => {
auth.isLoggedIn.value = true
billing.isActiveSubscription.value = true
dist.isCloud = true
featureFlags.userSecretsEnabled = true
Object.assign(window, { __CONFIG__: { subscription_required: true } })
const { navGroups, panels } = runSettingUI()
expect(panels.value.map((panel) => panel.node.key)).toEqual([
'about',
'credits',
'user',
'keybinding',
'extension',
'subscription',
'secrets'
])
expect(navGroups.value[0]).toEqual({
title: 'Account',
items: [
{
id: 'user',
label: 'User',
icon: 'icon-[lucide--user]'
},
{
id: 'subscription',
label: 'PlanCredits',
icon: 'icon-[lucide--credit-card]'
},
{
id: 'secrets',
label: 'Secrets',
icon: 'icon-[lucide--key-round]'
}
]
})
})
it('includes credits in legacy account settings when login is not subscription-gated', () => {
auth.isLoggedIn.value = true
dist.isCloud = true
const { navGroups } = runSettingUI()
expect(navGroups.value[0].items.map((item) => item.id)).toEqual([
'user',
'credits'
])
})
it('builds workspace menus without optional children when gates are closed', () => {
dist.isCloud = true
featureFlags.teamWorkspacesEnabled = true
const { navGroups, panels } = runSettingUI()
expect(panels.value.map((panel) => panel.node.key)).toEqual([
'about',
'credits',
'user',
'keybinding',
'extension'
])
expect(navGroups.value.map((group) => group.title)).toEqual([
'Workspace',
'General'
])
expect(navGroups.value[0].items).toEqual([])
})
it('uses label and fallback icons for custom categories', () => {
settingsById = {
'Acme.Tools.Toggle': {
id: 'Acme.Tools.Toggle',
name: 'Toggle',
type: 'boolean',
defaultValue: false,
category: ['Acme Tools', 'Toggles']
},
PlanSetting: {
id: 'PlanSetting',
name: 'Plan Setting',
type: 'boolean',
defaultValue: false,
category: ['PlanCredits', 'Credits']
}
}
vi.mocked(useSettingStore).mockReturnValue({
settingsById
} as ReturnType<typeof useSettingStore>)
const { navGroups } = runSettingUI()
const settingsItems = navGroups.value[1].items
expect(settingsItems).toEqual([
{
id: 'root/Acme Tools',
label: 'Acme Tools',
icon: 'icon-[lucide--plug]'
},
{
id: 'root/PlanCredits',
label: 'PlanCredits',
icon: 'icon-[lucide--credit-card]'
}
])
})
describe('legacy billing in the workspace layout', () => {
const navKeys = (groups: { items: { id: string }[] }[]) =>
groups.flatMap((group) => group.items.map((item) => item.id))
beforeEach(() => {
Object.assign(env.state, {
isCloud: true,
isLoggedIn: true,
teamWorkspacesEnabled: true,
isActiveSubscription: true
})
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
auth.isLoggedIn.value = true
billing.isActiveSubscription.value = true
dist.isCloud = true
featureFlags.teamWorkspacesEnabled = true
Object.assign(window, { __CONFIG__: { subscription_required: true } })
})
it('exposes the legacy plan panel when billing is legacy', () => {
env.state.billingType = 'legacy'
const { defaultCategory, navGroups } = useSettingUI('subscription')
billing.type.value = 'legacy'
const { defaultCategory, navGroups } = runSettingUI('subscription')
expect(defaultCategory.value.key).toBe('subscription')
expect(navKeys(navGroups.value)).toContain('subscription')
@@ -206,8 +389,8 @@ describe('useSettingUI', () => {
})
it('hides the legacy plan panel when billing is workspace', () => {
env.state.billingType = 'workspace'
const { navGroups } = useSettingUI()
billing.type.value = 'workspace'
const { navGroups } = runSettingUI()
expect(navKeys(navGroups.value)).not.toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
@@ -215,7 +398,7 @@ describe('useSettingUI', () => {
it('never renders the plan panel in more than one tab', () => {
const countSubscription = () => {
const { navGroups } = useSettingUI()
const { navGroups } = runSettingUI()
return navKeys(navGroups.value).filter((id) => id === 'subscription')
.length
}
@@ -223,11 +406,9 @@ describe('useSettingUI', () => {
for (const teamWorkspacesEnabled of [true, false]) {
for (const billingType of ['legacy', 'workspace'] as const) {
for (const isLoggedIn of [true, false]) {
Object.assign(env.state, {
teamWorkspacesEnabled,
billingType,
isLoggedIn
})
featureFlags.teamWorkspacesEnabled = teamWorkspacesEnabled
billing.type.value = billingType
auth.isLoggedIn.value = isLoggedIn
expect(countSubscription()).toBeLessThanOrEqual(1)
}
}

View File

@@ -0,0 +1,204 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import type { SettingParams } from '@/platform/settings/types'
import type { Keybinding } from '@/platform/keybindings/types'
const mockSettingStore = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
setMany: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
vi.mock('@/locales/localeConfig', () => ({
getDefaultLocale: () => 'en',
SUPPORTED_LOCALE_OPTIONS: [{ value: 'en', text: 'English' }]
}))
function setting<T = unknown>(id: string): SettingParams<T> {
const result = CORE_SETTINGS.find((item) => item.id === id)
if (!result) throw new Error(`Missing setting ${id}`)
return result as SettingParams<T>
}
describe('CORE_SETTINGS', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.className = ''
document.body.innerHTML = ''
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('uses compact sidebar size below the wide breakpoint', () => {
vi.stubGlobal('innerWidth', 1200)
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
expect(typeof defaultValue).toBe('function')
expect((defaultValue as () => string)()).toBe('small')
})
it('uses normal sidebar size above the wide breakpoint', () => {
vi.stubGlobal('innerWidth', 1600)
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
expect((defaultValue as () => string)()).toBe('normal')
})
it('updates dependent canvas settings when navigation mode changes', async () => {
const navigation = setting<string>('Comfy.Canvas.NavigationMode')
await navigation.onChange?.('standard', 'legacy')
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
'Comfy.Canvas.MouseWheelScroll': 'panning'
})
await navigation.onChange?.('legacy', 'standard')
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
'Comfy.Canvas.MouseWheelScroll': 'zoom'
})
})
it('does not update dependent canvas settings on initial navigation setup', async () => {
await setting<string>('Comfy.Canvas.NavigationMode').onChange?.('standard')
expect(mockSettingStore.setMany).not.toHaveBeenCalled()
})
it('keeps preset navigation mode when left-click behavior still matches it', async () => {
mockSettingStore.get.mockReturnValue('standard')
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
'select'
)
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('marks navigation mode custom when left-click behavior diverges from the preset', async () => {
mockSettingStore.get.mockReturnValue('standard')
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
'panning'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode',
'custom'
)
})
it('does not rewrite custom navigation mode from left-click behavior', async () => {
mockSettingStore.get.mockReturnValue('custom')
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
'select'
)
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('keeps preset navigation mode when wheel behavior still matches it', async () => {
mockSettingStore.get.mockReturnValue('legacy')
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('zoom')
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('marks navigation mode custom when wheel behavior diverges from the preset', async () => {
mockSettingStore.get.mockReturnValue('legacy')
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('panning')
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode',
'custom'
)
})
it('toggles the dev-mode API save button when present', () => {
const button = document.createElement('button')
button.id = 'comfy-dev-save-api-button'
document.body.append(button)
const devMode = setting<boolean>('Comfy.DevMode')
devMode.onChange?.(true)
expect(button.style.display).toBe('flex')
devMode.onChange?.(false)
expect(button.style.display).toBe('none')
})
it('ignores the dev-mode button handler when the element is absent', () => {
expect(() =>
setting<boolean>('Comfy.DevMode').onChange?.(true)
).not.toThrow()
})
it('toggles the disabled animations body class', () => {
const animations = setting<boolean>('Comfy.Appearance.DisableAnimations')
animations.onChange?.(true)
expect(document.body.classList.contains('disable-animations')).toBe(true)
animations.onChange?.(false)
expect(document.body.classList.contains('disable-animations')).toBe(false)
})
it('migrates deprecated menu and workflow tab values', () => {
expect(
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Floating')
).toBe('Top')
expect(
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Bottom')
).toBe('Top')
expect(
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Top')
).toBe('Top')
expect(
setting<string>(
'Comfy.Workflow.WorkflowTabsPosition'
).migrateDeprecatedValue?.('Topbar (2nd-row)')
).toBe('Topbar')
})
it('migrates graph-canvas keybinding target selectors', () => {
const bindings = [
{
combo: { key: 'a' },
commandId: 'test.command',
targetSelector: '#graph-canvas'
},
{
combo: { key: 'b' },
commandId: 'other.command',
targetSelector: '#other'
}
]
const migrated =
setting<Keybinding[]>(
'Comfy.Keybinding.UnsetBindings'
).migrateDeprecatedValue?.(bindings) ?? []
expect(migrated[0].targetElementId).toBe('graph-canvas-container')
expect(migrated[1].targetElementId).toBeUndefined()
})
})

View File

@@ -1207,18 +1207,6 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: false
},
{
id: 'Comfy.VueNodes.AutoScaleLayout',
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
name: 'Auto-scale layout (Nodes 2.0)',
tooltip:
'Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap',
type: 'boolean',
sortOrder: 50,
experimental: true,
defaultValue: true,
versionAdded: '1.30.3'
},
{
id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library',

View File

@@ -78,4 +78,43 @@ describe('TelemetryRegistry', () => {
})
).not.toThrow()
})
it('dispatches subscription cancellation telemetry to every registered provider', () => {
const a: TelemetryProvider = { trackSubscriptionCancellation: vi.fn() }
const b: TelemetryProvider = { trackSubscriptionCancellation: vi.fn() }
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const payload = {
source: 'cancel_plan_menu' as const,
current_tier: 'standard',
cycle: 'monthly' as const,
end_date: '2026-08-01T00:00:00.000Z'
}
registry.trackSubscriptionCancellation('flow_opened', payload)
expect(a.trackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
'flow_opened',
payload
)
expect(b.trackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
'flow_opened',
payload
)
})
it('dispatches resubscribe click telemetry to every registered provider', () => {
const a: TelemetryProvider = { trackResubscribeClicked: vi.fn() }
const b: TelemetryProvider = { trackResubscribeClicked: vi.fn() }
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const payload = { source: 'settings_billing_panel' as const }
registry.trackResubscribeClicked(payload)
expect(a.trackResubscribeClicked).toHaveBeenCalledExactlyOnceWith(payload)
expect(b.trackResubscribeClicked).toHaveBeenCalledExactlyOnceWith(payload)
})
})

View File

@@ -19,10 +19,12 @@ import type {
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
ResubscribeClickMetadata,
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionCancellationMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -100,6 +102,19 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
}
trackSubscriptionCancellation(
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
metadata?: SubscriptionCancellationMetadata
): void {
this.dispatch((provider) =>
provider.trackSubscriptionCancellation?.(event, metadata)
)
}
trackResubscribeClicked(metadata: ResubscribeClickMetadata): void {
this.dispatch((provider) => provider.trackResubscribeClicked?.(metadata))
}
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.dispatch((provider) =>
provider.trackAddApiCreditButtonClicked?.(metadata)

View File

@@ -27,6 +27,7 @@ export async function initTelemetry(): Promise<void> {
{ ImpactTelemetryProvider },
{ PostHogTelemetryProvider },
{ ClickHouseTelemetryProvider },
{ SyftTelemetryProvider },
{ CustomerIoTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
@@ -35,6 +36,7 @@ export async function initTelemetry(): Promise<void> {
import('./providers/cloud/ImpactTelemetryProvider'),
import('./providers/cloud/PostHogTelemetryProvider'),
import('./providers/cloud/ClickHouseTelemetryProvider'),
import('./providers/cloud/SyftTelemetryProvider'),
import('./providers/cloud/CustomerIoTelemetryProvider')
])
@@ -44,6 +46,7 @@ export async function initTelemetry(): Promise<void> {
registry.registerProvider(new ImpactTelemetryProvider())
registry.registerProvider(new PostHogTelemetryProvider())
registry.registerProvider(new ClickHouseTelemetryProvider())
registry.registerProvider(new SyftTelemetryProvider())
registry.registerProvider(new CustomerIoTelemetryProvider())
setTelemetryRegistry(registry)

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