Compare commits

..

57 Commits

Author SHA1 Message Date
Simon Pinfold
b017b26499 Merge branch 'synap5e/assets-mode-sidebar' into synap5e/test/assets-sidebar-e2e 2026-07-12 23:03:10 +12:00
Simon Pinfold
84491c3c60 Merge branch 'synap5e/disable-change-model-tag-on-core' into synap5e/assets-mode-sidebar 2026-07-12 23:03:10 +12:00
Simon Pinfold
e8f5617da7 Merge branch 'synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag' into synap5e/disable-change-model-tag-on-core 2026-07-12 23:03:10 +12:00
Simon Pinfold
65f80ecee7 Merge branch 'synap5e/fe-1076-fe-group-the-model-library-by-model_type-tags' into synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag 2026-07-12 23:03:10 +12:00
Simon Pinfold
f6bbaf4b9e docs(assets): correct the multi-root limitation to preview-only reads
/view_metadata takes no path index and searches roots in order, so
metadata only misresolves on relative-path collisions; the root-0
targeting applies to preview URLs and row identity.
2026-07-12 23:02:39 +12:00
Simon Pinfold
1e7d48623a fix(assets): represent every category of a shared multi-type asset
A shared-root asset tagged with several model_type values groups under
each of them, but its badge and a node-typed picker's title keyed off
only the first value, so viewing it under its second category showed the
first category's label. Cards now badge every authoritative model_type
value, and a node-typed picker titles off the requested node's category
instead of guessing from the first asset.
2026-07-12 23:01:25 +12:00
Simon Pinfold
b14353d340 Merge branch 'synap5e/assets-mode-sidebar' into synap5e/test/assets-sidebar-e2e 2026-07-12 22:53:01 +12:00
Simon Pinfold
c8afd4cf8a docs(assets): mark the multi-root pathIndex limitation at its source
Asset records carry no root identity, so the asset-backed sidebar
reports every model as root 0; secondary-root metadata/preview reads
misresolve on multi-root categories. Accepted for this surface, loader
values are unaffected; lifting it needs the backend to carry root
identity on assets.
2026-07-12 22:52:02 +12:00
Simon Pinfold
a8c5476d3f Merge branch 'synap5e/assets-mode-sidebar' into synap5e/test/assets-sidebar-e2e 2026-07-12 21:43:15 +12:00
Simon Pinfold
abfb89b990 Merge branch 'synap5e/disable-change-model-tag-on-core' into synap5e/assets-mode-sidebar 2026-07-12 21:43:14 +12:00
Simon Pinfold
259fd9a62f Merge branch 'synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag' into synap5e/disable-change-model-tag-on-core 2026-07-12 21:43:13 +12:00
Simon Pinfold
724e31d235 Merge branch 'synap5e/fe-1076-fe-group-the-model-library-by-model_type-tags' into synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag 2026-07-12 21:43:13 +12:00
Simon Pinfold
9a7793ccc8 fix(models): close reload races that strand or misorder folder contents
A scan-complete reload landing while the eager load is still in flight
replaced the Loading folder objects but only re-loaded Loaded ones,
stranding the sidebar on uninitialized folders whose original requests
finish into detached objects; Loading folders now count as previously
loaded. Same-folder concurrent refreshes were last-completion-wins, so
an older slow response could overwrite a newer commit; refreshes now
carry a per-folder generation and stale ones discard.
2026-07-12 21:40:05 +12:00
Simon Pinfold
317cc81196 fix(assets): close pagination-termination and title-preservation gaps
Adversarial review follow-ups: an offset-ignoring backend that reorders
its responses could alternate two page orderings and defeat the
repeated-page check forever, so the walk now also remembers every served
page signature and treats a run of no-new-ID full pages as exhausted.
Modal titles for uncovered assets keep their legacy verbatim tag (only
covered assets retitle to their model_type value), restoring the
pre-alignment behavior the batch accidentally changed, and namespace
residue like a malformed empty model_type: can no longer leak into
titles, nav items, or categories.
2026-07-12 21:34:25 +12:00
Simon Pinfold
3a73d41d73 Merge branch 'synap5e/assets-mode-sidebar' into synap5e/test/assets-sidebar-e2e 2026-07-12 21:06:03 +12:00
Simon Pinfold
64740d9d4a Merge branch 'synap5e/disable-change-model-tag-on-core' into synap5e/assets-mode-sidebar 2026-07-12 21:05:25 +12:00
Simon Pinfold
9788ed2439 Merge branch 'synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag' into synap5e/disable-change-model-tag-on-core 2026-07-12 21:05:00 +12:00
Simon Pinfold
7b841548ce refactor(assets): require modelTypeMode on the write-path helpers
Matches the read-path helpers: new call sites must choose the tag mode
explicitly instead of silently inheriting legacy behavior.
2026-07-12 21:04:01 +12:00
Simon Pinfold
fcb7d838ef Merge branch 'synap5e/fe-1076-fe-group-the-model-library-by-model_type-tags' into synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag
# Conflicts:
#	src/platform/assets/utils/assetMetadataUtils.test.ts
#	src/platform/assets/utils/assetMetadataUtils.ts
2026-07-12 21:01:01 +12:00
Simon Pinfold
873a85e59e fix(assets): surface refresh errors and harden pagination races
A refresh that fails before its first batch commits now mirrors the
error onto the committed state consumers actually read, instead of
dying silently on the discarded pending state. Pagination stops only
when a full page repeats the previous page verbatim (ignored-offset
signature); an all-duplicate page whose content merely shifted keeps
walking so later pages still load. Teardown of a stale request can no
longer evict a newer request's single-flight entries after an
interleaved invalidation.
2026-07-12 20:57:50 +12:00
Simon Pinfold
ca33752569 fix(assets): align badge and loader resolution with model_type tags
In modelTypeMode the card badge and modal title now key off the same
model_type: value the asset groups under, so they can no longer diverge
from the grouping (review consensus on FE-1076). Loader-node resolution
demotes bare tags unrelated to any model_type: value to trailing
fallbacks: they can no longer pre-empt an authoritative resolvable
model_type: however deep they are, while related hierarchical twins
(LLM/Qwen-VL over flat model_type:LLM) keep winning.

Also: getAssetModelType can no longer return a raw model_type: literal
(kills the prefixed round-trip through edit widgets), modelTypeMode is
now a required parameter so new call sites must choose explicitly,
malformed empty model_type: tags produce no badge, per-asset flag reads
hoisted, and a test pins the local reserved-tag mirrors to
assetService's canonical values.
2026-07-12 20:57:13 +12:00
Simon Pinfold
033ef6069c test(assets): tighten seed-payload and ghost-arming assertions
The seed test now asserts the request body ({ roots: ['models'] }), not
just the endpoint. The placement test anchors its zero-node check on the
visible ghost preview, so it verifies arming completed without placing
rather than racing a first-poll pass.
2026-07-10 23:37:58 +12:00
Simon Pinfold
1b44590a87 test(assets): harden e2e suite against review findings
The active-search scan test now proves the eager load and debounced
search pipeline settled (via a hit on existing data) before the mock
changes, so it can no longer false-pass off a still-pending load. The
bare-tag test forces supports_model_type_tags off instead of inheriting
whatever the test backend advertises, which would have broken once CI's
backend gained the capability. The seed mock response is typed as
SeedAssetsResponse per the typed-mock rule.
2026-07-10 22:45:26 +12:00
Simon Pinfold
561b519944 test(assets): e2e coverage for the assets-mode model library sidebar
Covers the integration seams the unit suites mock away: settings-driven
tab routing (asset browser modal vs sidebar tree, including the cloud
defaults), the eager-loaded asset-mode tree rendered from a mocked walk
with registration-order folders and extension display filtering, refresh
seeding a backend rescan with live tree/search updates on the
assets.seed.fast_complete websocket event, and ghost placement filling
the loader widget with the category-relative loader_path.

