Compare commits

..

28 Commits

Author SHA1 Message Date
Comfy Org PR Bot
779cf50691 [backport cloud/1.44] Fix node tooltip metadata i18n parsing (#12554)
Backport of #12469 to `cloud/1.44`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:30:53 +09:00
Comfy Org PR Bot
41efa26d10 1.44.19 (#12471)
Patch version increment to 1.44.19

**Base branch:** `cloud/1.44`

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-05-27 11:08:05 -07:00
Comfy Org PR Bot
e0c301bb52 [backport cloud/1.44] Fix errant subscription popups with workspaces (#12475)
Backport of #12472 to `cloud/1.44`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-26 19:56:28 -07:00
Comfy Org PR Bot
bef26f6dd4 [backport cloud/1.44] feat: OAuth consent UI for authorization (BE-638) (#12470)
Backport of #12159 to `cloud/1.44`

Automatically created by backport workflow.

Co-authored-by: skishore23 <shimikeri.kishore@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-26 16:07:12 -07:00
Comfy Org PR Bot
553e9e3b11 [backport cloud/1.44] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12452)
Backport of #12447 to `cloud/1.44`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 21:55:11 -07:00
AustinMroz
325dc8ee15 [backport cloud/1.44] Fix reactivity of vue subgraph price badges (#12385)
Manually backport #12029 to cloud/1.44

Removes tests so it backports cleanly

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12385-backport-cloud-1-44-Fix-reactivity-of-vue-subgraph-price-badges-3666d73d3650812297dad89d87a0b8e2)
by [Unito](https://www.unito.io)
2026-05-21 20:46:11 +09:00
Dante
d00447b40d [backport cloud/1.44] fix: clarify unsaved-changes modal buttons and fix sign-out 3-state (#12401)
Backport of #11669 to `cloud/1.44`.

Manually created because the auto-backport workflow's cleanup step
removed the 1.44 branches when 1.43 cherry-picks failed (see PR #11669
comments).
2026-05-21 20:30:05 +09:00
Comfy Org PR Bot
694ae7fcc6 [backport cloud/1.44] Subgraph io fixes (#12379)
Backport of #12281 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12379-backport-cloud-1-44-Subgraph-io-fixes-3666d73d36508166b709c77b71eb013f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-20 11:19:51 -07:00
Comfy Org PR Bot
b4ef75eac4 [backport cloud/1.44] fix: avoid false missing media errors after importing shared workflow assets (#12370)
Backport of #12333 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12370-backport-cloud-1-44-fix-avoid-false-missing-media-errors-after-importing-shared-workf-3666d73d36508113a827c3c003c9d71c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-20 16:45:20 +09:00
Comfy Org PR Bot
c057222bab [backport cloud/1.44] fix(terminal): resync logs console on backend reconnect (#12373) 2026-05-20 16:42:59 +09:00
Comfy Org PR Bot
9688f1ad6e [backport cloud/1.44] fix: prevent first user template popup when following shared link (#12368)
Backport of #12024 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12368-backport-cloud-1-44-fix-prevent-first-user-template-popup-when-following-shared-link-3666d73d365081c681a4f70097746312)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-05-20 14:24:45 +09:00
Dante
e6a751f42f [backport cloud/1.44] fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006) (#12353)
Backport of #12006 to cloud/1.44.

## Conflict resolution

One conflict in
`src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue`
(imports + dropdown source). On main, the original PR replaced
`useMediaAssets('output')` with `isCloud ? useFlatOutputAssets() :
useAssetsApi('output')`. On `cloud/1.44`, the local path still goes
through `useMediaAssets`, which itself internally gates `isCloud →
useAssetsApi : useInternalFilesApi`. Resolution preserves the new cloud
branch (which is the whole point of this PR — single
`getAssetsByTag('output')` instead of jobs-walk + per-job expansion)
while keeping `useMediaAssets('output')` for the local path on this
branch:

```ts
const outputMediaAssets = isCloud
  ? useFlatOutputAssets()
  : useMediaAssets('output')
```

All other files auto-merged.

## Verification

- `pnpm typecheck` 
- `pnpm test:unit` — `assetsStore.test.ts` (60) +
`useWidgetSelectItems.test.ts` (40) = 100/100 

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12353-backport-cloud-1-44-fix-stabilize-multi-output-expansion-simplify-cloud-output-fetc-3666d73d3650815280a4f9207332e058)
by [Unito](https://www.unito.io)
2026-05-20 12:01:15 +09:00
Comfy Org PR Bot
f8a3f462b7 [backport cloud/1.44] Revert "fix(cloud): stop bouncing working users to /cloud/survey mid-session" (#12346)
Backport of #12344 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12346-backport-cloud-1-44-Revert-fix-cloud-stop-bouncing-working-users-to-cloud-survey-m-3656d73d365081a2bd91ce5002af5fdc)
by [Unito](https://www.unito.io)

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-05-20 10:26:06 +09:00
Comfy Org PR Bot
81229466e1 [backport cloud/1.44] fix: keep node context menu overflow visible when content fits (#12338)
Backport of #12035 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12338-backport-cloud-1-44-fix-keep-node-context-menu-overflow-visible-when-content-fits-3656d73d3650811aa95bc47b0e41fb4e)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-19 22:21:39 +09:00
Comfy Org PR Bot
db2d381c89 [backport cloud/1.44] fix(cloud): stop bouncing working users to /cloud/survey mid-session (#12320)
Backport of #12301 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12320-backport-cloud-1-44-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-sessi-3646d73d3650815185cecaa2babdf3d1)
by [Unito](https://www.unito.io)

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 19:50:13 -07:00
Comfy Org PR Bot
13894beeb2 [backport cloud/1.44] fix: stop trackpad pinch/swipe gestures from breaking the UI (#12291)
Backport of #12052 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12291-backport-cloud-1-44-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3616d73d365081f3a362e618ab12d34d)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 12:12:41 +09:00
Comfy Org PR Bot
4c4b85bd49 [backport cloud/1.44] fix: include share_id when importing published assets (FE-603) (#12256)
Backport of #12055 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12256-backport-cloud-1-44-fix-include-share_id-when-importing-published-assets-FE-603-3606d73d365081d4965fcec791a68bd4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 12:11:55 +09:00
Comfy Org PR Bot
1dffc948d7 [backport cloud/1.44] Fix descriptions on core blueprints (#12259)
Backport of #12220 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12259-backport-cloud-1-44-Fix-descriptions-on-core-blueprints-3606d73d3650811f9956f4368d9e723f)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-13 23:51:19 -07:00
Comfy Org PR Bot
b30454ac4f [backport cloud/1.44] fix: clear media upload errors via widget change (#12245)
Backport of #12212 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12245-backport-cloud-1-44-fix-clear-media-upload-errors-via-widget-change-3606d73d365081bb948cd1d6729565bd)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-14 12:22:26 +09:00
Comfy Org PR Bot
b1b6394345 [backport cloud/1.44] fix: open node info panel from context menu (#12248)
Backport of #12205 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12248-backport-cloud-1-44-fix-open-node-info-panel-from-context-menu-3606d73d36508184b212c72f0af6af26)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 12:22:18 +09:00
Comfy Org PR Bot
efde624926 [backport cloud/1.44] fix: Load Image preview retains deleted asset (FE-230) (#12130)
Backport of #11493 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12130-backport-cloud-1-44-fix-Load-Image-preview-retains-deleted-asset-FE-230-35d6d73d3650812983cfdd3086dc6969)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-14 11:21:41 +09:00
jaeone94
2f48694192 [backport cloud/1.44] fix: suppress missing media scan during uploads (#12111) (#12189)
## Summary

Manual backport of #12111 to `cloud/1.44`.

This suppresses false-positive missing media detection while media
loader nodes are still uploading files from drag/drop, paste, or
file-select flows.

## Conflict Resolution

The cherry-pick conflicted only in
`src/platform/missingMedia/missingMediaScan.test.ts` because the target
branch still has the older annotated-media parameterized test block
around the insertion point. I resolved it by:

- adding the new upload-state tests from #12111 above the existing
annotated-media cases
- keeping the existing release-branch annotated-media `it.each` cases
intact
- using `it.for([false, true])` only for the new upload-state test added
by #12111

## Validation

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
src/platform/missingMedia/missingMediaScan.test.ts
src/composables/node/useNodeImageUpload.test.ts
src/extensions/core/uploadAudio.test.ts
src/composables/graph/useErrorClearingHooks.test.ts`

Result: 4 files passed, 87 tests passed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12189-backport-cloud-1-44-fix-suppress-missing-media-scan-during-uploads-12111-35e6d73d36508195a407f1fa0d6898e7)
by [Unito](https://www.unito.io)
2026-05-12 20:38:01 +09:00
Dante
c6d9a84aac [backport cloud/1.44] fix(i18n): clamp unsupported browser locales to a shipped tag (#11712) (#12179)
*PR Created by the Glary-Bot Agent*

---

Backport of #11712 to `cloud/1.44`. Companion to #12178 (`core/1.44`).

## Summary

Cherry-picks `ceb993605` ("fix(i18n): clamp unsupported browser locales
to a shipped tag") onto `cloud/1.44`.

Sidebar buttons rendered literal i18n keys (e.g.
`sideToolbar.labels.assets`) on a fresh install when the user's
`navigator.language` base tag wasn't one of the 12 shipped locales —
German/Italian/Polish/Dutch/Brazilian-Portuguese users among others.

The i18n files this PR touches are byte-identical between `core/1.44`
and `cloud/1.44`, so this backport applies the same patch as the core
backport (verified via `git show` byte diff).

## Cherry-pick conflict resolution

One file required manual resolution (same as the core backport):

- **`browser_tests/tests/customNodeLocales.spec.ts`** — `modify/delete`
conflict. This file does not exist on `cloud/1.44` (it was introduced on
`main` after the 1.44 branch cut, in #12132). Dropped the modification
from the backport; the underlying test file is not on the release
branch, so its updates are irrelevant here.

All other files merged cleanly (`src/views/GraphView.vue` had an
auto-merge that resolved without intervention).

## Verification

- `pnpm typecheck` — clean
- `pnpm test:unit src/i18n.test.ts` — **17/17 passing** (covers the new
`resolveSupportedLocale` block and the updated unsupported-locale clamp
behaviour)
- `eslint` on all changed files — clean
- **Manual verification via Playwright on the backport branch**,
simulating a fresh install with `navigator.language='de-DE'`:
- Sidebar `aria-label`s render real strings ("Assets (a)", "Node Library
(n)", "Workflows (w)", "Settings", …) — **no** literal i18n keys like
`sideToolbar.labels.assets`.
- Confirmed `hasLiteralKeys: false` on the rendered DOM, matching the
fixed behaviour from the original PR.
  - Screenshot attached.

## Files

```
 apps/desktop-ui/src/i18n.ts                     |   3 +-
 browser_tests/tests/i18nLocaleFallback.spec.ts  |  47 ++++++++
 browser_tests/tests/templates.spec.ts           |  59 +++++-----
 src/i18n.test.ts                                | 109 +++++++++++++++---
 src/i18n.ts                                     | 145 ++++++++----------------
 src/locales/CONTRIBUTING.md                     |  44 +------
 src/locales/localeConfig.ts                     |  82 ++++++++++++++
 src/platform/settings/constants/coreSettings.ts |  21 +---
 src/views/GraphView.vue                         |  20 ++--
```

Backport-of: #11712
Companion: #12178 (core/1.44)
Fixes #10563 on the 1.44 cloud release line


## Screenshots

![Cloud 1.44 backport — sidebar with navigator.language=de-DE shows real
labels (Assets, Node Library, Workflows...) instead of literal i18n
keys](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/08da2130835cfe011512360b413c21eae085ac145ae94852e9dc841da09d0411/pr-images/1778567031488-dce52eb2-e07c-48ec-9c5e-409ffc45c49c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12179-backport-cloud-1-44-fix-i18n-clamp-unsupported-browser-locales-to-a-shipped-tag-11-35e6d73d365081c8882cfd086ee8b90b)
by [Unito](https://www.unito.io)
2026-05-12 18:35:05 +09:00
Comfy Org PR Bot
4a30b51bee [backport cloud/1.44] fix: handle annotated output media paths in missing media scan (#12122)
Backport of #12069 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12122-backport-cloud-1-44-fix-handle-annotated-output-media-paths-in-missing-media-scan-35d6d73d36508174bfc1f2590edd1a03)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-11 01:35:14 +00:00
Comfy Org PR Bot
d891fafc3d [backport cloud/1.44] fix: make credits help icon a tooltip button in cloud user popover (FE-617) (#12084)
Backport of #12072 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12084-backport-cloud-1-44-fix-make-credits-help-icon-a-tooltip-button-in-cloud-user-popover-35a6d73d3650816eb973f20c0954696c)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-08 23:20:35 +09:00
Comfy Org PR Bot
b585afcd9c [backport cloud/1.44] fix: prevent enter subgraph/toggle advanced when nodes were dragged (#12081)
Backport of #12051 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12081-backport-cloud-1-44-fix-prevent-enter-subgraph-toggle-advanced-when-nodes-were-dragge-35a6d73d365081e5becbcff78471d87f)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-05-08 20:54:47 +09:00
Comfy Org PR Bot
5cb36fea91 [backport cloud/1.44] fix: remove asset hash verification (#12079)
Backport of #12061 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12079-backport-cloud-1-44-fix-remove-asset-hash-verification-35a6d73d36508157bec2d1259f32d142)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-08 09:11:57 +00:00
Comfy Org PR Bot
6d1221bc2f [backport cloud/1.44] refactor: align asset pagination schema (#12065)
Backport of #11899 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12065-backport-cloud-1-44-refactor-align-asset-pagination-schema-3596d73d365081a5b596c288ef8a818a)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-07 17:36:36 +00:00
298 changed files with 6931 additions and 12655 deletions

View File

@@ -19,26 +19,15 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass otherwise.
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |

View File

@@ -20,8 +20,6 @@ jobs:
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
@@ -39,33 +37,31 @@ jobs:
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
@@ -86,7 +82,7 @@ jobs:
done
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -95,7 +91,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -104,7 +100,6 @@ jobs:
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
@@ -119,7 +114,6 @@ jobs:
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
@@ -128,9 +122,7 @@ jobs:
deploy:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -1,6 +1,6 @@
# Description: Manual workflow to refresh apps/website data snapshots and open
# a PR. Merging the PR triggers the existing Vercel website production deploy
# via ci-vercel-website-preview.yaml.
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
refresh-snapshots:
refresh-snapshot:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
@@ -31,20 +31,15 @@ jobs:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh GitHub stars snapshot
run: pnpm --filter @comfyorg/website github-stars:refresh-snapshot
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh data snapshots'
title: 'chore(website): refresh data snapshots'
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
body: |
Automated refresh of website build-data snapshots:
- `apps/website/src/data/ashby-roles.snapshot.json`
- `apps/website/src/data/github-stars.snapshot.json`
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
@@ -52,11 +47,12 @@ jobs:
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback paths remain intact: builds use committed
snapshots whenever a live build-data source is unavailable.
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-website-data-snapshots-${{ github.run_id }}
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
base: main
labels: |
Release:Website

View File

@@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -167,7 +168,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
locale: getDefaultLocale(),
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

View File

@@ -2,20 +2,6 @@
Marketing/brand website built with Astro + Vue.
## Build-time data sources
External data used during static generation should use the shared build-data
source helpers in `src/utils/buildDataSource.ts` and
`src/utils/buildDataReporter.ts`. Each source owns its domain parsing, but the
shared machinery owns the repeated lifecycle:
1. Fetch fresh data once per build process.
2. Validate and map to a committed snapshot shape.
3. Fall back to the committed snapshot on fetch/schema/network failure.
4. Emit GitHub Actions annotations and `$GITHUB_STEP_SUMMARY` rows once per
build process.
5. Refresh snapshots through package scripts that write atomically.
## Ashby careers integration
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
@@ -127,19 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## GitHub stars integration
The navigation star badge is rendered from the GitHub repository API at build
time through the same snapshot-fallback lifecycle.
- Runtime source: `src/utils/github.ts`
- Snapshot: `src/data/github-stars.snapshot.json`
- CI reporter: `src/utils/github.ci.ts`
- Refresh script: `pnpm --filter @comfyorg/website github-stars:refresh-snapshot`
`WEBSITE_GITHUB_STARS_OVERRIDE` remains available for visual snapshots and local
determinism. It must be a non-negative integer and is build-time only.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
@@ -173,5 +146,3 @@ renders the documented embed container.
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
- `pnpm github-stars:refresh-snapshot` — refresh the committed GitHub stars
snapshot

View File

@@ -32,34 +32,16 @@ test.describe('Careers page @smoke', () => {
}
})
test('clicking a department button scrolls to and activates that section', async ({
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const rolesSection = page.getByTestId('careers-roles')
await rolesSection.scrollIntoViewIfNeeded()
await expect(rolesSection).toBeVisible()
const allCount = await page.getByTestId('careers-role-link').count()
const engineeringButton = page.getByRole('button', {
name: 'ENGINEERING',
exact: true
})
// RolesSection is hydrated via `client:visible`. Once the button responds
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
// the locator logic is in effect.
await expect(async () => {
await engineeringButton.click()
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
timeout: 1_000
})
}).toPass({ timeout: 10_000 })
const engineeringSection = page.locator('#careers-dept-engineering')
await expect(engineeringSection).toBeInViewport()
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})

View File

@@ -1,61 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
const LAST_SECTION_HASH = '#contact'
test.describe(
'ContentSection scroll-spy @smoke',
{
annotation: [
{
type: 'issue',
description:
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
},
{
type: 'environment',
description:
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
}
]
},
() => {
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
test('activates the last badge when user scrolls to the bottom', async ({
page
}) => {
await page.goto('/privacy-policy')
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const badges = sidebarNav.getByRole('button')
const lastBadge = badges.last()
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight)
)
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
page
}) => {
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const lastBadge = sidebarNav.getByRole('button').last()
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
}
)

View File

@@ -1,33 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

View File

@@ -1,71 +1,27 @@
import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
for (const demo of demos) {
const nextDemo = getNextDemo(demo.slug)
test('demo detail page renders hero and embed', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'Create a Video from an Image'
)
const iframe = page.locator('iframe[title*="Interactive demo"]')
await expect(iframe).toBeAttached()
})
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
page
}) => {
await page.goto(`/demos/${demo.slug}`)
test('demo detail page has transcript section', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
})
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t(demo.title, 'en'))
const ogImage = page.locator('head meta[property="og:image"]')
await expect(ogImage).toHaveAttribute(
'content',
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
)
const iframe = page.locator(
`iframe[title*="${t('demos.embed.label', 'en')}"]`
)
await expect(iframe).toBeAttached()
await expect(iframe).toHaveAttribute(
'src',
new RegExp(escapeRegExp(demo.arcadeId))
)
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
await expect(
page.getByText(t(nextDemo.title, 'en')).first()
).toBeVisible()
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
await expect(nextThumb).toBeAttached()
await expect(nextThumb).toBeVisible()
const naturalWidth = await nextThumb.evaluate(
(img) => (img as HTMLImageElement).naturalWidth
)
expect(naturalWidth).toBeGreaterThan(1)
})
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
page
}) => {
await page.goto(`/zh-CN/demos/${demo.slug}`)
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
await expect(
page.getByText(t(nextDemo.title, 'zh-CN')).first()
).toBeVisible()
})
}
test('demo detail page has next demo navigation', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByText(/what's next/i)).toBeVisible()
})
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
@@ -76,4 +32,13 @@ test.describe('Demo pages @smoke', () => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
test('zh-CN demo page renders localized content', async ({ page }) => {
await page.goto('/zh-CN/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'从图片创建视频'
)
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
await expect(nextDemoLink).toBeAttached()
})
})

View File

@@ -1,4 +1,3 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -48,105 +47,4 @@ test.describe('Mobile layout @mobile', () => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
test.describe('SocialProofBar seamless marquee', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test('mobile forward marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
)
expectSeamlessReverseLoop(geometry)
})
})
})
test.describe('Desktop SocialProofBar @smoke', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('desktop marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-desktop"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
})
type MarqueeGeometry = {
copyWidths: number[]
startPositions: number[]
endPositions: number[]
}
async function measureMarqueeLoopGeometry(
page: Page,
selector: string
): Promise<MarqueeGeometry> {
await page.locator(selector).first().waitFor()
return page.evaluate((sel) => {
const tracks = Array.from(
document.querySelectorAll<HTMLElement>(sel)
).slice(0, 2)
const firstAnimation = tracks[0]?.getAnimations()[0]
if (!firstAnimation) {
throw new Error(`No CSS animation found on ${sel}`)
}
const duration = firstAnimation.effect?.getTiming().duration
if (typeof duration !== 'number' || duration <= 1) {
throw new Error(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
}
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(
(track) => track.getBoundingClientRect().width
)
setAllTimes(duration - 0.1)
const endPositions = readX()
return { copyWidths, startPositions, endPositions }
}, selector)
}
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
const { copyWidths } = geometry
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
expect(copyWidths[0]).toBeGreaterThan(0)
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
}
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
// (when copy 1 jumps back to its start position) is visually indistinguishable.
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
}
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
}

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -15,8 +15,7 @@
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"github-stars:refresh-snapshot": "tsx ./scripts/refresh-github-stars-snapshot.ts"
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 100 }
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,29 +0,0 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchGitHubStarsForBuild } from '../src/utils/github'
const snapshotPath = fileURLToPath(
new URL('../src/data/github-stars.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchGitHubStarsForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
process.stdout.write(
`Wrote snapshot with ${outcome.snapshot.stargazersCount} star(s) to ${snapshotPath}\n`
)

View File

@@ -1,13 +1,10 @@
<script setup lang="ts">
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { t } from '../../i18n/translations'
import { scrollTo } from '../../scripts/smoothScroll'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
@@ -16,72 +13,24 @@ const { locale = 'en', departments = [] } = defineProps<{
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() =>
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
const activeCategory = ref('')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
const deptElementId = (key: string) => `careers-dept-${key}`
function pickActiveSection() {
pendingFrame = 0
if (isScrolling) return
const sections = sectionRefs.value as HTMLElement[]
if (sections.length === 0) return
let active = sections[0]
for (const el of sections) {
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
active = el
} else {
break
}
}
activeCategory.value = active.id.replace(/^careers-dept-/, '')
}
function scheduleUpdate() {
if (pendingFrame !== 0) return
pendingFrame = requestAnimationFrame(pickActiveSection)
}
onMounted(pickActiveSection)
useEventListener('scroll', scheduleUpdate, { passive: true })
useEventListener('resize', scheduleUpdate, { passive: true })
function scrollToDepartment(deptKey: string) {
activeCategory.value = deptKey
isScrolling = true
const el = document.getElementById(deptElementId(deptKey))
if (!el) {
isScrolling = false
return
}
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
pickActiveSection()
}
})
}
</script>
<template>
@@ -99,10 +48,9 @@ function scrollToDepartment(deptKey: string) {
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
:model-value="activeCategory"
class="mt-4"
@update:model-value="scrollToDepartment"
/>
</div>
</div>
@@ -117,11 +65,9 @@ function scrollToDepartment(deptKey: string) {
</p>
<div
v-for="dept in visibleDepartments"
:id="deptElementId(dept.key)"
:ref="sectionRefs.set"
v-for="dept in filteredDepartments"
:key="dept.key"
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
class="mb-12 last:mb-0"
>
<SectionLabel>
{{ dept.name }}

View File

@@ -1,11 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
useEventListener,
useIntersectionObserver,
useTemplateRefsList
} from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
@@ -44,25 +40,13 @@ const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
function clearScrollLock() {
isScrolling = false
if (scrollSafetyTimer !== undefined) {
clearTimeout(scrollSafetyTimer)
scrollSafetyTimer = undefined
}
}
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
if (isAtBottom()) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
@@ -74,39 +58,22 @@ useIntersectionObserver(
{ rootMargin: '-20% 0px -60% 0px' }
)
function isAtBottom(): boolean {
const scrollBottom = window.scrollY + window.innerHeight
return (
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
)
}
function activateLastIfAtBottom() {
if (isScrolling) return
if (!isAtBottom()) return
const lastId = sections[sections.length - 1]?.id
if (lastId) activeSection.value = lastId
}
onMounted(activateLastIfAtBottom)
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
function scrollToSection(id: string) {
activeSection.value = id
clearScrollLock()
isScrolling = true
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock
onComplete: () => {
isScrolling = false
}
})
return
}
clearScrollLock()
isScrolling = false
}
</script>

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-1 lg:flex"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-6 shrink-0"
class="bg-primary-comfy-yellow block size-7"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

@@ -14,28 +14,23 @@ const logos = [
'Ubisoft'
]
const mobileRow1Logos = logos.slice(0, 6)
const mobileRow2Logos = logos.slice(6)
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
const row1 = logos.slice(0, 6)
const mobileRow1 = [...row1, ...row1]
const row2 = logos.slice(6)
const mobileRow2 = [...row2, ...row2]
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
<div class="animate-marquee hidden items-center gap-2 md:flex">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in desktopLogos"
:key="`${logo}-${i}`"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<div
v-for="logo in logos"
:key="logo"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
@@ -44,38 +39,22 @@ const mobileRow2Logos = logos.slice(6)
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
<div class="flex w-max gap-8">
<div class="animate-marquee flex items-center gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in mobileRow1"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<div
v-for="logo in mobileRow1Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
<div class="flex w-max gap-8">
<div class="animate-marquee-reverse flex items-center gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in mobileRow2"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<div
v-for="logo in mobileRow2Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>

View File

@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full"
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
:style="{ width: progressPercent }"
/>
</div>

View File

@@ -5,7 +5,6 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
import SectionLabel from '../common/SectionLabel.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { ScrollTrigger } from '../../scripts/gsapSetup'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -23,10 +22,6 @@ useHeroAnimation({
logo: logoRef,
video: videoRef
})
function handleLogoLoad() {
ScrollTrigger.refresh(true)
}
</script>
<template>
@@ -42,10 +37,7 @@ function handleLogoLoad() {
<img
src="https://media.comfy.org/website/customers/c-projection.webp"
alt="Comfy 3D logo"
width="1568"
height="1763"
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
@load="handleLogoLoad"
class="mx-auto w-full max-w-md lg:max-w-none"
/>
</div>

View File

@@ -8,12 +8,10 @@ import { t } from '../../i18n/translations'
const {
arcadeId,
title,
aspectRatio = 16 / 9,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
aspectRatio?: number
locale?: Locale
}>()
@@ -26,8 +24,7 @@ const loaded = ref(false)
:aria-label="t('demos.embed.label', locale)"
>
<div
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
:style="{ aspectRatio }"
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
>
<div
v-if="!loaded"

View File

@@ -25,7 +25,7 @@ const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
},
{
label: t('useCase.advertising', locale),

View File

@@ -276,6 +276,29 @@ onUnmounted(() => {
fill="#211927"
/>
</g>
<!-- Left-edge fade -->
<rect
x="300"
y="150"
width="250"
height="900"
fill="url(#localHeroFadeLeft)"
/>
<defs>
<linearGradient
id="localHeroFadeLeft"
x1="550"
y1="600"
x2="300"
y2="600"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
</defs>
</svg>
</div>

View File

@@ -15,14 +15,6 @@ interface Demo {
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
/**
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
* landscape screencast). Sizes the embed container to match so rounded
* corners hug the content instead of empty letterbox space. Source from
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
* invert it). Defaults to 16/9 if omitted.
*/
readonly aspectRatio?: number
}
export const demos: readonly Demo[] = [
@@ -40,8 +32,7 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19',
aspectRatio: 1.931
modifiedDate: '2026-04-19'
},
{
slug: 'workflow-templates',
@@ -57,25 +48,7 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'community-workflows',
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
category: 'demos.category.gettingStarted',
title: 'demos.community-workflows.title',
description: 'demos.community-workflows.description',
transcript: 'demos.community-workflows.transcript',
ogImage: '/images/demos/community-workflows-og.png',
thumbnail: '/images/demos/community-workflows-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['getting-started', 'community', 'workflow', 'hub'],
publishedDate: '2026-05-04',
modifiedDate: '2026-05-04',
aspectRatio: 1.931
modifiedDate: '2026-04-19'
}
]

View File

@@ -1,5 +0,0 @@
{
"fetchedAt": "2026-05-11T21:41:14.168Z",
"repository": "Comfy-Org/ComfyUI",
"stargazersCount": 112467
}

View File

@@ -1,23 +0,0 @@
export interface GitHubStarsSnapshot {
fetchedAt: string
repository: 'Comfy-Org/ComfyUI'
stargazersCount: number
}
export function isGitHubStarsSnapshot(
value: unknown
): value is GitHubStarsSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as {
fetchedAt?: unknown
repository?: unknown
stargazersCount?: unknown
}
return (
typeof candidate.fetchedAt === 'string' &&
candidate.repository === 'Comfy-Org/ComfyUI' &&
typeof candidate.stargazersCount === 'number' &&
Number.isSafeInteger(candidate.stargazersCount) &&
candidate.stargazersCount >= 0
)
}

View File

@@ -3570,20 +3570,6 @@ const translations = {
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
},
'demos.community-workflows.title': {
en: 'Explore and Use a Community Workflow from the Hub',
'zh-CN': '探索并使用社区工作流'
},
'demos.community-workflows.description': {
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
},
'demos.community-workflows.transcript': {
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
'zh-CN':
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
},
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },

View File

@@ -5,8 +5,7 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { fetchGitHubStarsForBuild, formatStarCount } from '../utils/github'
import { reportGitHubStarsOutcome } from '../utils/github.ci'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
title: string
@@ -31,17 +30,8 @@ const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const githubStarsOutcome = await fetchGitHubStarsForBuild()
reportGitHubStarsOutcome(githubStarsOutcome)
if (githubStarsOutcome.status === 'failed') {
throw new Error(
`GitHub stars fetch failed and no snapshot is available. Reason: ${githubStarsOutcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website github-stars:refresh-snapshot` locally and commit the snapshot.'
)
}
const githubStars = formatStarCount(githubStarsOutcome.snapshot.stargazersCount)
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD

View File

@@ -121,7 +121,6 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
client:load
/>

View File

@@ -122,7 +122,6 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
locale="zh-CN"
client:load
/>

View File

@@ -101,13 +101,13 @@
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
transform: translateX(-50%);
}
}
@keyframes marquee-reverse {
0% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
transform: translateX(-50%);
}
100% {
transform: translateX(0);
@@ -115,15 +115,11 @@
}
@utility animate-marquee {
@media (prefers-reduced-motion: no-preference) {
animation: marquee 30s linear infinite;
}
animation: marquee 30s linear infinite;
}
@utility animate-marquee-reverse {
@media (prefers-reduced-motion: no-preference) {
animation: marquee-reverse 30s linear infinite;
}
animation: marquee-reverse 30s linear infinite;
}
@keyframes ripple-effect {

View File

@@ -1,31 +1,39 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './ashby'
import {
createBuildDataReporter,
describeSnapshotAge,
escapeAnnotation
} from './buildDataReporter'
let hasReported = false
const ashbyReporter = createBuildDataReporter({
summaryHeading: '## 💼 Careers (Ashby)\n',
buildAnnotations,
buildSummaryRows
})
export function resetAshbyReporterForTests(): void {
hasReported = false
}
export const resetAshbyReporterForTests = ashbyReporter.resetForTests
export const reportAshbyOutcome = ashbyReporter.report
export function reportAshbyOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
const drops = outcome.droppedRoles
.map(
(d) =>
` - ${escapeAnnotation(
d.title ? `"${d.title}"` : '(untitled)'
)}: ${escapeAnnotation(d.reason)}`
)
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
.join('%0A')
return [
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
@@ -55,7 +63,12 @@ function staleAnnotation(reason: string): string {
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
}
function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
function escapeAnnotation(value: string): string {
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## 💼 Careers (Ashby)\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
@@ -82,5 +95,19 @@ function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
rows.push(['Reason', outcome.reason])
}
return rows
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -9,24 +9,25 @@ import {
AshbyJobBoardResponseSchema,
AshbyJobPostingSchema
} from './ashby.schema'
import type { BuildDataFetchResult, BuildDataOutcome } from './buildDataSource'
import { createBuildDataSource } from './buildDataSource'
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
interface DroppedRole {
export interface DroppedRole {
title: string
reason: string
}
interface FreshRolesData {
droppedCount: number
droppedRoles: DroppedRole[]
}
export type FetchOutcome = BuildDataOutcome<RolesSnapshot, FreshRolesData>
export type FetchOutcome =
| {
status: 'fresh'
snapshot: RolesSnapshot
droppedCount: number
droppedRoles: DroppedRole[]
}
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchRolesOptions {
apiKey?: string
@@ -39,50 +40,56 @@ interface FetchRolesOptions {
sleep?: (ms: number) => Promise<void>
}
const ashbySource = createBuildDataSource<
FetchRolesOptions,
RolesSnapshot,
FreshRolesData
>({
name: 'Ashby roles',
fetchFresh: fetchFreshRoles,
readSnapshot: (options) => readSnapshot(options.snapshotUrl),
getCacheKey: getAshbyCacheKey
})
let inflight: Promise<FetchOutcome> | undefined
export const resetAshbyFetcherForTests = ashbySource.resetForTests
export const fetchRolesForBuild = ashbySource.fetchForBuild
export function resetAshbyFetcherForTests(): void {
inflight = undefined
}
async function fetchFreshRoles(
export function fetchRolesForBuild(
options: FetchRolesOptions = {}
): Promise<FetchOutcome> {
inflight ??= doFetchRolesForBuild(options)
return inflight
}
async function doFetchRolesForBuild(
options: FetchRolesOptions
): Promise<BuildDataFetchResult<RolesSnapshot, FreshRolesData>> {
): Promise<FetchOutcome> {
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
const boardName =
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
if (!apiKey || !boardName) {
return {
kind: 'err',
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME'
}
return fallback(
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
options.snapshotUrl
)
}
const result = await tryFetchAndParse(apiKey, boardName, options)
if (result.kind === 'ok') {
return {
kind: 'ok',
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
departments: result.departments
},
data: {
droppedCount: result.droppedRoles.length,
droppedRoles: result.droppedRoles
}
droppedCount: result.droppedRoles.length,
droppedRoles: result.droppedRoles
}
}
return result
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
@@ -290,15 +297,3 @@ function isRolesSnapshot(value: unknown): value is RolesSnapshot {
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getAshbyCacheKey(options: FetchRolesOptions): string {
return JSON.stringify({
apiKey: options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY ?? '',
boardName:
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME ?? '',
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
retryDelaysMs: options.retryDelaysMs ?? RETRY_DELAYS_MS,
snapshotUrl: options.snapshotUrl?.href ?? ''
})
}

View File

@@ -1,78 +0,0 @@
import { appendFileSync } from 'node:fs'
import type { BuildDataOutcome } from './buildDataSource'
type SummaryRows = Array<[string, string]>
interface BuildDataReporterConfig<
TSnapshot,
TFreshData extends object = object
> {
summaryHeading: string
buildAnnotations: (
outcome: BuildDataOutcome<TSnapshot, TFreshData>
) => string[]
buildSummaryRows: (
outcome: BuildDataOutcome<TSnapshot, TFreshData>
) => SummaryRows
}
export function createBuildDataReporter<
TSnapshot,
TFreshData extends object = object
>(config: BuildDataReporterConfig<TSnapshot, TFreshData>) {
let hasReported = false
function resetForTests(): void {
hasReported = false
}
function report(outcome: BuildDataOutcome<TSnapshot, TFreshData>): void {
if (hasReported) return
hasReported = true
const lines = config.buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(
summaryPath,
buildStepSummary(
config.summaryHeading,
config.buildSummaryRows(outcome)
)
)
} catch {
// Best-effort only; a missing local summary file must not fail a build.
}
}
}
return { report, resetForTests }
}
export function escapeAnnotation(value: string): string {
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
}
export function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}
function buildStepSummary(heading: string, rows: SummaryRows): string {
const table =
'| | |\n|---|---|\n' +
rows.map(([key, value]) => `| **${key}** | ${value} |`).join('\n') +
'\n'
return `${heading}${table}\n`
}

View File

@@ -1,82 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createBuildDataSource } from './buildDataSource'
interface TestOptions {
cacheKey?: string
snapshot?: string
result?: 'fresh' | 'stale' | 'failed'
}
describe('createBuildDataSource', () => {
it('returns fresh data without reading the snapshot', async () => {
const readSnapshot = vi.fn(async () => 'snapshot')
const source = createBuildDataSource<TestOptions, string>({
name: 'test',
fetchFresh: async () => ({
kind: 'ok',
snapshot: 'fresh',
data: {}
}),
readSnapshot
})
await expect(source.fetchForBuild()).resolves.toEqual({
status: 'fresh',
snapshot: 'fresh'
})
expect(readSnapshot).not.toHaveBeenCalled()
})
it('falls back to a snapshot when fresh fetch fails', async () => {
const source = createBuildDataSource<TestOptions, string>({
name: 'test',
fetchFresh: async () => ({ kind: 'err', reason: 'HTTP 500' }),
readSnapshot: async (options) => options.snapshot ?? null
})
await expect(
source.fetchForBuild({ snapshot: 'last-known-good' })
).resolves.toEqual({
status: 'stale',
snapshot: 'last-known-good',
reason: 'HTTP 500'
})
})
it('returns failed when fresh fetch and snapshot are unavailable', async () => {
const source = createBuildDataSource<TestOptions, string>({
name: 'test',
fetchFresh: async () => ({ kind: 'err', reason: 'HTTP 500' }),
readSnapshot: async () => null
})
await expect(source.fetchForBuild()).resolves.toEqual({
status: 'failed',
reason: 'HTTP 500'
})
})
it('memoizes matching cache keys and rejects mismatched cache keys', async () => {
const fetchFresh = vi.fn(async () => ({
kind: 'ok' as const,
snapshot: 'fresh',
data: {}
}))
const source = createBuildDataSource<TestOptions, string>({
name: 'test',
fetchFresh,
readSnapshot: async () => null,
getCacheKey: (options) => options.cacheKey ?? 'default'
})
const first = await source.fetchForBuild({ cacheKey: 'a' })
const second = await source.fetchForBuild({ cacheKey: 'a' })
expect(first).toBe(second)
expect(fetchFresh).toHaveBeenCalledTimes(1)
expect(() => source.fetchForBuild({ cacheKey: 'b' })).toThrow(
/called twice with different options/
)
})
})

View File

@@ -1,83 +0,0 @@
export type BuildDataOutcome<TSnapshot, TFreshData extends object = object> =
| ({ status: 'fresh'; snapshot: TSnapshot } & TFreshData)
| { status: 'stale'; snapshot: TSnapshot; reason: string }
| { status: 'failed'; reason: string }
export type BuildDataFetchResult<
TSnapshot,
TFreshData extends object = object
> =
| { kind: 'ok'; snapshot: TSnapshot; data: TFreshData }
| { kind: 'err'; reason: string }
interface BuildDataSourceConfig<
TOptions extends object,
TSnapshot,
TFreshData extends object
> {
name: string
fetchFresh: (
options: TOptions
) => Promise<BuildDataFetchResult<TSnapshot, TFreshData>>
readSnapshot: (options: TOptions) => Promise<TSnapshot | null>
getCacheKey?: (options: TOptions) => string
}
export function createBuildDataSource<
TOptions extends object,
TSnapshot,
TFreshData extends object = object
>(config: BuildDataSourceConfig<TOptions, TSnapshot, TFreshData>) {
let inflight: Promise<BuildDataOutcome<TSnapshot, TFreshData>> | undefined
let inflightCacheKey: string | undefined
function resetForTests(): void {
inflight = undefined
inflightCacheKey = undefined
}
function fetchForBuild(
options = {} as TOptions
): Promise<BuildDataOutcome<TSnapshot, TFreshData>> {
const cacheKey = config.getCacheKey?.(options) ?? 'default'
if (inflight) {
if (inflightCacheKey !== cacheKey) {
throw new Error(
`${config.name} fetcher called twice with different options; reset between distinct configurations`
)
}
return inflight
}
inflightCacheKey = cacheKey
inflight = doFetchForBuild(config, options)
return inflight
}
return { fetchForBuild, resetForTests }
}
async function doFetchForBuild<
TOptions extends object,
TSnapshot,
TFreshData extends object
>(
config: BuildDataSourceConfig<TOptions, TSnapshot, TFreshData>,
options: TOptions
): Promise<BuildDataOutcome<TSnapshot, TFreshData>> {
const result = await config.fetchFresh(options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: result.snapshot,
...result.data
}
}
const snapshot = await config.readSnapshot(options)
if (snapshot) {
return { status: 'stale', snapshot, reason: result.reason }
}
return { status: 'failed', reason: result.reason }
}

View File

@@ -1,83 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './github'
import {
reportGitHubStarsOutcome,
resetGitHubStarsReporterForTests
} from './github.ci'
function snapshot() {
return {
fetchedAt: new Date().toISOString(),
repository: 'Comfy-Org/ComfyUI' as const,
stargazersCount: 112464
}
}
function freshOutcome(): FetchOutcome {
return {
status: 'fresh',
snapshot: snapshot()
}
}
describe('reportGitHubStarsOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetGitHubStarsReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'github-stars-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits no annotation on a fresh outcome', () => {
reportGitHubStarsOutcome(freshOutcome())
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one stale warning across repeated calls', () => {
const outcome: FetchOutcome = {
status: 'stale',
reason: 'HTTP 403 rate limited',
snapshot: snapshot()
}
reportGitHubStarsOutcome(outcome)
reportGitHubStarsOutcome(outcome)
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=GitHub stars unavailable')
expect(readFileSync(summaryPath, 'utf8')).toContain('Stale')
})
it('emits an error for failed outcomes', () => {
reportGitHubStarsOutcome({
status: 'failed',
reason: 'HTTP 500 Server Error'
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=GitHub stars fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
})

View File

@@ -1,56 +0,0 @@
import type { FetchOutcome } from './github'
import {
createBuildDataReporter,
describeSnapshotAge,
escapeAnnotation
} from './buildDataReporter'
const githubStarsReporter = createBuildDataReporter({
summaryHeading: '## GitHub stars\n',
buildAnnotations,
buildSummaryRows
})
export const resetGitHubStarsReporterForTests =
githubStarsReporter.resetForTests
export const reportGitHubStarsOutcome = githubStarsReporter.report
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') return []
if (outcome.status === 'stale') {
return [
`::warning title=GitHub stars unavailable::${escapeAnnotation(
outcome.reason
)}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check GitHub API availability/rate limits.%0A 2. Re-run the build once GitHub is healthy.%0A 3. To refresh the fallback value, run \`pnpm --filter @comfyorg/website github-stars:refresh-snapshot\` and commit apps/website/src/data/github-stars.snapshot.json.`
]
}
return [
`::error title=GitHub stars fetch failed and no snapshot is available::Cannot render website navigation stars.%0A%0AReason: ${escapeAnnotation(
outcome.reason
)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website github-stars:refresh-snapshot\`.%0A 2. Commit apps/website/src/data/github-stars.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', 'Fresh (fetched from GitHub)'])
rows.push(['Repository', outcome.snapshot.repository])
rows.push(['Stars', String(outcome.snapshot.stargazersCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', 'Stale (using snapshot - GitHub fetch failed)'])
rows.push(['Repository', outcome.snapshot.repository])
rows.push(['Stars', String(outcome.snapshot.stargazersCount)])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', 'Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
return rows
}

View File

@@ -1,50 +1,10 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubStarsSnapshot } from '../data/githubStars'
import {
fetchGitHubStars,
fetchGitHubStarsForBuild,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
const base: ResponseInit = {
status: 200,
headers: { 'content-type': 'application/json' }
}
return new Response(JSON.stringify(body), { ...base, ...init })
}
function makeSnapshot(stargazersCount = 111_605): GitHubStarsSnapshot {
return {
fetchedAt: '2026-05-06T00:00:00.000Z',
repository: 'Comfy-Org/ComfyUI',
stargazersCount
}
}
function withSnapshotDir(snapshot: GitHubStarsSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'github-stars-test-'))
const file = join(dir, 'github-stars.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
import { fetchGitHubStars, formatStarCount } from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
beforeEach(() => {
resetGitHubStarsFetcherForTests()
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
})
afterEach(() => {
vi.restoreAllMocks()
if (savedOverride === undefined)
@@ -56,86 +16,16 @@ describe('fetchGitHubStars', () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars()).resolves.toBe(110000)
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when the build-time override is malformed', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars()).rejects.toThrow(
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
expect(fetchMock).not.toHaveBeenCalled()
})
it('returns fresh stars when the API succeeds', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({ stargazers_count: 112464 })
)
const outcome = await fetchGitHubStarsForBuild({ fetchImpl })
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.repository).toBe('Comfy-Org/ComfyUI')
expect(outcome.snapshot.stargazersCount).toBe(112464)
})
it('memoizes build-time star fetches within a single process', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({ stargazers_count: 110000 })
)
const [a, b] = await Promise.all([
fetchGitHubStarsForBuild({ fetchImpl }),
fetchGitHubStarsForBuild({ fetchImpl })
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('falls back to the committed snapshot when GitHub fetch fails', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({}, { status: 403 })
)
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 403/)
expect(outcome.snapshot.stargazersCount).toBe(111605)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when both GitHub fetch and snapshot are unavailable', async () => {
const snapshotUrl = withSnapshotDir(null)
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({}, { status: 403 })
)
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('rejects invalid numeric star counts before caching them', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({ stargazers_count: -1 })
)
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^response schema validation failed/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
})

View File

@@ -1,129 +1,22 @@
import { readFile } from 'node:fs/promises'
import type { GitHubStarsSnapshot } from '../data/githubStars'
import type { BuildDataFetchResult, BuildDataOutcome } from './buildDataSource'
import bundledSnapshot from '../data/github-stars.snapshot.json' with { type: 'json' }
import { isGitHubStarsSnapshot } from '../data/githubStars'
import { createBuildDataSource } from './buildDataSource'
const GITHUB_REPOSITORY = 'Comfy-Org/ComfyUI'
const GITHUB_REPO_API_URL = `https://api.github.com/repos/${GITHUB_REPOSITORY}`
export type FetchOutcome = BuildDataOutcome<GitHubStarsSnapshot>
interface FetchGitHubStarsOptions {
fetchImpl?: typeof fetch
snapshotUrl?: URL
}
const githubStarsSource = createBuildDataSource<
FetchGitHubStarsOptions,
GitHubStarsSnapshot
>({
name: 'GitHub stars',
fetchFresh: fetchFreshGitHubStars,
readSnapshot: (options) => readSnapshot(options.snapshotUrl),
getCacheKey: getGitHubStarsCacheKey
})
export const resetGitHubStarsFetcherForTests = githubStarsSource.resetForTests
export const fetchGitHubStarsForBuild = githubStarsSource.fetchForBuild
export async function fetchGitHubStars(
fetchImpl: typeof fetch = fetch
owner: string,
repo: string
): Promise<number | null> {
const result = await fetchFreshGitHubStars({ fetchImpl })
if (result.kind === 'err') return null
return result.snapshot.stargazersCount
}
async function fetchFreshGitHubStars(
options: FetchGitHubStarsOptions
): Promise<BuildDataFetchResult<GitHubStarsSnapshot>> {
const override = readGitHubStarsOverride()
if (override !== undefined) {
return {
kind: 'ok',
snapshot: makeSnapshot(override),
data: {}
}
}
if (override !== undefined) return override
const response = await callOnce(options.fetchImpl ?? fetch)
if (response.kind === 'err') return response
const count = readStargazerCount(response.body)
if (count === null) {
return {
kind: 'err',
reason:
'response schema validation failed: stargazers_count must be a non-negative safe integer'
}
}
return {
kind: 'ok',
snapshot: makeSnapshot(count),
data: {}
}
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string }
async function callOnce(fetchImpl: typeof fetch): Promise<CallResponse> {
try {
const res = await fetchImpl(GITHUB_REPO_API_URL, {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
if (res.ok) return { kind: 'ok', body: await res.json() }
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim()
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason }
}
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<GitHubStarsSnapshot | null> {
if (!snapshotUrl) {
return isGitHubStarsSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isGitHubStarsSnapshot(parsed)) return parsed
return null
if (!res.ok) return null
const data = await res.json()
return data.stargazers_count ?? null
} catch {
return null
}
}
function makeSnapshot(stargazersCount: number): GitHubStarsSnapshot {
return {
fetchedAt: new Date().toISOString(),
repository: GITHUB_REPOSITORY,
stargazersCount
}
}
function readStargazerCount(data: unknown): number | null {
if (data === null || typeof data !== 'object') return null
const count = (data as { stargazers_count?: unknown }).stargazers_count
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
? count
: null
}
export function formatStarCount(count: number): string {
if (count >= 1_000_000) {
const m = count / 1_000_000
@@ -149,10 +42,3 @@ function readGitHubStarsOverride(): number | undefined {
return count
}
function getGitHubStarsCacheKey(options: FetchGitHubStarsOptions): string {
return JSON.stringify({
override: process.env.WEBSITE_GITHUB_STARS_OVERRIDE ?? '',
snapshotUrl: options.snapshotUrl?.href ?? ''
})
}

View File

@@ -1,42 +0,0 @@
{
"last_node_id": 10,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -190,9 +190,6 @@ export class ComfyPage {
/** Worker index to test user ID */
public readonly userIds: string[] = []
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
public isVueNodes = false
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
@@ -285,10 +282,12 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true
mockReleases = true,
url
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -320,7 +319,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto()
await this.goto({ url })
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -347,8 +346,8 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto() {
await this.page.goto(this.url)
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
}
async nextFrame() {
@@ -503,7 +502,6 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({

View File

@@ -20,7 +20,6 @@ export class ContextMenu {
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
await this.waitForHidden()
}
/**

View File

@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {

View File

@@ -82,7 +82,7 @@ export class Topbar {
}
getSaveDialog(): Locator {
return this.page.getByRole('dialog').getByRole('textbox')
return this.page.locator('.p-dialog-content input')
}
saveWorkflow(workflowName: string): Promise<void> {
@@ -116,9 +116,9 @@ export class Topbar {
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}

View File

@@ -1,12 +0,0 @@
import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
constructor(public readonly root: Locator) {
this.selection = root.locator('button span span')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}
}

View File

@@ -9,15 +9,13 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly mobile: MobileAppHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
@@ -62,16 +60,13 @@ export class AppModeHelper {
public readonly vueNodeSwitchDismissButton: Locator
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
/** The main content area where outputs are displayed*/
public readonly centerPanel: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.mobile = new MobileAppHelper(comfyPage)
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
@@ -130,7 +125,6 @@ export class AppModeHelper {
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
}
private get page(): Page {

View File

@@ -127,7 +127,9 @@ export class BuilderSelectHelper {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })

View File

@@ -6,6 +6,71 @@ import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
function readFilePayload(filePath: string) {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
return { bufferArray, fileName, fileType }
}
async function dispatchFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const target = document.activeElement ?? document
target.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
}, payload)
}
async function interceptNextFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
document.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
},
{ capture: true, once: true }
)
}, payload)
}
type PasteFileOptions = {
mode?: 'keyboard' | 'direct'
}
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
@@ -20,43 +85,20 @@ export class ClipboardHelper {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
async pasteFile(
filePath: string,
{ mode = 'keyboard' }: PasteFileOptions = {}
): Promise<void> {
const payload = readFilePayload(filePath)
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
if (mode === 'keyboard') {
await interceptNextFilePaste(this.page, payload)
await this.paste()
return
}
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
// Dispatch the app-level paste event with file clipboardData directly.
await dispatchFilePaste(this.page, payload)
}
}

View File

@@ -1,5 +1,4 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -14,7 +13,6 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -24,14 +22,13 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !filePath && !url)
throw new Error('Must provide fileName, filePath, or url')
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
const evaluateParams: {
dropPosition: Position
@@ -42,22 +39,12 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName || filePath) {
const resolvedPath = filePath ?? assetPath(fileName!)
const displayName = fileName ?? basename(resolvedPath)
let buffer: Buffer
try {
buffer = readFileSync(resolvedPath)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
{ cause: error }
)
}
if (fileName) {
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
evaluateParams.fileName = displayName
evaluateParams.fileType = getMimeType(displayName)
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -161,13 +148,6 @@ export class DragDropHelper {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropFilePath(
filePath: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ filePath, ...options })
}
async dragAndDropURL(
url: string,
options: {

View File

@@ -1,10 +1,6 @@
import type { WebSocketRoute } from '@playwright/test'
import type {
NodeError,
NodeProgressState,
PromptResponse
} from '@/schemas/apiSchema'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
@@ -234,16 +230,6 @@ export class ExecutionHelper {
)
}
/** Send `progress_state` WS event with per-node execution state. */
progressState(jobId: string, nodes: Record<string, NodeProgressState>): void {
this.requireWs().send(
JSON.stringify({
type: 'progress_state',
data: { prompt_id: jobId, nodes }
})
)
}
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.

View File

@@ -1,19 +1,26 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import type { Page, Route, WebSocketRoute } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
const RAW_LOGS_URL = '**/internal/logs/raw**'
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
async mockRawLogs(messages: string[]): Promise<() => number> {
let count = 0
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
)
})
return () => count
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
@@ -21,7 +28,8 @@ export class LogsTerminalHelper {
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
await pending
await route.fulfill({
status: 200,
@@ -33,15 +41,39 @@ export class LogsTerminalHelper {
}
async mockRawLogsError() {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
async mockSubscribeLogs(): Promise<() => number> {
let count = 0
await this.page.unroute(SUBSCRIBE_LOGS_URL)
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({ status: 200, body: '' })
})
return () => count
}
/**
* Force the frontend to reconnect by closing the proxied WebSocket. The
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
* handler fires again, and on `open` with `isReconnect=true` it dispatches
* `'reconnected'`, which triggers the logs-terminal resync.
*
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
* the time the count goes up, the new socket is open and resync has
* completed enough to assert against the terminal.
*/
async triggerReconnect(
ws: WebSocketRoute,
subscribeFetches: () => number
): Promise<void> {
const before = subscribeFetches()
await ws.close()
await expect.poll(subscribeFetches).toBeGreaterThan(before)
}
static buildWsLogFrame(messages: string[]): string {

View File

@@ -1,33 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class MobileAppHelper {
private readonly page: Page
readonly contentPanel: Locator
readonly navigation: Locator
readonly navigationTabs: Locator
readonly view: Locator
readonly workflows: Locator
constructor(comfyPage: ComfyPage) {
this.page = comfyPage.page
this.view = this.page.getByTestId(TestIds.linear.mobile)
this.contentPanel = this.page.getByRole('tabpanel')
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
this.navigationTabs = this.navigation.getByRole('tab')
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
}
async switchWorkflow(workflowName: string) {
await this.workflows.click()
await this.page.getByRole('menu').getByText(workflowName).click()
}
async navigateTab(name: 'run' | 'outputs' | 'assets') {
await this.navigation.getByRole('tab', { name }).click()
}
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
for (let i = 0; i < count; i++) await locator.tap()
}
}

View File

@@ -18,7 +18,9 @@ export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
}
private get page() {

View File

@@ -362,9 +362,6 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
if (this.comfyPage.isVueNodes) {
await this.comfyPage.vueNodes.waitForNodes()
}
}
async countGraphPseudoPreviewEntries(): Promise<number> {

View File

@@ -8,6 +8,7 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -75,7 +76,15 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog'
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -144,14 +153,6 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
widgetContainer: 'linear-widgets'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',

View File

@@ -0,0 +1,250 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -7,9 +7,6 @@ export function getMimeType(fileName: string): string {
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.mp3')) return 'audio/mpeg'
if (name.endsWith('.flac')) return 'audio/flac'
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'

View File

@@ -1,7 +1,3 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}
export function metadataFixturePath(fileName: string): string {
return `./src/scripts/metadata/__fixtures__/${fileName}`
}

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -13,8 +13,6 @@ export class VueNodeFixture {
public readonly collapseButton: Locator
public readonly collapseIcon: Locator
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -25,8 +23,6 @@ export class VueNodeFixture {
this.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
}
async getTitle(): Promise<string> {
@@ -43,16 +39,6 @@ export class VueNodeFixture {
await this.collapseButton.click()
}
/**
* Select this node and delete it via the Delete key, waiting for the node
* element to leave the DOM before resolving.
*/
async delete(): Promise<void> {
await this.header.click()
await this.header.press('Delete')
await this.locator.waitFor({ state: 'hidden' })
}
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}

View File

@@ -1,154 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
//an app without an image input will load the workflow
await test.step('App without an image input loads workflow', async () => {
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
await expect(centerPanel).toBeHidden()
})
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
await expect(centerPanel).toBeVisible()
await expect(imageInput.root).toBeVisible()
})
await test.step('Dragging an image redirects to image input', async () => {
const initialImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropExternalResource({
fileName: 'workflow.webp',
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
await expect(imageInput.selection).not.toHaveText(initialImage)
await expect(
centerPanel,
'A file with workflow should not open a new workflow'
).toBeVisible()
})
await test.step('Dragging a url redirects to image input', async () => {
const secondImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({
filename: 'og-image.png',
type: 'input'
})
await expect(imageInput.selection).not.toHaveText(secondImage)
})
})
test('Widget Interaction', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([
['3', 'seed'],
['3', 'sampler_name'],
['6', 'text']
])
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
exact: true
})
const { input, incrementButton, decrementButton } =
comfyPage.vueNodes.getInputNumberControls(seed)
const initialValue = Number(await input.inputValue())
await seed.dragTo(incrementButton, { steps: 5 })
const intermediateValue = Number(await input.inputValue())
expect(intermediateValue).toBeGreaterThan(initialValue)
await seed.dragTo(decrementButton, { steps: 5 })
const endValue = Number(await input.inputValue())
expect(endValue).toBeLessThan(intermediateValue)
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
exact: true
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')
//verify values are consistent with litegraph
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
await expect(mobile.view).toBeVisible()
await expect(mobile.navigation).toBeVisible()
await mobile.navigateTab('assets')
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
const buttons = await mobile.navigationTabs.all()
await buttons[0].dragTo(buttons[2], { steps: 5 })
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
await mobile.navigateTab('run')
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
const steps = comfyPage.page.getByRole('spinbutton')
const initialValue = Number(await steps.inputValue())
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'increment' }),
{ count: 5 }
)
await expect(steps).toHaveValue(String(initialValue + 5))
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'decrement' }),
{ count: 3 }
)
await expect(steps).toHaveValue(String(initialValue + 2))
})
test('workflow selection', async ({ comfyPage }) => {
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
for (const name of widgetNames)
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
const widgets = comfyPage.appMode.linearWidgets
await comfyPage.appMode.mobile.navigateTab('run')
for (let i = 0; i < widgetNames.length; i++) {
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
}
})
})
})

View File

@@ -1,121 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode builder selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Can independently select inputs of same name', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.vueNodes.selectNodes(['6', '7'])
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
const prompts = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.locator('.lg-node-widget')
const count = await prompts.count()
for (let i = 0; i < count; i++) {
await expect(prompts.nth(i)).toBeVisible()
await prompts.nth(i).click()
await expect(items).toHaveCount(i + 1)
}
})
test('Can select outputs', async ({ comfyPage }) => {
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToOutputs()
await comfyPage.nodeOps
.getNodeRefById('9')
.then((ref) => ref.centerOnNode())
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
await saveImage.click()
const items = comfyPage.appMode.select.inputItems
await expect(items).toHaveCount(1)
})
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
//Manually set error state on checkpoint loader
//Shouldn't be needed on ci, but has spotty reliability
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget(
'Load Checkpoint',
'ckpt_name'
)
await expect(items).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
await expect(items).toHaveCount(0)
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is initially editable'
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await ksampler.header.dblclick({ force: true })
await expect(
ksampler.titleEditor.input,
'Double clicking node titles will not initiate a rename'
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toHaveCount(1)
})
})

View File

@@ -147,5 +147,68 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
test('resyncs the terminal when the WebSocket reconnects', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
const initialLine = 'pre-reboot log line'
const postRebootLineA = 'post-reboot line A'
const postRebootLineB = 'post-reboot line B'
await logsTerminal.mockRawLogs([initialLine])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
initialLine
)
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineA
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineB
)
// reset() before write means the pre-reboot line must be gone.
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
initialLine
)
})
test('resumes WebSocket log streaming after the reconnect', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs(['initial'])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
'initial'
)
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
// The route handler fires again on the new connection; pull the latest
// WebSocketRoute and push a live frame to prove the 'logs' listener
// survived the reconnect.
const liveLine = 'live log emitted after the reconnect'
const newWs = await getWebSocket()
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
liveLine
)
})
})
})