All assets endpoints are mocked so the suite does not depend on the CI
container backend having the assets API. AssetHelper's route pattern is
narrowed to **/api/assets** so navigating after mock() no longer
intercepts the built frontend's /assets/*.js chunks.
2026-07-10 18:29:51 +12:00
Simon Pinfold
954f935d33 test: unbreak typecheck:browser with main's branded LinkId fix
Mirrors 9e5fb67b76 verbatim so the stack rebase merges cleanly.
2026-07-10 18:29:20 +12:00
Simon Pinfold
e6916bb665 chore: correct an overclaiming comment on the model-library routing 2026-07-10 16:34:25 +12:00
Simon Pinfold
fdc4651934 fix(assets): close eager-load and folder-refresh races in the model store
Second-pass review: loadModels now loads the folder structure first when it
has not arrived yet (eager-loading could race app boot and silently load
nothing), and refreshModelFolder no longer commits a stale folder object
over a structure rebuilt by a concurrent reload. The store also adopts the
assetService-owned scan subscription (with scope-disposal cleanup) and
requestModelScan handles its own failure via async try/catch.
2026-07-10 16:33:29 +12:00
Simon Pinfold
62123c4c0d refactor(assets): own the scan-complete subscription in assetService
Second-pass review: the wire-level assets.seed.fast_complete event is now
owned by assetService, which exposes an onModelsScanned callback returning
an unsubscribe, and the bucket memoizer uses async/try-finally over a
then/finally chain.
2026-07-10 16:32:22 +12:00
Simon Pinfold
1cb04bef92 chore(assets): drop the scan-discovery console log
Review feedback: the sidebar visibly updating is signal enough, and this
removes the only no-console suppression in src.
2026-07-10 14:45:47 +12:00
Simon Pinfold
f5e221b955 chore(assets): drop a comment restating behavior pinned by a test 2026-07-10 14:42:56 +12:00
Simon Pinfold
fd1f2726a5 fix(settings): open the asset browser only when the asset API is enabled
Review feedback: rather than prompt-correcting the invalid combination, the
browser routing now derives from UseAssetBrowser AND UseAssetAPI — the asset
browser cannot function without the asset API, so with the API disabled the
model library tab always opens the sidebar tree.
2026-07-10 14:42:04 +12:00
Simon Pinfold
8d51d933fc refactor(assets): resolve model preview urls in the model store
Review feedback: url construction is domain logic that belongs below the
component layer; ModelTreeLeaf now consumes a pure getModelPreviewUrl.
2026-07-10 14:41:47 +12:00
Simon Pinfold
be564b232b Merge remote-tracking branch 'origin/main' into synap5e/fe-1076-fe-group-the-model-library-by-model_type-tags
# Conflicts:
#	src/composables/useFeatureFlags.ts
2026-07-10 14:07:58 +12:00
Simon Pinfold
b62405a6ca fix(assets): group slashed bare tags by their top-level segment
Bare-tag backends can carry hierarchical tags (e.g. Chatterbox/sub/model);
the asset browser has always grouped those under the top-level segment, and
the sidebar's folder names come from /experiment/models, so bucketing under
the full slashed tag made such models unreachable. Match the browser's
grouping. On main's server-side per-folder query these assets were equally
invisible, so this is a fix over both.
2026-07-07 11:12:02 +12:00
Simon Pinfold
33c1806673 fix(assets): drop assets with traversal-shaped loader paths
The loader value flows into viewMetadata URLs and widget values; validate
it against the existing filename schema at bucket time so a '../' path
cannot pass through even if backend validation regresses.
2026-07-07 10:51:42 +12:00
Simon Pinfold
9342caad0a fix(assets): harden concurrent folder reloads
Guard loadModelFolders with a request id so a slow stale response cannot
overwrite a fresher folder structure when a manual refresh races the
scan-complete reload, and log instead of leaking an unhandled rejection
when the post-scan background reload fails.
2026-07-07 10:51:20 +12:00
Simon Pinfold
48aed9e0d9 feat(assets): filter model library folders by their registered extensions
/experiment/models returns each folder's raw registered allowlist; an
empty array means match-all. The asset walk filters folder contents by the
registered set verbatim, substituting an FE-owned copy of core's default
supported_pt_extensions when the allowlist is empty or the field is absent
(older backends). This reproduces the legacy sidebar's global-set display
behavior for match-all folders and extensions-less backends — nothing that
was hidden starts showing — while registered allowlists let exotic folders
(e.g. .gguf) surface their actual models. The legacy listing keeps its
historical server-side filter untouched.
2026-07-07 09:14:02 +12:00
Simon Pinfold
dd3dceeaca fix(assets): keep active search results live across model reloads
Search results were a snapshot filled inside handleSearch, so a reload
(e.g. a completed scan discovering new files) updated the store without the
filtered tree ever re-running. Filter via a computed over the store instead,
and expand results from a watch so late-arriving matches unfold too.
2026-07-07 09:14:02 +12:00
Simon Pinfold
3605fc1d75 feat(assets): eager-load the model library in asset mode
In asset mode every folder resolves from one cached walk, so load all
folders on sidebar mount: search and badge counts are complete from the
start and the load-all button becomes redundant (hidden; it remains for the
request-per-folder legacy path, where AutoLoadAll stays the opt-in). Log
when a scan's fast phase discovered new files, since the sidebar now
updates itself and the console note is the only trace of why.
2026-07-07 09:14:02 +12:00
Simon Pinfold
f97195e392 feat(assets): rescan model roots on refresh and live-update the sidebar
Manual refresh now kicks off POST /assets/seed (fire-and-forget, 409
tolerated, skipped on Cloud and on the legacy listing path) alongside the
immediate re-load of known server state. The scan's fast phase already
persists the category tags and loader filenames the sidebar needs, and the
backend announces it with the assets.seed.fast_complete websocket event, so
the store re-loads previously loaded folders on that event instead of
polling /assets/seed/status (which cannot distinguish the fast phase from
enrichment).
2026-07-07 09:14:02 +12:00
Simon Pinfold
0488d0d8a4 feat(assets): back the model library sidebar with the asset API
Derive folders from model_type: tags and use loader_path for the tree,
widget value, and viewMetadata. Gate local-disk metadata/preview reads
on !isCloud.

The models tag is walked once and bucketed per category (multi-category
assets land in each), memoized behind a request id so an in-flight walk
cannot repopulate an invalidated cache. Assets with a null loader_path
are unloadable (orphans) and are dropped with a warning rather than
minting a widget value from the deprecated name field; bare-tag backends
that predate loader_path keep the name fallback.
2026-07-07 09:14:02 +12:00
Simon Pinfold
aea28a06de feat(assets): gate model library asset view behind a runtime setting (FE-1142)
Add a hidden, experimental Comfy.Assets.ModelLibraryAssetView setting that
controls whether the model library opens the asset browser or the sidebar,
defaulting to the asset browser on Cloud and the sidebar on Core. The
model-library toggle now reads this setting instead of Comfy.Assets.UseAssetAPI,
decoupling the sidebar-vs-asset-browser view from Asset API enablement so the
asset-backed view stays reachable on Core for testing without forcing the
cutover.
2026-07-07 09:14:02 +12:00
Simon Pinfold
5847b3d148 fix(dialog): keep non-modal dialogs open when focus lands on a toast
Toasts teleport to body but were missing from the reka outside-layer
allowlist, so a non-modal dialog (e.g. Settings) dismissed itself when
focus recovery landed on a toast's close button. This happens whenever an
action button disables itself while its request is in flight and the
request fails: the browser drops focus from the disabled button and it can
resurface on the error toast. Latent on main; deterministic once the
sidebar rework shifted chunk timing enough for the loading state to render
before the failure response.
2026-07-07 09:14:02 +12:00
Simon Pinfold
187239712c fix(assets): make model type read-only on core
Retagging a model rewrites its asset tags, but core is filesystem-backed
and does not yet move the file to match the new type, so an edit would
leave the tag and the on-disk location diverged. Gate the model type
dropdown to cloud builds; on core the field renders the existing
read-only label. Revert once core implements move-on-retag (or the asset
system no longer requires the file move).
2026-07-02 14:08:09 +12:00
Simon Pinfold
e53b7b4ba8 fix(assets): recognize model_type: prefix in import type-mismatch guard
The required-type guard and imported-type label compared the bare folder_name
against raw asset tags, so a namespaced model_type:<folder> tag was treated as a
type mismatch (and leaked the raw prefix into the mismatch label). Strip the
model_type: prefix before comparing/labelling so a namespaced tag satisfies the
required type. Not reachable in production (the dual-write keeps the bare twin),
but hardens the import path against a namespaced-only asset.
2026-07-01 14:20:37 +12:00
Simon Pinfold
ab56cf9e82 test(assets): pin import write keeps user_metadata.model_type bare
Assert the import-by-URL path namespaces only the tag (model_type:<folder_name>)
while user_metadata.model_type stays the bare folder_name, since that value
still feeds download tracking, category refresh, and the progress label.
2026-07-01 14:20:37 +12:00
Simon Pinfold
f772fc0123 feat(assets): write model_type: tag from discovery
Prefix the discovery folder_name with model_type: on model import and on
model-type edits, gated on the supports_model_type_tags backend capability.
Default off writes the legacy bare directory-path tag unchanged, so this is a
no-op until the backend declares the cutover; the flag is the same authoritative
signal FE-1076 grouping reads.

The FE writes only the single selected folder_name's model_type: form; the
backend keeps the bare directory-path twin in sync (and forward-expands shared
paths), so the edit path manages just the model_type: tags and the resolver read
path is untouched. Mirrors the read side: an uncovered asset with no model_type:
tag still routes by its bare tag.
2026-07-01 14:20:37 +12:00
Simon Pinfold
a5f4559df8 fix(assets): halt model batch loop when backend ignores offset
The batch loop only stopped on a short page, so a backend that ignores
offset and repeats a full page would refetch forever. Now runs on non-cloud
builds too. Stop when a full page contributes no new IDs.
2026-07-01 14:07:54 +12:00
Simon Pinfold
6103dd164e fix(assets): prefer resolvable model_type over incidental slash tags
An incidental hierarchical tag (e.g. a user-added foo/bar) could outrank
an authoritative model_type:vae in node-category resolution, targeting the
wrong provider or failing with NO_PROVIDER. Resolution now tries category
candidates most-specific-first and takes the first that maps to a provider,
so the deepest resolvable path still wins but unresolvable tags fall through.
2026-07-01 14:07:37 +12:00
Simon Pinfold
f7d672e3a5 test(assets): restore mocks after each resolveModelNodeFromAsset test
Add afterEach(vi.restoreAllMocks) so the console.error spy doesn't persist
across tests (CodeRabbit nit).
2026-07-01 09:35:05 +12:00
Simon Pinfold
6f0eaefe1b fix(assets): keep reserved-tag constants local in assetMetadataUtils
Importing MODELS_TAG/MISSING_TAG from assetService made this leaf util pull
the assetService -> i18n (createI18n) chain. Test suites that import
assetMetadataUtils (e.g. useAssetFilterOptions, AssetsSidebarListView) with a
minimal vue-i18n mock then failed to load. Define the two reserved-tag
literals locally so the util has no heavy transitive dependency.
2026-07-01 09:35:05 +12:00
Simon Pinfold
55ceec0a16 fix(assets): port model_type node-category resolution to resolveModelNodeFromAsset
main (#12765) renamed createModelNodeFromAsset -> resolveModelNodeFromAsset.
Re-apply FE-1076's flag-gated node-category resolution there: use
getAssetNodeCategory(asset, flags.supportsModelTypeTags) instead of the inline
first-non-reserved-tag lookup, so a model_type:* tag resolves the loader when
the supports_model_type_tags flag is on.
2026-07-01 09:35:05 +12:00
Simon Pinfold
5ac1b15266 fix(assets): strip model_type: prefix from the asset browser title
Also realigns the card type badge to the same rule: keep the existing
"first non-models tag" selection and only strip the model_type: namespace
prefix, rather than reordering to prefer the model_type: value. So an asset
like [models, foo, model_type:bar] still shows foo, while
[models, model_type:checkpoints] shows checkpoints. Title (primaryCategoryTag)
strips the same prefix when the flag is on.
2026-07-01 09:35:05 +12:00
Simon Pinfold
df826415ca fix(assets): strip model_type: prefix from asset card type badge
The card type badge derived from the first raw non-models tag, so in
model_type mode it rendered the literal `model_type:checkpoints`. Route it
through a model_type-aware getAssetTypeBadge and key the display cache on the
flag so a late-arriving feature flag re-derives the badge. Also adds
docstrings to the tag helpers to satisfy coverage.
2026-07-01 09:35:05 +12:00
Simon Pinfold
ec25e874a2 refactor(assets): run model-asset store on non-cloud builds
Unwrap the if (isCloud) gate in assetsStore getModelState so the asset-mode
Model Library works on non-cloud distributions (it previously returned an
empty no-op store off-cloud, leaving the library blank even with the asset
API enabled). Scoped to the model-asset store only; the rest of the isCloud
forks are out of scope here.
2026-07-01 09:35:05 +12:00
Simon Pinfold
c8e4029f96 refactor(assets): gate model_type grouping on supports_model_type_tags
Drive the model_type: grouping/resolution off the backend-reported
supports_model_type_tags capability (getServerFeature) instead of sniffing
tags per asset. Default false preserves the legacy bare-tag grouping, so a
backend that does not declare the capability is unchanged. When the flag is
true, assets group by their model_type: values and an uncovered asset (no
model_type: tag) still routes by its bare tags.

The thread-through is a modelTypeMode flag on getAssetCategories /
getAssetNodeCategory / filterByCategory (keeping them pure); call sites read
the capability via useFeatureFlags.
2026-07-01 09:35:05 +12:00
Simon Pinfold
5ebeb580ca feat(assets): group model library by model_type tags
Group the asset-mode Model Library by namespaced model_type:* tags. When an
asset carries model_type:* tags, the stripped values are the authoritative
categories and all other tags are disregarded; otherwise fall back to the
legacy bare-tag top-level grouping. A shared getAssetCategories helper keeps
the "By type" nav and category filtering in agreement.

Resolve node-provider lookup from the same tags via getAssetNodeCategory:
prefer the most specific (deepest parent/child) candidate so a flat
model_type:LLM does not shadow a resolvable hierarchical tag, and strip the
model_type: prefix before the modelNodeMappings lookup.
2026-07-01 09:34:29 +12:00
383 changed files with 6530 additions and 21465 deletions

View File

@@ -38,11 +38,9 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -278,49 +278,32 @@ jobs:
continue
fi
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
git push --force-with-lease origin "${BACKPORT_BRANCH}"
else
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
git push origin "${BACKPORT_BRANCH}"
fi
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main || git checkout -f main
git checkout main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort || true
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
git checkout main
git branch -D "${BACKPORT_BRANCH}"
fi
echo "::endgroup::"
@@ -401,10 +384,6 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -437,37 +416,19 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
elif [ "${reason}" = "already-exists" ]; then
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -483,10 +444,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -23,10 +23,6 @@ on:
required: false
default: 'Comfy-Org/ComfyUI'
type: string
target_branch:
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
required: false
type: string
jobs:
check-release-week:
@@ -53,15 +49,12 @@ jobs:
fi
- name: Summary
env:
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -110,7 +103,6 @@ jobs:
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers - Comfy')
await expect(page).toHaveTitle('Careers Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 - Comfy')
await expect(page).toHaveTitle('招聘 Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud - supported by default'
'Custom-node packs on Comfy Cloud supported by default'
)
})

View File

@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
await expect(page).toHaveTitle('Comfy Cloud AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {

View File

@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop - Run AI on Your Hardware'
'Download Comfy Desktop Run AI on Your Hardware'
)
})