View File

@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.keyboard.press('Control+s')
// The Save As dialog should appear
const saveDialog = comfyPage.page.getByRole('dialog')
await expect(saveDialog).toBeVisible()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')

View File

@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {

View File

@@ -0,0 +1,47 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
//
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
// must render translated strings, not literal i18n keys like
// 'sideToolbar.labels.assets'.
test.describe('i18n locale fallback', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.addInitScript(() => {
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true
})
Object.defineProperty(navigator, 'languages', {
value: ['de-DE', 'de'],
configurable: true
})
})
// Default sidebar size on small viewports hides labels; force normal so
// .side-bar-button-label is rendered for the assertion.
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.page.reload()
await comfyPage.waitForAppReady()
})
test('sidebar labels render translated strings, not raw i18n keys', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.setViewportSize({ width: 1920, height: 1080 })
const labelTexts = await page
.getByTestId('side-toolbar')
.locator('.side-bar-button-label')
.allTextContents()
expect(labelTexts.length).toBeGreaterThan(0)
for (const text of labelTexts) {
expect(text).not.toContain('sideToolbar.labels')
}
})
})

View File

@@ -1,62 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
type MetadataFixture = {
fileName: string
parser: string
}
// Each fixture embeds the same single-KSampler workflow (see
// scripts/generate-embedded-metadata-test-files.py), exercising a different
// parser in src/scripts/metadata/. Dropping the file should import that
// workflow.
const FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_metadata.png', parser: 'png' },
{ fileName: 'with_metadata.avif', parser: 'avif' },
{ fileName: 'with_metadata.webp', parser: 'webp' },
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
{ fileName: 'with_metadata.flac', parser: 'flac' },
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
{ fileName: 'with_metadata.opus', parser: 'ogg' },
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
] as const
test.describe(
'Metadata drop-to-load workflow import',
{ tag: ['@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
})
for (const { fileName, parser } of FIXTURES) {
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
comfyPage
}) => {
await test.step(`drop ${fileName} on canvas`, async () => {
await comfyPage.dragDrop.dragAndDropFilePath(
metadataFixturePath(fileName)
)
})
await test.step('graph contains only the embedded KSampler', async () => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(1)
const ksamplers =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
expect(
ksamplers,
'exactly one KSampler should have been loaded from the fixture'
).toHaveLength(1)
})
})
}
}
)