View File

@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
await expect(page).toHaveTitle('Comfy Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {

View File

@@ -13,7 +13,7 @@ test.describe('Learning page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning - Comfy')
await expect(page).toHaveTitle('Learning Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
@@ -137,7 +137,7 @@ test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 - Comfy')
await expect(page).toHaveTitle('学习 Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)

View File

@@ -31,26 +31,6 @@ test.describe('Desktop navigation @smoke', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
@@ -137,27 +117,6 @@ test.describe('Mobile menu @mobile', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
for (const label of ['Products', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {

View File

@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful - Comfy')
await expect(page).toHaveTitle('Payment Successful Comfy')
await expectNoIndex(page)
})
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed - Comfy')
await expect(page).toHaveTitle('Payment Failed Comfy')
await expectNoIndex(page)
})
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 - Comfy')
await expect(page).toHaveTitle('支付成功 Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 - Comfy')
await expect(page).toHaveTitle('支付失败 Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 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: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -16,7 +16,6 @@ import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
import NewBadge from './NewBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
@@ -43,10 +42,7 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
<span class="inline-flex items-center gap-1">
<span class="ppformula-text-center">{{ navItem.label }}</span>
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
</span>
{{ navItem.label }}
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">

View File

@@ -8,7 +8,6 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
@@ -97,8 +96,7 @@ onUnmounted(() => {
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
{{ item.label }}
<template #append>
<ChevronRight class="size-7" />
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import { ArrowUpRight } from '@lucide/vue'
import type { NavColumnItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NewBadge from './NewBadge.vue'
import { t } from '../../../i18n/translations'
defineProps<{ item: NavColumnItem; locale: Locale }>()
</script>
@@ -11,7 +12,9 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
<Badge v-if="item.badge" size="xs" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import type { BadgeVariants } from '@/components/ui/badge'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
</script>
<template>
<Badge :size="size" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
</template>

View File

@@ -6,15 +6,14 @@ export const badgeVariants = cva({
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]',
xxs: 'px-1.5 py-px text-[8px]'
xs: 'px-2 py-0.5 text-[9px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
}
},
defaultVariants: {

View File

@@ -30,23 +30,15 @@ export type NavItem =
label: string
columns: NavColumn[]
featured?: NavFeatured
badge?: 'new'
href?: never
}
| {
label: string
href: string
badge?: 'new'
columns?: never
featured?: never
}
| { label: string; href: string; columns?: never; featured?: never }
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
@@ -103,7 +95,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),

View File

@@ -823,7 +823,7 @@ const translations = {
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud - supported by default',
en: 'Custom-node packs on Comfy Cloud supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
@@ -1845,8 +1845,8 @@ const translations = {
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP - Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI'
en: 'Comfy MCP Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
@@ -3483,8 +3483,8 @@ const translations = {
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms - Comfy',
'zh-CN': 'Affiliate Terms - Comfy'
en: 'Affiliate Terms Comfy',
'zh-CN': 'Affiliate Terms Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
@@ -3896,8 +3896,8 @@ const translations = {
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA - Comfy',
'zh-CN': 'Enterprise MSA - Comfy'
en: 'Enterprise MSA Comfy',
'zh-CN': 'Enterprise MSA Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
@@ -4361,8 +4361,8 @@ const translations = {
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
en: 'Comfy.org Affiliate Program - Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴'
en: 'Comfy.org Affiliate Program Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 成为合作伙伴'
},
'affiliate.page.description': {
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
@@ -4387,8 +4387,8 @@ const translations = {
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播'
en: 'ComfyUI Live Demo & Q&A June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
title="404 - Page Not Found - Comfy"
title="404 Page Not Found Comfy"
noindex>
<div
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">

View File

@@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="About Us - Comfy"
title="About Us Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -42,7 +42,7 @@ const roles = itemListNode(
---
<BaseLayout
title="Careers - Comfy"
title="Careers Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Case Studies - Comfy">
<BaseLayout title="Case Studies Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud - AI in the Cloud"
title="Comfy Cloud AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>

View File

@@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="Pricing - Comfy Cloud"
title="Pricing Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },

View File

@@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="Contact - Comfy"
title="Contact Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories'
const stories = (await loadStories('en')).map(toCardProps)
---
<BaseLayout title="Customer Stories - Comfy">
<BaseLayout title="Customer Stories Comfy">
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} - Comfy`}>
<BaseLayout title={`${entry.data.title} Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -58,7 +58,7 @@ const learningResource: JsonLdNode = {
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI.">
<BaseLayout title="Demos Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="Download Comfy Desktop - Run AI on Your Hardware"
title="Download Comfy Desktop Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[

View File

@@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
---
<BaseLayout title="Gallery - Comfy">
<BaseLayout title="Gallery Comfy">
<HeroSection />
<GallerySection client:load />
<ContactSection />

View File

@@ -24,7 +24,7 @@ const { siteUrl } = pageContext(
---
<BaseLayout
title="Comfy - Professional Control of Visual AI"
title="Comfy Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[

View File

@@ -12,7 +12,7 @@ import { externalLinks } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="Learning - Comfy">
<BaseLayout title="Learning Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
---
<BaseLayout
title="Models - Comfy"
title="Models Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection

View File

@@ -103,7 +103,7 @@ const faqPage: JsonLdNode = {
---
<BaseLayout
title={`${pageTitle} - Comfy`}
title={`${pageTitle} Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}

View File

@@ -43,7 +43,7 @@ const dirLabel: Record<string, string> = {
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Failed - Comfy"
title="Payment Failed Comfy"
description="Your payment was not completed."
noindex
>

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Successful - Comfy"
title="Payment Successful Comfy"
description="Your payment was processed successfully."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../components/legal/HeroSection.vue'
---
<BaseLayout
title="Privacy Policy - Comfy"
title="Privacy Policy Comfy"
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop Privacy Policy - Comfy"
title="Desktop Privacy Policy Comfy"
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
>
<HeroSection title="Desktop Privacy Policy" />

View File

@@ -6,7 +6,7 @@ import { t } from '../i18n/translations'
---
<BaseLayout
title="Terms of Service - Comfy"
title="Terms of Service Comfy"
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
noindex
>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Videos - Comfy">
<BaseLayout title="Videos Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="关于我们 - Comfy"
title="关于我们 Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}

View File

@@ -42,7 +42,7 @@ const roles = itemListNode(
---
<BaseLayout
title="招聘 - Comfy"
title="招聘 Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="案例研究 - Comfy">
<BaseLayout title="案例研究 Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud - 云端 AI"
title="Comfy Cloud 云端 AI"
description={t('cloud.hero.subtitle', 'zh-CN')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
>

View File

@@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="定价 - Comfy Cloud"
title="定价 Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{

View File

@@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="联系我们 - Comfy"
title="联系我们 Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../../utils/loadStories'
const stories = (await loadStories('zh-CN')).map(toCardProps)
---
<BaseLayout title="客户故事 - Comfy">
<BaseLayout title="客户故事 Comfy">
<HeroSection locale="zh-CN" client:load />
<StorySection stories={stories} locale="zh-CN" />
<FeedbackSection locale="zh-CN" client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} - Comfy`}>
<BaseLayout title={`${entry.data.title} Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -58,7 +58,7 @@ const learningResource: JsonLdNode = {
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
---
<BaseLayout title="演示 - Comfy" description="ComfyUI 的互动演示和教程。">
<BaseLayout title="演示 Comfy" description="ComfyUI 的互动演示和教程。">
<section class="flex min-h-[60vh] items-center justify-center px-6">
<div class="text-center">
<h1 class="text-primary-comfy-canvas text-4xl font-light">

View File

@@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="下载 Comfy 桌面版 - 在您的硬件上运行 AI"
title="下载 Comfy 桌面版 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[

View File

@@ -5,7 +5,7 @@ import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
---
<BaseLayout title="作品集 - Comfy">
<BaseLayout title="作品集 Comfy">
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />

View File

@@ -24,7 +24,7 @@ const { siteUrl } = pageContext(
---
<BaseLayout
title="Comfy - 视觉 AI 的最强可控性"
title="Comfy 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}

View File

@@ -11,7 +11,7 @@ import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 - Comfy">
<BaseLayout title="学习 Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
---
<BaseLayout
title="模型 - Comfy"
title="模型 Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付失败 - Comfy" description="您的支付未能完成。" noindex>
<BaseLayout title="支付失败 Comfy" description="您的支付未能完成。" noindex>
<PaymentStatusSection status="failed" locale="zh-CN" />
</BaseLayout>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付成功 - Comfy" description="您的支付已成功完成。" noindex>
<BaseLayout title="支付成功 Comfy" description="您的支付已成功完成。" noindex>
<PaymentStatusSection status="success" locale="zh-CN" />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="隐私政策 - Comfy"
title="隐私政策 Comfy"
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop 隐私政策 - Comfy"
title="Desktop 隐私政策 Comfy"
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
>
<HeroSection title="Desktop 隐私政策" />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="视频 - Comfy">
<BaseLayout title="视频 Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -61,12 +61,7 @@ const { drop, locale } = defineProps<{
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
</div>
<Badge
v-if="drop.badge"
size="xs"
variant="accent"
class="absolute top-6 left-8"
>
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
{{ drop.badge[locale] }}
</Badge>
</CardContent>

View File

@@ -28,7 +28,6 @@ import {
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
SidebarTab,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
@@ -71,7 +70,6 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _appsTab: SidebarTab | null = null
private _assetsTab: AssetsSidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
@@ -106,11 +104,6 @@ class ComfyMenu {
return this._nodeLibraryTabV2
}
get appsTab() {
this._appsTab ??= new SidebarTab(this.page, 'apps')
return this._appsTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab

View File

@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
export class SidebarTab {
class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator
@@ -254,6 +254,18 @@ export class ModelLibrarySidebarTab extends SidebarTab {
.filter({ hasText: label })
.first()
}
/**
* A folder's own row (not the whole subtree). Required for nested folders:
* an ancestor `.p-tree-node`'s text contains its descendants' labels, so
* `getFolderByLabel` would match — and click — the ancestor instead.
*/
getFolderRowByLabel(label: string) {
return this.modelTree
.locator('.p-tree-node:not(.p-tree-node-leaf) > .p-tree-node-content')
.filter({ hasText: label })
.first()
}
}
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
@@ -322,9 +334,6 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Panel chrome ---
public readonly panelHeader: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
@@ -361,7 +370,6 @@ export class AssetsSidebarTab extends SidebarTab {
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')
this.panelHeader = page.locator('.comfy-vue-side-bar-header')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)

View File

@@ -1,40 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The graph/app view-mode toggle and its workflow actions dropdown.
* A separate instance mounts in each host - the subgraph breadcrumb (graph
* mode) and the app-mode center panel - and unmounts as the mode flips.
*/
export class WorkflowActionsDropdown {
/** The segmented graph/app toggle hosting the workflow actions trigger. */
public readonly viewModeToggle: Locator
/** The active segment; opens the workflow actions menu. */
public readonly trigger: Locator
/** The inactive segment that switches into app mode. */
public readonly enterAppModeSegment: Locator
/** The inactive segment that switches back to the node graph. */
public readonly enterGraphSegment: Locator
/** The workflow actions dropdown menu. */
public readonly menu: Locator
constructor(page: Page) {
this.viewModeToggle = page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
this.trigger = this.triggerIn(this.viewModeToggle)
this.enterAppModeSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter app mode'
})
this.enterGraphSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter node graph'
})
this.menu = page.getByRole('menu', { name: 'Workflow actions' })
}
/** The trigger as rendered inside a specific mode's host. */
triggerIn(host: Locator): Locator {
return host.getByRole('button', { name: 'Workflow actions' })
}
}

View File

@@ -1,4 +1,13 @@
import type { Asset } from '@comfyorg/ingest-types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Core-native asset shape: the ingest Asset plus the `loader_path` contract
* field that `supports_model_type_tags` backends emit (see
* `src/platform/assets/schemas/assetSchema.ts`).
*/
export type CoreModelAsset = Asset & Pick<AssetItem, 'loader_path'>
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
@@ -89,6 +98,70 @@ export const STABLE_LORA: Asset = createModelAsset({
updated_at: '2025-02-20T14:00:00Z'
})
function createCoreModelAsset(
overrides: Partial<CoreModelAsset>
): CoreModelAsset {
return { ...createModelAsset(), ...overrides }
}
export const MODEL_TYPE_CHECKPOINT_NESTED: CoreModelAsset =
createCoreModelAsset({
id: 'mt-checkpoint-001',
name: 'sd_xl_base_1.0.safetensors',
tags: ['models', 'model_type:checkpoints'],
loader_path: 'SDXL/sd_xl_base_1.0.safetensors',
created_at: '2025-01-15T10:30:00Z',
updated_at: '2025-01-15T10:30:00Z'
})
export const MODEL_TYPE_CHECKPOINT_ROOT: CoreModelAsset = createCoreModelAsset({
id: 'mt-checkpoint-002',
name: 'v1-5-pruned-emaonly.safetensors',
tags: ['models', 'model_type:checkpoints'],
loader_path: 'v1-5-pruned-emaonly.safetensors',
created_at: '2025-01-20T08:00:00Z',
updated_at: '2025-01-20T08:00:00Z'
})
export const MODEL_TYPE_CHECKPOINT_GGUF: CoreModelAsset = createCoreModelAsset({
id: 'mt-checkpoint-003',
name: 'flux_quantized.gguf',
tags: ['models', 'model_type:checkpoints'],
loader_path: 'flux_quantized.gguf',
created_at: '2025-02-01T09:00:00Z',
updated_at: '2025-02-01T09:00:00Z'
})
export const MODEL_TYPE_CHECKPOINT_SCANNED: CoreModelAsset =
createCoreModelAsset({
id: 'mt-checkpoint-004',
name: 'freshly_scanned.safetensors',
tags: ['models', 'model_type:checkpoints'],
loader_path: 'freshly_scanned.safetensors',
created_at: '2025-02-10T09:00:00Z',
updated_at: '2025-02-10T09:00:00Z'
})
export const MODEL_TYPE_LORA: CoreModelAsset = createCoreModelAsset({
id: 'mt-lora-001',
name: 'detail_enhancer_v1.2.safetensors',
tags: ['models', 'model_type:loras'],
loader_path: 'detail_enhancer_v1.2.safetensors',
created_at: '2025-02-20T14:00:00Z',
updated_at: '2025-02-20T14:00:00Z'
})
export const MODEL_TYPE_LORA_README: CoreModelAsset = createCoreModelAsset({
id: 'mt-lora-002',
name: 'README.txt',
mime_type: 'text/plain',
size: 2_048,
tags: ['models', 'model_type:loras'],
loader_path: 'README.txt',
created_at: '2025-02-20T14:00:00Z',
updated_at: '2025-02-20T14:00:00Z'
})
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
id: 'test-input-001',
name: 'reference_photo.png',

View File

@@ -4,7 +4,6 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { WorkflowActionsDropdown } from '@e2e/fixtures/components/WorkflowActionsDropdown'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -20,7 +19,6 @@ export class AppModeHelper {
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
readonly workflowActions: WorkflowActionsDropdown
/** The "Connect an output" popover shown when saving without outputs. */
public readonly connectOutputPopover: Locator
@@ -79,7 +77,6 @@ export class AppModeHelper {
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.workflowActions = new WorkflowActionsDropdown(comfyPage.page)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
@@ -188,7 +185,10 @@ export class AppModeHelper {
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.workflowActions.trigger.click()
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.page
.getByRole('menuitem', { name: /Build app|Edit app/ })
.click()

View File

@@ -3,6 +3,7 @@ import type { Page, Route } from '@playwright/test'
import type {
Asset,
ListAssetsResponse,
SeedAssetsResponse,
UpdateAssetData
} from '@comfyorg/ingest-types'
import {
@@ -35,6 +36,13 @@ function emptyConfig(): AssetConfig {
type AssetOperator = (config: AssetConfig) => AssetConfig
/**
* Scoped to the API path so the built frontend's own `/assets/*.js` chunks
* are never intercepted (a page navigated after `mock()` would otherwise
* fail to load).
*/
const ASSET_API_ROUTE_PATTERN = '**/api/assets**'
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
const merged = new Map(config.assets)
for (const asset of newAssets) {
@@ -141,6 +149,8 @@ export class AssetHelper {
return this.handleUpdateAsset(route, path, body)
if (method === 'DELETE' && /\/assets\/[^/]+$/.test(path))
return this.handleDeleteAsset(route, path)
if (method === 'POST' && path.endsWith('/assets/seed'))
return this.handleSeedScan(route)
if (method === 'POST' && /\/assets\/?$/.test(path))
return this.handleUploadAsset(route)
if (method === 'POST' && path.endsWith('/assets/download'))
@@ -149,7 +159,7 @@ export class AssetHelper {
return route.fallback()
}
const pattern = '**/assets**'
const pattern = ASSET_API_ROUTE_PATTERN
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
@@ -165,7 +175,7 @@ export class AssetHelper {
})
}
const pattern = '**/assets**'
const pattern = ASSET_API_ROUTE_PATTERN
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
@@ -276,6 +286,11 @@ export class AssetHelper {
return route.fulfill({ status: 201, json: response })
}
private handleSeedScan(route: Route) {
const response: SeedAssetsResponse = { status: 'started' }
return route.fulfill({ status: 200, json: response })
}
private handleDownloadAsset(route: Route) {
return route.fulfill({
status: 202,

View File

@@ -51,6 +51,22 @@ export class FeatureFlagHelper {
})
}
/**
* Force server feature flags (the WS `feature_flags` handshake payload) on
* the running app by merging into `api.serverFeatureFlags`. The `ff:`
* localStorage override is dev-only (tree-shaken from production builds),
* so this is the way to control `api.serverSupportsFeature()` in e2e.
* Call after `comfyPage.setup()` so the real handshake cannot clobber it.
*/
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -239,9 +239,6 @@ export const TestIds = {
renameInput: 'subgraph-breadcrumb-rename-input',
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
},
workflowActions: {
viewModeToggle: 'view-mode-toggle'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`
@@ -279,7 +276,6 @@ export const TestIds = {
overlay: 'loading-overlay'
},
load3d: {
categoryMenu: 'load3d-category-menu',
recordingDuration: 'load3d-recording-duration'
},
load3dViewer: {

View File

@@ -0,0 +1,20 @@
import type { Page } from '@playwright/test'
/**
* Dispatches a wire-level custom event on the app's api singleton, simulating
* a websocket broadcast (e.g. `assets.seed.fast_complete`). Uses the raw
* EventTarget dispatch because `api.dispatchCustomEvent` is typed against the
* ApiEventTypes map, which deliberately excludes events consumed via
* `addCustomEventListener`.
*/
export async function dispatchApiCustomEvent(
page: Page,
type: string
): Promise<void> {
await page.evaluate((eventType) => {
EventTarget.prototype.dispatchEvent.call(
window.app!.api,
new CustomEvent(eventType)
)
}, type)
}

View File

@@ -1,14 +1,9 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
test.describe('App mode usage', () => {
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
const { centerPanel } = comfyPage.appMode
@@ -142,117 +137,6 @@ test.describe('App mode usage', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test('Shows a single side toolbar per mode, filtered to assets + apps in app mode', async ({
comfyPage
}) => {
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
await test.step('Graph mode shows the full toolbar', async () => {
await expect(sideToolbar).toHaveCount(1)
await expect(nodeLibraryTab.tabButton).toBeVisible()
})
await test.step('App mode shows only assets + apps', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(sideToolbar).toHaveCount(1)
await expect(assetsTab.tabButton).toBeVisible()
await expect(appsTab.tabButton).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeHidden()
})
})
test('Workflow actions menu keeps the same position across graph/app mode', async ({
comfyPage,
subgraphBreadcrumb
}) => {
const { workflowActions, centerPanel } = comfyPage.appMode
// Toggling graph<->app mode happens from this control, so it must not move
// out from under the cursor as the mode flips.
const graphActions = workflowActions.triggerIn(
subgraphBreadcrumb.panel.root
)
await expect(graphActions).toBeVisible()
const graphBox = await graphActions.boundingBox()
expect(graphBox).not.toBeNull()
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel).toBeVisible()
const appActions = workflowActions.triggerIn(centerPanel)
await expect(appActions).toBeVisible()
// The toggle segments reorder (morph) as the mode flips, so poll until the
// active control settles at the same x it occupied in graph mode.
await expect
.poll(async () => {
const box = await appActions.boundingBox()
return box ? Math.abs(box.x - graphBox!.x) : Infinity
})
.toBeLessThanOrEqual(1)
})
test('Toggle segment flips mode without opening the menu', async ({
comfyPage
}) => {
const { workflowActions } = comfyPage.appMode
await expect(workflowActions.viewModeToggle).toBeVisible()
await workflowActions.enterAppModeSegment.click()
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The inactive segment switches mode; it must not also open the actions menu.
await expect(workflowActions.menu).toBeHidden()
await expect(workflowActions.viewModeToggle).toBeVisible()
})
test('Toggle segment flips mode via keyboard without opening the menu', async ({
comfyPage
}) => {
const { workflowActions } = comfyPage.appMode
await workflowActions.enterAppModeSegment.focus()
await workflowActions.enterAppModeSegment.press('Enter')
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(workflowActions.menu).toBeHidden()
await expect(workflowActions.trigger).toBeFocused()
})
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
comfyPage
}) => {
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
await comfyPage.appMode.enableLinearMode()
await expect(toggle).toBeVisible()
await comfyPage.appMode.enterBuilder()
await expect(toggle).toBeHidden()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
await comfyPage.appMode.footer.exitButton.click()
// Exiting the builder lands in graph mode: the app-mode-only center panel
// stays hidden while the graph-mode toggle host re-mounts and the toggle
// re-appears.
await expect(toggle).toBeVisible()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
})
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
comfyPage
}) => {
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(toggle).toBeVisible()
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
await comfyPage.menu.assetsTab.tabButton.click()
await expect(toggle).toBeVisible()
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -11,47 +11,39 @@ export class Load3DHelper {
}
get menuButton(): Locator {
return this.node.getByTestId(TestIds.load3d.categoryMenu)
}
private get menuPanel(): Locator {
return this.node.page().getByRole('dialog')
return this.node.getByRole('button', { name: /show menu/i })
}
get recordingButton(): Locator {
return this.node.getByRole('button', { name: 'Record', exact: true })
return this.node.getByRole('button', { name: /start recording/i })
}
get stopRecordingButton(): Locator {
return this.node.getByRole('button', { name: /stop recording/i })
}
get recordingMenuButton(): Locator {
get exportRecordingButton(): Locator {
return this.node.getByRole('button', { name: /export recording/i })
}
get clearRecordingButton(): Locator {
return this.node.getByRole('button', { name: /clear recording/i })
}
get recordingDuration(): Locator {
return this.node.getByTestId(TestIds.load3d.recordingDuration)
}
get downloadRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
}
get startNewRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
}
get deleteRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
}
get gridToggleButton(): Locator {
return this.node.getByRole('button', { name: /show grid/i })
}
get uploadBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: 'BG Image' })
return this.node.getByRole('button', { name: /upload background image/i })
}
get removeBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: 'Remove BG' })
return this.node.getByRole('button', { name: /remove background image/i })
}
get panoramaModeButton(): Locator {
@@ -62,10 +54,6 @@ export class Load3DHelper {
return this.node.locator('input[type="color"]')
}
get exportButton(): Locator {
return this.node.getByRole('button', { name: 'Export', exact: true })
}
get openViewerButton(): Locator {
return this.node.getByRole('button', { name: /open in 3d viewer/i })
}
@@ -75,15 +63,11 @@ export class Load3DHelper {
}
getMenuCategory(name: string): Locator {
return this.menuPanel.getByRole('button', { name, exact: true })
return this.node.getByText(name, { exact: true })
}
get gizmoToggleButton(): Locator {
// The category chip is also named "Gizmo" once that category is active;
// only the toggle carries aria-pressed.
return this.node
.getByRole('button', { name: 'Gizmo' })
.and(this.node.locator('[aria-pressed]'))
return this.node.getByRole('button', { name: 'Gizmo' })
}
get gizmoTranslateButton(): Locator {
@@ -99,17 +83,13 @@ export class Load3DHelper {
}
get gizmoResetButton(): Locator {
return this.node.getByRole('button', { name: 'Reset', exact: true })
return this.node.getByRole('button', { name: 'Reset Transform' })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async openRecordingMenu(): Promise<void> {
await this.recordingMenuButton.click()
}
async openGizmoCategory(): Promise<void> {
await this.openMenu()
await this.getMenuCategory('Gizmo').click()

View File

@@ -47,12 +47,10 @@ test.describe('Load3D', () => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeVisible()
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
await expect(load3d.exportButton).toBeVisible()
await expect(load3d.getMenuCategory('Export')).toBeVisible()
await expect(load3d.node).toHaveScreenshot(
'load3d-controls-menu-open.png',
@@ -255,7 +253,7 @@ test.describe('Load3D', () => {
}
)
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
test('Recording controls show stop/export/clear buttons after a recording', async ({
comfyPage,
load3d
}) => {
@@ -267,25 +265,20 @@ test.describe('Load3D', () => {
await expect(load3d.stopRecordingButton).toBeVisible()
})
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
// Record for 1s wall-clock so the duration display settles on 00:01.
await comfyPage.delay(1000)
await load3d.stopRecordingButton.click()
await expect(load3d.recordingMenuButton).toBeVisible()
await expect(load3d.recordingMenuButton).toHaveText('00:01')
})
await test.step('Chip menu offers download, re-record and delete actions', async () => {
await load3d.openRecordingMenu()
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
})
await test.step('Deleting the recording restores the record button', async () => {
await load3d.deleteRecordingMenuItem.click()
await expect(load3d.recordingMenuButton).toHaveCount(0)
await expect(load3d.recordingButton).toBeVisible()
await expect(load3d.exportRecordingButton).toBeVisible()
await expect(load3d.clearRecordingButton).toBeVisible()
await expect(load3d.recordingDuration).toHaveText('00:01')
})
await test.step('Clear recording removes export and clear controls', async () => {
await load3d.clearRecordingButton.click()
await expect(load3d.exportRecordingButton).toHaveCount(0)
await expect(load3d.clearRecordingButton).toHaveCount(0)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -46,10 +46,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
// Center the node so its header clears the view-mode toggle floating
// at the top-left of the canvas.
const nodeRef = await comfyPage.nodeOps.getNodeRefById(nodeId)
await nodeRef.centerOnNode()
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)

View File

@@ -476,37 +476,6 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
.toBe(true)
})
test(
'Closing minimap after subgraph navigation keeps Vue render in sync',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
// Round-trip layers Vue's onNodeAdded wrapper on top of the minimap's.
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await comfyPage.subgraph.exitViaBreadcrumb()
// Minimap unmount must not clobber the Vue wrapper layered above it.
await comfyPage.page
.getByTestId(TestIds.canvas.closeMinimapButton)
.click()
const subgraphFixture =
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await comfyPage.contextMenu.openForVueNode(subgraphFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
await comfyPage.contextMenu.waitForHidden()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(2)
await expect(
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
).toHaveCount(0)
}
)
})
test.describe('Minimap mobile', { tag: ['@mobile', '@canvas'] }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -276,255 +276,3 @@ test.describe('FE-130 assets sidebar route mocks', () => {
)
})
})
test.describe('FE-910 marquee selection and select all', () => {
test.beforeEach(async ({ jobsRoutes, page, comfyPage }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
await comfyPage.setup()
await comfyPage.menu.assetsTab.open()
})
test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a marquee that begins in the panel header selects the cards', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const header = await tab.panelHeader.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!header || !beta) {
throw new Error('panel header or asset card has no layout box')
}
// Begin the rubber-band in the header (above the grid), then drag down
// across both cards.
await page.mouse.move(header.x + 24, header.y + 20)
await page.mouse.down()
await page.mouse.move(beta.x + 8, beta.y + beta.height - 8, { steps: 14 })
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd+A leaves assets unselected while the canvas is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const viewport = page.viewportSize()
if (!viewport) throw new Error('viewport size is unavailable')
// Hover the canvas (not the panel); Ctrl/Cmd+A must yield to the canvas.
await page.mouse.move(viewport.width - 100, viewport.height / 2)
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
})
test('a modifier-held marquee adds to the existing selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectedCards).toHaveCount(1)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Hold a modifier so the marquee is additive, then rubber-band over beta.
await page.keyboard.down('Control')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a Ctrl/Cmd+Shift marquee removes the covered cards from the selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Ctrl+Shift makes the marquee subtractive: rubber-band over beta only.
await page.keyboard.down('Control')
await page.keyboard.down('Shift')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Shift')
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(tab.getAssetCardByName('alpha')).toHaveAttribute(
'data-selected',
'true'
)
})
test('Ctrl/Cmd-dragging from an asset card starts a marquee selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const alpha = await tab.getAssetCardByName('alpha').boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!alpha || !beta) throw new Error('asset cards have no layout box')
// Ctrl bypasses card drag, so a press that begins on a card rubber-bands.
await page.keyboard.down('Control')
await page.mouse.move(alpha.x + alpha.width / 2, alpha.y + alpha.height / 2)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 6, beta.y + beta.height - 6, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd-dragging within a single card selects only that card', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const alpha = tab.getAssetCardByName('alpha')
const box = await alpha.boundingBox()
if (!box) throw new Error('alpha card has no layout box')
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
await page.keyboard.down('Control')
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + 12, start.y + 12, { steps: 4 })
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(alpha).toHaveAttribute('data-selected', 'true')
})
test('Ctrl/Cmd+A in the focused search input does not select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const query = 'alpha'
await tab.searchInput.fill(query)
await expect(tab.assetCards).toHaveCount(1)
await tab.searchInput.focus()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await expect
.poll(() =>
tab.searchInput.evaluate((el: HTMLInputElement) => {
return { start: el.selectionStart, end: el.selectionEnd }
})
)
.toEqual({ start: 0, end: query.length })
})
test('a drag starting in the search input does not marquee-select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const search = await tab.searchInput.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!search || !beta)
throw new Error('search box or card has no layout box')
await page.mouse.move(
search.x + search.width / 2,
search.y + search.height / 2
)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width / 2, beta.y + beta.height / 2, {
steps: 12
})
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(0)
})
test('Ctrl/Cmd+A does not select assets while an aria-modal dialog is open', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const dialog = document.createElement('div')
dialog.id = 'test-modal'
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
document.body.appendChild(dialog)
})
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await comfyPage.page.evaluate(() => {
document.getElementById('test-modal')?.remove()
})
})
})

View File

@@ -0,0 +1,231 @@
import { expect, mergeTests } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
MODEL_TYPE_CHECKPOINT_GGUF,
MODEL_TYPE_CHECKPOINT_NESTED,
MODEL_TYPE_CHECKPOINT_ROOT,
MODEL_TYPE_CHECKPOINT_SCANNED,
MODEL_TYPE_LORA,
MODEL_TYPE_LORA_README,
STABLE_CHECKPOINT
} from '@e2e/fixtures/data/assetFixtures'
import { withModels } from '@e2e/fixtures/helpers/AssetHelper'
import { dispatchApiCustomEvent } from '@e2e/fixtures/utils/dispatchApiEvent'
import type { ModelFolderInfo } from '@/platform/assets/schemas/assetSchema'
const test = mergeTests(comfyPageFixture, assetApiFixture)
// Deliberately not alphabetical: the sidebar must show folders in backend
// registration order, so 'loras' listed first must render first.
const REGISTERED_FOLDERS: ModelFolderInfo[] = [
{ name: 'loras', folders: ['/models/loras'], extensions: [] },
{
name: 'checkpoints',
folders: ['/models/checkpoints'],
extensions: ['.safetensors', '.gguf']
}
]
const WALK_ASSETS: Asset[] = [
MODEL_TYPE_CHECKPOINT_NESTED,
MODEL_TYPE_CHECKPOINT_ROOT,
MODEL_TYPE_CHECKPOINT_GGUF,
MODEL_TYPE_LORA,
MODEL_TYPE_LORA_README
]
test.use({
initialSettings: {
'Comfy.Assets.UseAssetAPI': true,
'Comfy.ModelLibrary.UseAssetBrowser': false
}
})
test.describe('Model library sidebar - asset mode', () => {
test.beforeEach(async ({ comfyPage, assetApi }) => {
assetApi.configure(withModels(WALK_ASSETS))
await assetApi.mock()
await comfyPage.modelLibrary.mockModelFolders(REGISTERED_FOLDERS)
await comfyPage.setup()
await comfyPage.featureFlags.setServerFlags({
supports_model_type_tags: true
})
await comfyPage.menu.modelLibraryTab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Lists folders in backend registration order', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await expect(tab.folderNodes.nth(0)).toContainText('loras')
await expect(tab.folderNodes.nth(1)).toContainText('checkpoints')
})
test('Eager-loads models and drops the load-all button', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await expect(tab.refreshButton).toBeVisible()
await expect(tab.loadAllFoldersButton).toBeHidden()
// Models render from the eager walk on expansion, with loader_path
// subdirectories as nested folders.
await tab.getFolderRowByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('v1-5-pruned-emaonly')).toBeVisible()
await tab.getFolderRowByLabel('SDXL').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
})
test('Applies registered extension allowlists verbatim and default-filters match-all folders', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
// checkpoints registers ['.safetensors', '.gguf'], so the .gguf model
// shows even though the legacy fixed list would have hidden it.
await tab.getFolderRowByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('flux_quantized.gguf')).toBeVisible()
// loras is registered match-all (empty allowlist); the FE substitutes
// the default model-extension list, hiding non-model noise.
await tab.getFolderRowByLabel('loras').click()
await expect(tab.getLeafByLabel('detail_enhancer_v1.2')).toBeVisible()
await expect(tab.getLeafByLabel('README')).toBeHidden()
})
test('Refresh seeds a backend rescan', async ({ comfyPage, assetApi }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.refreshButton.click()
await expect
.poll(
() =>
assetApi
.getMutations()
.find(
(mutation) =>
mutation.method === 'POST' &&
mutation.endpoint.endsWith('/assets/seed')
)?.body
)
.toEqual({ roots: ['models'] })
})
test('Live-updates the tree when the scan fast-phase completes', async ({
comfyPage,
assetApi
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.getFolderRowByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('v1-5-pruned-emaonly')).toBeVisible()
await expect(tab.getLeafByLabel('freshly_scanned')).toBeHidden()
assetApi.configure(
withModels([...WALK_ASSETS, MODEL_TYPE_CHECKPOINT_SCANNED])
)
await dispatchApiCustomEvent(comfyPage.page, 'assets.seed.fast_complete')
await expect(tab.getLeafByLabel('freshly_scanned')).toBeVisible()
})
test('Active search results update when the scan fast-phase completes', async ({
comfyPage,
assetApi
}) => {
const tab = comfyPage.menu.modelLibraryTab
// Search an existing model first: its result proves the eager load and
// the debounced search pipeline have settled, so the later update can
// only come from the scan event, not from a still-pending load.
await tab.searchInput.fill('detail_enhancer')
await expect(tab.getLeafByLabel('detail_enhancer_v1.2')).toBeVisible()
await tab.searchInput.fill('freshly')
await expect(tab.leafNodes).toHaveCount(0)
assetApi.configure(
withModels([...WALK_ASSETS, MODEL_TYPE_CHECKPOINT_SCANNED])
)
await dispatchApiCustomEvent(comfyPage.page, 'assets.seed.fast_complete')
await expect(tab.getLeafByLabel('freshly_scanned')).toBeVisible()
})
test('Placing a model fills the loader with the category-relative loader path', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
const tab = comfyPage.menu.modelLibraryTab
await tab.getFolderRowByLabel('checkpoints').click()
await tab.getFolderRowByLabel('SDXL').click()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
// The visible ghost preview marks arming as complete, so a zero node
// count here proves nothing is placed until the canvas is clicked.
const ghost = comfyPage.page.locator(
'[data-node-id="preview-CheckpointLoaderSimple"]'
)
await expect(ghost).toBeVisible()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe('SDXL/sd_xl_base_1.0.safetensors')
})
})
test.describe('Model library sidebar - asset mode on bare-tag backends', () => {
test.beforeEach(async ({ comfyPage, assetApi }) => {
assetApi.configure(withModels([STABLE_CHECKPOINT]))
await assetApi.mock()
await comfyPage.modelLibrary.mockModelFolders([
{
name: 'checkpoints',
folders: ['/models/checkpoints'],
extensions: ['.safetensors']
}
])
await comfyPage.setup()
// Force the capability off rather than omitting it: the real backend's
// feature_flags handshake would otherwise decide which mode this tests.
// Bare-tag backends bucket by bare tags and emit no loader_path, so
// names fall back to the filename.
await comfyPage.featureFlags.setServerFlags({
supports_model_type_tags: false
})
await comfyPage.menu.modelLibraryTab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Buckets by bare tags and names leaves from the filename', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.getFolderRowByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
})
})

View File

@@ -0,0 +1,73 @@
import { expect, mergeTests } from '@playwright/test'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const test = mergeTests(comfyPageFixture, assetApiFixture)
const assetBrowserModal = '[data-component-id="AssetBrowserModal"]'
test.describe('Model library tab routing', () => {
test('Opens the asset browser when both asset settings are enabled', async ({
comfyPage,
assetApi
}) => {
await assetApi.mock()
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.settings.setSetting(
'Comfy.ModelLibrary.UseAssetBrowser',
true
)
await comfyPage.menu.modelLibraryTab.tabButton.click()
await expect(comfyPage.page.locator(assetBrowserModal)).toBeVisible()
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeHidden()
})
test('Keeps the sidebar tree when the asset API is disabled', async ({
comfyPage
}) => {
// With the asset API off, the browser setting is inert.
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', false)
await comfyPage.settings.setSetting(
'Comfy.ModelLibrary.UseAssetBrowser',
true
)
await comfyPage.menu.modelLibraryTab.open()
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeVisible()
await expect(comfyPage.page.locator(assetBrowserModal)).toBeHidden()
})
test('Keeps the sidebar tree when only the asset API is enabled', async ({
comfyPage,
assetApi
}) => {
await assetApi.mock()
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.settings.setSetting(
'Comfy.ModelLibrary.UseAssetBrowser',
false
)
await comfyPage.menu.modelLibraryTab.open()
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeVisible()
await expect(comfyPage.page.locator(assetBrowserModal)).toBeHidden()
})
})
test.describe('Model library tab routing on cloud', { tag: '@cloud' }, () => {
test('Defaults to the asset browser', async ({ comfyPage, assetApi }) => {
// Cloud defaults both asset settings on; no explicit settings here so the
// test pins the defaults, not just the routing.
await assetApi.mock()
await comfyPage.menu.modelLibraryTab.tabButton.click()
await expect(comfyPage.page.locator(assetBrowserModal)).toBeVisible()
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

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