View File

@@ -8,9 +8,6 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const DEPRECATED_NODE_TYPE = 'ImageBatch'
const API_NODE_TYPE = 'FluxProUltraImageNode'
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
test('Can add badge', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
@@ -144,73 +141,3 @@ test.describe(
})
}
)
for (const vueEnabled of [false, true] as const) {
const renderer = vueEnabled ? 'vue' : 'classic'
const tag = vueEnabled
? ['@vue-nodes', '@screenshot', '@node']
: ['@screenshot', '@node']
test.describe(`Node lifecycle badge (${renderer})`, { tag }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
})
for (const mode of [NodeBadgeMode.ShowAll, NodeBadgeMode.None] as const) {
test(`renders deprecated node with mode=${mode}`, async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
mode
)
await comfyPage.nodeOps.clearGraph()
await comfyPage.nodeOps.addNode(DEPRECATED_NODE_TYPE, undefined, {
x: 100,
y: 100
})
await comfyPage.canvasOps.resetView()
await expect(comfyPage.canvas).toHaveScreenshot(
`node-lifecycle-${mode}-${renderer}.png`
)
})
}
})
test.describe(`API pricing badge (${renderer})`, { tag }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
await comfyPage.page.evaluate((type) => {
const registered = window.LiteGraph!.registered_node_types[type] as {
nodeData?: { price_badge?: unknown }
}
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
registered.nodeData.price_badge = {
engine: 'jsonata',
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
}, API_NODE_TYPE)
})
for (const enabled of [true, false] as const) {
test(`renders api node with showApiPricing=${enabled}`, async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.ShowApiPricing',
enabled
)
await comfyPage.nodeOps.clearGraph()
await comfyPage.nodeOps.addNode(API_NODE_TYPE, undefined, {
x: 100,
y: 100
})
await comfyPage.canvasOps.resetView()
await expect(comfyPage.canvas).toHaveScreenshot(
`api-pricing-${enabled ? 'on' : 'off'}-${renderer}.png`
)
})
}
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,65 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -692,27 +692,19 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Controls stack label above widget in compact mode', async ({
test('Controls collapse to single column in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
const brushButton = painterWidget.getByText('Brush', { exact: true })
await expect(
toolLabel,
'tool label should be visible in wide layout'
'tool label should be visible in two-column layout'
).toBeVisible()
const wideLabelBox = await toolLabel.boundingBox()
const wideBrushBox = await brushButton.boundingBox()
expect(
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
'label should sit to the left of the brush button in wide layout'
).toBe(true)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
@@ -724,22 +716,8 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(
toolLabel,
'tool label should remain visible in compact layout'
).toBeVisible()
await expect
.poll(
async () => {
const labelBox = await toolLabel.boundingBox()
const brushBox = await brushButton.boundingBox()
if (!labelBox || !brushBox) return false
return labelBox.y + labelBox.height <= brushBox.y
},
{
message: 'label should stack above the brush button in compact layout'
}
)
.toBe(true)
'tool label should hide in compact single-column layout'
).toBeHidden()
})
test('Multiple sequential strokes at different positions all accumulate', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -54,14 +54,44 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
.toBe(initialCount - 1)
})
test('info button opens properties panel', async ({ comfyPage }) => {
test('info button opens the right-side info tab in new menu mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(panel).toContainText('KSampler')
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('info button is hidden when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(
comfyPage.selectionToolbox.getByTestId('info-button')
).toBeHidden()
})
test('convert-to-subgraph button visible with multi-select', async ({

View File

@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -18,70 +19,19 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
await expect(nodeInfoButton).toBeHidden()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
@@ -90,11 +40,14 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -0,0 +1,145 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
sharedWorkflowImportMocks.resetAndStartRecording()
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup({
clearStorage: false,
url: `/?share=${sharedWorkflowImportScenario.shareId}`
})
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -120,4 +120,13 @@ test.describe('Node library sidebar V2', () => {
await expect(options.first()).toBeVisible()
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
})

View File

@@ -558,52 +558,5 @@ test.describe(
.toBe(0)
})
})
test.fail(
'Promoted text widget is removed when source node is deleted inside the subgraph',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.first()
await expect(subgraphNode).toBeVisible()
const subgraphNodeId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('text')
await expect(
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeVisible()
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await interiorClip.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNodeAfter =
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(subgraphNodeAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeHidden()
}
)
}
)

View File

@@ -106,6 +106,49 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?share=test-share-id'
})
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
@@ -131,48 +174,51 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Set locale to a language that doesn't have a template file
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
// Pick a shipped LTR locale and simulate its template index returning 404.
// (Previously this test used 'de', but unsupported locales are now
// clamped to 'en' at boot so they never hit the template fallback path.
// 'fa' would also work but flips document.dir to rtl, which can leak
// into adjacent specs in the same worker.)
const locale = 'tr'
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
await comfyPage.page.route(
`**/templates/index.${locale}.json`,
async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
}
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.settings.setSetting('Comfy.Locale', locale)
const localeRequestPromise = comfyPage.page.waitForRequest(
`**/templates/index.${locale}.json`
)
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const localeRequest = await localeRequestPromise
const englishRequest = await englishRequestPromise
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
expect(englishRequest.url()).toContain('templates/index.json')
// Verify English titles are shown as fallback
await expect(
comfyPage.page.getByRole('main').getByText('All Templates')
).toBeVisible()
// Assert on rendered content, not just the container — the container
// testid is present even when the dialog body is empty, which would let
// a regression where the fallback fetch succeeds but no cards render
// pass silently.
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({

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