Compare commits

...

10 Commits

Author SHA1 Message Date
Alexander Brown
b87b5eba54 Merge branch 'main' into glary/model-info-panel-e2e 2026-05-18 20:01:24 -07:00
Christian Byrne
4931b0c4b2 fix(website): add dark-background favicon for legibility in search results (#12285)
*PR Created by the Glary-Bot Agent*

---

## Summary

The comfy.org favicon was reported as illegible in Google search
results. The current `logomark.svg` is a transparent yellow "C" — when
Google (or any client) composites it onto a white surface (search
results, light-theme tab strips), the yellow disappears into the
background.

Fix: ship a dedicated `/favicon.svg` that wraps the existing yellow
logomark in a solid black square, and point `<link rel="icon">` at it.
The in-page nav logo, `Organization.logo` Schema.org URL, and any other
consumer of `logomark.svg` are left untouched, so transparent-composite
contexts (knowledge panels, dark nav) continue to render cleanly.

## Changes

- `apps/website/public/favicon.svg` *(new)* — 48×48 SVG: black square +
scaled-down original logomark path. Existing path geometry is reused
verbatim inside a `<g transform>` so the C glyph is byte-identical to
the source.
- `apps/website/src/layouts/BaseLayout.astro` — `<link rel="icon"
href="/icons/logomark.svg">` → `<link rel="icon" href="/favicon.svg">`.
One-line change.

## Why a new file (vs editing `logomark.svg` in place)

`logomark.svg` is also used by `SiteNav.vue` (in-page header on the dark
`--color-primary-comfy-ink` background) and by the JSON-LD
`Organization.logo` URL. Both consumers want the transparent version.
Editing it in place would draw an ugly black square in the site's own
header.

## User report

> "just google searched comfyui and logo isnt legible. We should
update.."

## Verification

**Built site**
- `pnpm typecheck` (astro check): 0 errors, 0 warnings
- `pnpm build` (astro build): 280 pages built, exit 0
- Built `dist/index.html` contains exactly one `<link rel="icon"
href="/favicon.svg">` and zero references to the old icon path in
`<head>`
- `oxlint` on changed `.astro` file: 0 warnings, 0 errors

**Visual (Playwright on local astro dev server)**
- New favicon renders correctly at 16/32/64 px — yellow C centered on
black square, no clipping.
- In-page nav logo unchanged (yellow C floats cleanly on the dark
`--color-primary-comfy-ink` nav background, no black wrapper visible).
- Mock of Google search-result row shows the new favicon is
high-contrast inside Google's white circular wrapper; the old one is
nearly invisible.

## Screenshots

### Google-style search result simulation (before / after)
![Google search result simulation — before and
after](google-result-before-after)

### Favicon at native sizes + Google circular wrapper
![Favicon comparison at 16/32/64 px and inside a Google-style white
circle wrapper](favicon-comparison-preview)

### In-page nav header (unchanged after the fix)
![Site nav header showing the yellow Comfy logomark still sits cleanly
on the dark nav background](homepage-header-after)

## Notes for reviewers

- The change deliberately uses pure black `#000` (matching the user's
literal request "make the white background, black") rather than
`--color-primary-comfy-ink` (`#211927`). Either would work; happy to
switch if brand preference is the ink color.
- Search-engine cached favicons can take days/weeks to refresh on
Google's side after the new file is deployed.

## Screenshots


![google-result-before-after](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827200860-80e3877f-e1af-4cf3-962e-a1bf25ea9815.png)


![favicon-comparison-preview](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827201188-582fe0fb-aa7c-4fa1-af5b-fbc2a72387b7.png)


![homepage-header-after](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827201630-3a88502e-dd59-4e52-82a6-55f6b9768e4d.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12285-fix-website-add-dark-background-favicon-for-legibility-in-search-results-3616d73d365081babbcbedf0b86d3d67)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-18 19:24:16 -07:00
jaeone94
fc7e6a0935 fix(terminal): resync logs console on backend reconnect (#12270)
## Summary

When the built-in logs terminal stayed open during a backend restart,
the buffer froze on pre-restart entries and live log streaming silently
stopped — only closing and reopening the panel resynced. Listen for the
api `reconnected` event and rebuild the terminal contents the same way a
fresh open would.

## Changes

- **What**:
- Extract `useLogsTerminal` composable. The SFC is now a thin shell
holding `terminal: shallowRef<Terminal>` and forwarding to the
composable, so `onMounted`/`onScopeDispose` no longer rely on the
child's emit callback timing.
- Subscribe to `api`'s `reconnected` event via `useEventListener`,
registered synchronously before any awaits. On reconnect:
`terminal.reset()` → refetch raw logs → `scrollToBottom()` →
`subscribeLogs(true)` (the backend loses the per-client subscription on
restart, so re-subscribe is required for live streaming to resume).
- Wrap in-flight resync/mount fetches in AbortControllers. Overlapping
reconnects abort the prior resync, and unmount mid-fetch suppresses
writes to the disposed xterm.
- Hide BaseTerminal whenever `errorMessage` is set so the error layout
doesn't expose an empty xterm container behind the message;
`loading=false` after both load failure and resync success so a later
successful reconnect can clear a stuck spinner.
- Migrate the load/resync error strings to vue-i18n
(`logsTerminal.loadError`, `logsTerminal.resyncError`).

## Review Focus

- **Re-subscribe is the non-obvious half of the fix** — without it, even
after the WebSocket reconnects the backend never resumes streaming logs
to this client because its subscription state was wiped on restart. The
visible "stale buffer" is only one symptom; the silent "no new logs"
symptom needed the explicit `subscribeLogs(true)` re-call in resync.
- `terminal.reset()` lives after a successful raw-logs fetch (not
before) so a failed resync leaves the prior buffer visible instead of
blanking it; resync errors surface via the same inline error message the
mount path uses.
- 8 unit tests around the composable: mount + subscribe, resync ordering
(reset → write → scroll → subscribe via `invocationCallOrder`),
in-flight resync abort on double reconnect, resync error surfacing,
mount-failure-then-recovery, unmount-mid-fetch terminal-write
suppression, listener cleanup on unmount.
- 2 E2E tests using `ws.close()` on the proxied WebSocket as the
reconnect trigger and `subscribeLogs` HTTP fetch count as the sync point
(same pattern as `wsReconnectStaleJob.spec.ts`). Red-checked: disabling
the `reconnected` listener fails exactly the two new tests, all 8
pre-existing tests stay green.

Fixes FE-712

## Screenshots

Before - (After rebooting, the console window does not update from its
state before the reboot must remount the console window for it to
resync.)


https://github.com/user-attachments/assets/b1e49c2c-89a4-4a4a-82b4-064412acee12

After - (The console window syncs automatically after a reboot.)


https://github.com/user-attachments/assets/54b582c5-ad42-41c0-9886-18f4495859da




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12270-fix-terminal-resync-logs-console-on-backend-reconnect-3606d73d3650812fb13fd1934c632344)
by [Unito](https://www.unito.io)
2026-05-19 01:55:11 +00:00
Benjamin Lu
a97f46b497 test: cover job history sidebar with typed route mocks (#12272)
## Summary

Add the first product-area browser coverage on top of the merged typed
route mock foundation: the docked job history sidebar.

## Changes

- **What**: Adds `browser_tests/tests/sidebar/jobHistory.spec.ts` using
`jobsRouteFixture`.
- **What**: Covers direct sidebar entry, docked QPO history entry,
terminal history jobs, active queue jobs, tab filtering, search, clear
queue, and clear history.
- **What**: Adds typed `POST /api/queue` and `POST /api/history` route
helpers that validate request bodies with generated zod schemas.
- **What**: Adds stable test ids for the job history sidebar and queue
progress overlay so tests avoid structural CSS selectors.
- **Dependencies**: Builds on the typed route mock foundation merged in
#12267.

## Review Focus

Review the product assertions and whether this is the right first
coverage slice on top of the typed route mock foundation. This PR
intentionally avoids asset sidebar and floating QPO lifecycle coverage;
those should remain follow-up PRs.

## Screenshots (if applicable)

Not applicable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12272-test-cover-job-history-sidebar-with-typed-route-mocks-3606d73d3650817481d5f9fac4bfc93c)
by [Unito](https://www.unito.io)
2026-05-18 18:34:56 -07:00
Dante
448ad73fae refactor(assets): collapse useMediaAssets factory wrapper (FE-727) (#12283)
## Summary

https://linear.app/comfyorg/issue/FE-727/refactor-usemediaasset

`useMediaAssets` was a factory wrapper that branched on `isCloud` and
returned one of two near-identical implementations (`useAssetsApi` /
`useInternalFilesApi`). Both implementations delegated to the same
`assetsStore` actions (`updateInputs` / `updateHistory` /
`loadMoreHistory`) — the real cloud/local fork lives inside
`assetsStore.fetchInputFiles` (line 121), not at the composable layer.

Collapse the wrapper:

1. Delete `useInternalFilesApi` (identical to `useAssetsApi`).
2. Delete `useMediaAssets` (now a pass-through).
3. Switch the four callers to `useAssetsApi` directly.

## Changes

- **What**:
  - Delete `useInternalFilesApi.ts` and `useMediaAssets.ts`.
- `AssetsSidebarTab.vue`, `WidgetSelectDropdown.vue`,
`useOutputHistory.ts`: call `useAssetsApi` directly.
- `useWidgetSelectItems.ts`: narrow `outputMediaAssets` prop type to
`IAssetsProvider` (the interface `useOutputHistory` already implements
directly).
  - Test mocks repointed from `useMediaAssets` → `useAssetsApi`.
- Two unrelated tailwind class-order lint errors auto-fixed by `pnpm
lint:fix` to keep CI green.
- **Breaking**: None — no behavior change.

## Review Focus

The real cloud/local fork remains at `assetsStore.ts:121`
(`fetchInputFiles = isCloud ? fetchInputFilesFromCloud :
fetchInputFilesFromAPI`). That branch is M1-strict and clears once
BE-933 lands. This PR only collapses the dead wrapper layer above it.

`IAssetsProvider` is intentionally kept — `useOutputHistory.ts:83`
directly implements it for a different use case, so the interface still
has more than one consumer.

Surfaced while working on the FE-678 cloud/local asset branching survey.

## Screenshots (if applicable)

N/A — no UI change.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-19 00:05:40 +00:00
Deep Mehta
cf267acffe fix(cloud): stop bouncing working users to /cloud/survey mid-session (#12301)
## Summary

`getSurveyCompletedStatus` (auth.ts) now resolves ambiguous responses to
"completed" instead of "not completed", so transient backend errors no
longer bounce working users to `/cloud/survey`.

| Backend response | Old behavior | New behavior |
|---|---|---|
| 200 with non-empty `value` | `true` (completed) | `true` (completed) |
| 200 with empty `value` | `false` (not completed) | `false` (not
completed) |
| 404 | `false` → bounced | `true` (treat as completed) |
| 5xx | `false` → bounced | `true` (treat as completed) |
| 401 / 403 | `false` → bounced | `true` (treat as completed) |
| Network error | `false` → bounced | `true` (treat as completed) |

Only a definitive `200` with empty `value` is treated as "not
completed". Everything else fails open. The dedicated auth layer handles
re-authentication on the next API call, so 401/403 doesn't need a
separate branch here.

## Why

User reports from team-plan customers: _"I was working in a workflow,
hit run, and then got logged out and redirected to a survey screen."_
Datadog shows ~7,000 distinct users/day hitting the `setting key
onboarding_survey not found` path on prod ingest. With
`onboarding_survey_enabled: true` in prod dynamic config and the
catch-all `!response.ok` returning `false`, any mid-session reload
tripped a redirect to `/cloud/survey`.

User-validated requirement: rather miss showing the survey to a few
users than show it duplicately or interrupt working customers.

## Trade-off worth product review

A genuinely brand-new user whose `User.Settings` JSON is empty also
returns 404 from `/api/settings/onboarding_survey` — the backend doesn't
distinguish "key absent for existing user" from "user has no settings
yet". With this change, that 404 is treated as "completed", so the
survey gate does not fire on the strict 404 path. New users will still
see the survey if signup pre-populates the `onboarding_survey` key with
an empty object (`200` with empty `value`); if not, the survey is missed
on initial signup.

We picked this trade-off per the product call that false positives
(bouncing paying customers) are strictly worse than false negatives
(occasionally missing a new user).

The clean fix to recover the new-user signal is a backend change: return
`200` with `value: null` when the `User` row exists but the key is
absent — distinguishing "no survey saved" from "user not found". Out of
scope for this PR; filing as follow-up if accepted.

## Test plan

- [ ] Logged-in user with completed survey navigates around — no
redirect
- [ ] Logged-in user with no survey, fresh tab — redirected to
`/cloud/survey` (gate still works for new sessions)
- [ ] Logged-in user with no survey, after submitting — no redirect on
next nav
- [ ] Simulate transient 5xx on `/api/settings/onboarding_survey`
(DevTools blocking) — user stays on current page, no redirect

Unit coverage in `auth.test.ts` locks the resolution table above against
drift (one test per branch, 8 total).

## Companion PRs

None — frontend only.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12301-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-session-3616d73d365081128ba7e266ad7ccff9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 23:37:02 +00:00
Glary-Bot
3b75d9c1e1 test: remove redundant page param and misplaced immutable test
- Use this.page in enableAssetApiSetting/openModelLibrary instead of
  accepting a redundant page parameter
- Remove 'immutable asset disables all editable controls' from debounce
  section — already covered by Section 2 immutable/mutable tests
2026-04-20 04:10:04 +00:00
Glary-Bot
954dbd1f4a test: address CodeRabbit review feedback
- Compute real tag deltas in mock response instead of echoing request
- Assert specific base_model/additional_tags values in mutation payloads
- Assert final debounced user_description value, not just mutation count
2026-04-20 03:23:41 +00:00
Glary-Bot
ae7e16c7fc test: address review feedback on ModelInfoPanel E2E tests
- Wait for asset-specific content (filename) after switching assets
  instead of just panel visibility to prevent stale state interactions
- Seed tag mock from fixture assets' actual tags array
- Scope base-model and additional-tags locators to labeled fields
  instead of fragile positional nth() selectors
- Assert specific user_metadata payload keys in mutation tests
- Use data-asset-id attribute for deterministic asset card selection
2026-04-20 03:09:12 +00:00
Glary-Bot
8d2b1d16e6 test: add E2E tests for ModelInfoPanel.vue (asset browser)
Add 42 Playwright test cases covering all uncovered lines (250-352)
in ModelInfoPanel.vue. Tests organized into 8 groups:
- Panel rendering & basic info display
- Immutable vs mutable behavior
- Display name editing flow
- Model type selection
- Base models & additional tags editing
- User description editing with debounce
- Watcher state reset on asset switch
- Debounce coalescing behavior

New files:
- fixtures/data/assetBrowserFixtures.ts: Typed mock data
- fixtures/components/AssetBrowserModal.ts: Page object
- fixtures/helpers/AssetBrowserHelper.ts: Route mocking helper
- tests/assetBrowser/AGENTS.md: Test documentation
- tests/assetBrowser/modelInfoPanel.spec.ts: Test file
2026-04-20 02:47:25 +00:00
30 changed files with 1861 additions and 178 deletions

View File

@@ -0,0 +1,14 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: #000000; }
.fg { fill: #F2FF59; }
@media (prefers-color-scheme: dark) {
.bg { fill: #F2FF59; }
.fg { fill: #000000; }
}
</style>
<circle class="bg" cx="24" cy="24" r="24"/>
<g transform="translate(7.8 6.72) scale(0.72)">
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -71,7 +71,7 @@ const websiteJsonLd = {
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -0,0 +1,103 @@
import type { Locator, Page } from '@playwright/test'
export class AssetBrowserModal {
public readonly root: Locator
public readonly assetGrid: Locator
public readonly modelInfoPanel: Locator
public readonly basicInfoSection: Locator
public readonly modelTaggingSection: Locator
public readonly modelDescriptionSection: Locator
public readonly displayNameText: Locator
public readonly editDisplayNameButton: Locator
public readonly displayNameInput: Locator
public readonly fileNameText: Locator
public readonly sourceLink: Locator
public readonly modelTypeSelect: Locator
public readonly baseModelsField: Locator
public readonly additionalTagsField: Locator
public readonly baseModelsInput: Locator
public readonly additionalTagsInput: Locator
public readonly descriptionText: Locator
public readonly userDescriptionTextarea: Locator
public readonly triggerPhrasesCopyAllButton: Locator
public readonly triggerPhraseButtons: Locator
public readonly selectModelPrompt: Locator
constructor(public readonly page: Page) {
this.root = page.locator('[data-component-id="AssetBrowserModal"]')
this.assetGrid = this.root.locator('[data-component-id="AssetGrid"]')
this.modelInfoPanel = page.locator('[data-component-id="ModelInfoPanel"]')
const sections = this.modelInfoPanel.locator(':scope > div')
this.basicInfoSection = sections.nth(0)
this.modelTaggingSection = sections.nth(1)
this.modelDescriptionSection = sections.nth(2)
this.displayNameText = this.basicInfoSection
.locator('.editable-text')
.first()
this.editDisplayNameButton = this.basicInfoSection.getByRole('button', {
name: /edit/i
})
this.displayNameInput = this.basicInfoSection.locator('input[type="text"]')
this.fileNameText = this.basicInfoSection
.locator('span.break-all.text-muted-foreground')
.first()
this.sourceLink = this.basicInfoSection
.locator('a[target="_blank"]')
.first()
this.modelTypeSelect = this.modelTaggingSection.getByRole('combobox')
this.baseModelsField = this.modelTaggingSection
.locator('div')
.filter({ hasText: /base model/i })
.first()
this.additionalTagsField = this.modelTaggingSection
.locator('div')
.filter({ hasText: /additional tag/i })
.first()
this.baseModelsInput = this.baseModelsField.locator('input')
this.additionalTagsInput = this.additionalTagsField.locator('input')
this.descriptionText = this.modelDescriptionSection.locator('p').first()
this.userDescriptionTextarea =
this.modelDescriptionSection.locator('textarea')
this.triggerPhrasesCopyAllButton = this.modelDescriptionSection.getByRole(
'button',
{ name: /copy all/i }
)
this.triggerPhraseButtons = this.modelDescriptionSection
.locator('button')
.filter({ hasText: /.+/ })
this.selectModelPrompt = this.root.locator('.wrap-break-word.text-muted')
}
async clickAsset(name: string, assetId?: string): Promise<void> {
const assetCard = assetId
? this.assetGrid.locator(
`[data-component-id="AssetCard"][data-asset-id="${assetId}"]`
)
: this.assetGrid.locator('[data-component-id="AssetCard"]').filter({
has: this.page.getByRole('heading', {
name,
exact: true
})
})
await assetCard.first().click()
}
async waitForModelInfoPanel(): Promise<void> {
await this.modelInfoPanel.waitFor({ state: 'visible' })
}
async waitForAssetContent(text: string): Promise<void> {
await this.modelInfoPanel
.getByText(text, { exact: false })
.first()
.waitFor({ state: 'visible' })
}
}

View File

@@ -5,11 +5,13 @@ import { TestIds } from '@e2e/fixtures/selectors'
export class QueuePanel {
readonly overlayToggle: Locator
readonly overlay: Locator
readonly moreOptionsButton: Locator
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
this.overlay = page.getByTestId(TestIds.queue.progressOverlay)
this.moreOptionsButton = this.overlay.getByLabel(/More options/i)
}
async openClearHistoryDialog() {

View File

@@ -0,0 +1,64 @@
import type { Asset } from '@comfyorg/ingest-types'
function createAssetBrowserModel(overrides: Partial<Asset> = {}): Asset {
return {
id: 'browser-model-001',
name: 'test_model.safetensors',
asset_hash:
'blake3:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2025-01-15T10:00:00Z',
updated_at: '2025-01-15T10:00:00Z',
last_access_time: '2025-01-15T10:00:00Z',
...overrides
}
}
export const EDITABLE_MODEL: Asset = createAssetBrowserModel({
id: 'browser-model-editable-001',
name: 'cinematic_details_v2.safetensors',
tags: ['models', 'loras'],
is_immutable: false,
metadata: {
description: 'A cinematic detail enhancer LoRA tuned for portraits.',
source_arn: 'civitai:model:12345:version:67890',
trained_words: ['cinematic lighting', 'sharp details', 'portrait glow'],
filename: 'cinematic_details_v2.safetensors'
},
user_metadata: {
name: 'Cinematic Details v2',
base_model: ['sdxl', 'flux.1-dev'],
additional_tags: ['portrait', 'detail'],
user_description: 'Great for close-up portraits and high-frequency details.'
}
})
export const IMMUTABLE_MODEL: Asset = createAssetBrowserModel({
id: 'browser-model-immutable-001',
name: 'sdxl_base_1.0.safetensors',
tags: ['models', 'checkpoints'],
is_immutable: true,
metadata: {
description: 'Official SDXL base checkpoint from Hugging Face.',
repo_url: 'https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0'
},
user_metadata: {}
})
export const BARE_MODEL: Asset = createAssetBrowserModel({
id: 'browser-model-bare-001',
name: 'bare_checkpoint.safetensors',
tags: ['models', 'checkpoints'],
is_immutable: false,
metadata: {},
user_metadata: {}
})
export const MOCK_MODEL_FOLDERS: Array<{ name: string; folders: string[] }> = [
{ name: 'checkpoints', folders: ['main'] },
{ name: 'loras', folders: ['style', 'detail'] },
{ name: 'vae', folders: ['default'] },
{ name: 'controlnet', folders: ['canny', 'depth'] }
]

View File

@@ -0,0 +1,146 @@
import type { Page, Route } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
export type TagMutationCall = {
method: string
assetId: string
body: { tags: string[] }
timestamp: number
}
const modelFoldersRoutePattern = /\/api\/experiment\/models(?:\?.*)?$/
const assetTagsRoutePattern = /\/api\/assets\/([^/]+)\/tags(?:\?.*)?$/
export class AssetBrowserHelper {
private readonly routeHandlers: Array<{
pattern: string | RegExp
handler: (route: Route) => Promise<void>
}> = []
constructor(private readonly page: Page) {}
async mockModelFolders(
folders: Array<{ name: string; folders: string[] }>
): Promise<void> {
const handler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(folders)
})
}
this.routeHandlers.push({ pattern: modelFoldersRoutePattern, handler })
await this.page.route(modelFoldersRoutePattern, handler)
}
async mockAssetTags(
initialAssets?: Array<{ id: string; tags: string[] }>
): Promise<{ getCalls(): TagMutationCall[] }> {
const calls: TagMutationCall[] = []
const tagsByAssetId = new Map<string, string[]>()
if (initialAssets) {
for (const asset of initialAssets) {
tagsByAssetId.set(asset.id, [...asset.tags])
}
}
const handler = async (route: Route) => {
const request = route.request()
const method = request.method()
if (method !== 'POST' && method !== 'DELETE') {
await route.fallback()
return
}
const match = request.url().match(assetTagsRoutePattern)
const assetId = match?.[1]
if (!assetId) {
await route.fallback()
return
}
const rawBody = request.postDataJSON() as { tags?: unknown }
const tags = Array.isArray(rawBody?.tags)
? rawBody.tags.filter((tag): tag is string => typeof tag === 'string')
: []
const body = { tags }
calls.push({
method,
assetId,
body,
timestamp: Date.now()
})
const existing = tagsByAssetId.get(assetId) ?? ['models']
const totalTags =
method === 'POST'
? Array.from(new Set([...existing, ...tags]))
: existing.filter((tag) => !tags.includes(tag))
const added =
method === 'POST'
? totalTags.filter((tag) => !existing.includes(tag))
: []
const removed =
method === 'DELETE'
? existing.filter((tag) => !totalTags.includes(tag))
: []
tagsByAssetId.set(assetId, totalTags)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
total_tags: totalTags,
added,
removed
})
})
}
this.routeHandlers.push({ pattern: assetTagsRoutePattern, handler })
await this.page.route(assetTagsRoutePattern, handler)
return {
getCalls: () => [...calls]
}
}
async enableAssetApiSetting(): Promise<void> {
await this.page.evaluate(async () => {
await window.app!.extensionManager.setting.set(
'Comfy.Assets.UseAssetAPI',
true
)
})
}
async openModelLibrary(): Promise<void> {
await this.page.evaluate(async () => {
await window.app!.extensionManager.command.execute(
'Comfy.BrowseModelAssets'
)
})
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
}
}
export function assetToDisplayName(asset: Asset): string {
if (typeof asset.user_metadata?.name === 'string') {
return asset.user_metadata.name
}
if (typeof asset.metadata?.name === 'string') {
return asset.metadata.name
}
return asset.name
}

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,6 +1,11 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { z } from 'zod'
import {
zHistoryManageRequest,
zQueueManageRequest,
zQueueManageResponse
} from '@comfyorg/ingest-types/zod'
import type {
JobStatus,
@@ -9,6 +14,8 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
type JobsListResponse = z.infer<typeof zJobsListResponse>
type HistoryManageRequest = z.infer<typeof zHistoryManageRequest>
type QueueManageRequest = z.infer<typeof zQueueManageRequest>
const terminalJobStatuses = [
'completed',
@@ -22,7 +29,8 @@ const activeJobStatuses = [
const defaultJobsListLimit = 200
const defaultScenarioHistoryLimit = 64
const defaultJobsListOffset = 0
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
export const routeMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
interface JobsListRoute {
statuses: readonly JobStatus[]
@@ -65,11 +73,9 @@ function hasJobsListPageParams(
)
}
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
function matchesJobsListRoute(url: URL, route: JobsListRoute): boolean {
return (
url.pathname.endsWith('/api/jobs') &&
hasExactStatuses(url, route.statuses) &&
hasJobsListPageParams(url, route)
hasExactStatuses(url, route.statuses) && hasJobsListPageParams(url, route)
)
}
@@ -99,9 +105,9 @@ export function createRouteMockJob({
return {
id,
status: 'completed',
create_time: defaultRouteMockJobTimestamp,
execution_start_time: defaultRouteMockJobTimestamp,
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
create_time: routeMockJobTimestamp,
execution_start_time: routeMockJobTimestamp,
execution_end_time: routeMockJobTimestamp + 5_000,
preview_output: {
filename: `output_${id}.png`,
subfolder: '',
@@ -150,7 +156,8 @@ export class JobsRouteMocker {
const response = createJobsListResponse(route)
await this.page.route(
(url) => isJobsListRequest(url, route),
(url) =>
url.pathname.endsWith('/api/jobs') && matchesJobsListRoute(url, route),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
@@ -161,6 +168,44 @@ export class JobsRouteMocker {
}
)
}
async mockClearQueue(): Promise<QueueManageRequest[]> {
const response = zQueueManageResponse.parse({ cleared: true })
return await this.mockPostManageRoute(
'queue',
zQueueManageRequest,
response
)
}
async mockClearHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
private async mockPostManageRoute<TRequest>(
type: 'queue' | 'history',
requestSchema: z.ZodType<TRequest>,
response: unknown
): Promise<TRequest[]> {
const requests: TRequest[] = []
await this.page.route(
(url) => url.pathname.endsWith(`/api/${type}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'POST') {
await requestRoute.fallback()
return
}
requests.push(
requestSchema.parse(requestRoute.request().postDataJSON())
)
await requestRoute.fulfill({ json: response })
}
)
return requests
}
}
export const jobsRouteFixture = base.extend<{
@@ -168,6 +213,5 @@ export const jobsRouteFixture = base.extend<{
}>({
jobsRoutes: async ({ page }, use) => {
await use(new JobsRouteMocker(page))
await page.unrouteAll({ behavior: 'wait' })
}
})

View File

@@ -226,7 +226,10 @@ export const TestIds = {
currentUserIndicator: 'current-user-indicator'
},
queue: {
jobHistorySidebar: 'job-history-sidebar',
progressOverlay: 'queue-progress-overlay',
overlayToggle: 'queue-overlay-toggle',
dockedJobHistoryAction: 'docked-job-history-action',
jobDetailsPopover: 'queue-job-details-popover',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',

View File

@@ -0,0 +1,25 @@
# Asset Browser E2E Tests
Tests for the Asset Browser modal right panel (`ModelInfoPanel.vue`).
## Structure
| File | Coverage |
| ------------------------ | ------------------------------------------------------------------------------ |
| `modelInfoPanel.spec.ts` | Rendering, mutable/immutable behavior, editing flows, watcher resets, debounce |
## Shared Test Utilities
- `@e2e/fixtures/components/AssetBrowserModal` — Page object for modal/root grid
and all ModelInfoPanel locators.
- `@e2e/fixtures/helpers/AssetBrowserHelper` — Route mocks for endpoints not
covered by `AssetHelper` (`GET /experiment/models`, `POST/DELETE /assets/:id/tags`).
- `@e2e/fixtures/data/assetBrowserFixtures` — Typed fixtures for editable,
immutable, and bare model states.
## Conventions
- Set all route mocks **before** `await comfyPage.setup()` so startup fetches hit
the mocked endpoints.
- Use `expect.poll()` for debounced behavior assertions (metadata and tags updates).
- Do not use `waitForTimeout()`.

View File

@@ -0,0 +1,514 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { AssetBrowserModal } from '@e2e/fixtures/components/AssetBrowserModal'
import {
BARE_MODEL,
EDITABLE_MODEL,
IMMUTABLE_MODEL,
MOCK_MODEL_FOLDERS
} from '@e2e/fixtures/data/assetBrowserFixtures'
import {
assetToDisplayName,
AssetBrowserHelper
} from '@e2e/fixtures/helpers/AssetBrowserHelper'
import type { TagMutationCall } from '@e2e/fixtures/helpers/AssetBrowserHelper'
import { withAsset } from '@e2e/fixtures/helpers/AssetHelper'
type MetadataBody = {
user_metadata?: Record<string, unknown>
}
test.describe('Asset Browser - ModelInfoPanel', () => {
let modal: AssetBrowserModal
let assetBrowserHelper: AssetBrowserHelper
let tagCalls: { getCalls(): TagMutationCall[] }
async function focusEditableModel() {
await modal.clickAsset(
assetToDisplayName(EDITABLE_MODEL),
EDITABLE_MODEL.id
)
await modal.waitForAssetContent('cinematic_details_v2.safetensors')
}
async function focusImmutableModel() {
await modal.clickAsset(
assetToDisplayName(IMMUTABLE_MODEL),
IMMUTABLE_MODEL.id
)
await modal.waitForAssetContent('sdxl_base_1.0.safetensors')
}
async function focusBareModel() {
await modal.clickAsset(assetToDisplayName(BARE_MODEL), BARE_MODEL.id)
await modal.waitForAssetContent('bare_checkpoint.safetensors')
}
function metadataMutations(comfyPage: {
assetApi: {
getMutations(): Array<{ method: string; endpoint: string; body: unknown }>
}
}) {
return comfyPage.assetApi
.getMutations()
.filter((mutation) => mutation.method === 'PUT')
.filter((mutation) => /\/assets\/[^/]+$/.test(mutation.endpoint))
}
function getLastMetadataBody(comfyPage: {
assetApi: {
getMutations(): Array<{ method: string; endpoint: string; body: unknown }>
}
}): MetadataBody | undefined {
const list = metadataMutations(comfyPage)
const last = list[list.length - 1]
if (!last) return undefined
return (last.body ?? undefined) as MetadataBody | undefined
}
test.beforeEach(async ({ comfyPage }) => {
comfyPage.assetApi.configure(
withAsset(EDITABLE_MODEL),
withAsset(IMMUTABLE_MODEL),
withAsset(BARE_MODEL)
)
await comfyPage.assetApi.mock()
assetBrowserHelper = new AssetBrowserHelper(comfyPage.page)
await assetBrowserHelper.mockModelFolders(MOCK_MODEL_FOLDERS)
tagCalls = await assetBrowserHelper.mockAssetTags([
{ id: EDITABLE_MODEL.id, tags: [...(EDITABLE_MODEL.tags ?? [])] },
{ id: IMMUTABLE_MODEL.id, tags: [...(IMMUTABLE_MODEL.tags ?? [])] },
{ id: BARE_MODEL.id, tags: [...(BARE_MODEL.tags ?? [])] }
])
await comfyPage.setup()
await assetBrowserHelper.enableAssetApiSetting()
await assetBrowserHelper.openModelLibrary()
modal = new AssetBrowserModal(comfyPage.page)
await expect(modal.root).toBeVisible()
await focusEditableModel()
})
test.afterEach(async ({ comfyPage }) => {
await assetBrowserHelper.clearMocks()
await comfyPage.assetApi.clearMocks()
})
test.describe('1) Panel Rendering & Basic Info', () => {
test('shows panel after focusing an asset', async () => {
await expect(modal.modelInfoPanel).toBeVisible()
})
test('renders display name text', async () => {
await expect(modal.displayNameText).toContainText('Cinematic Details v2')
})
test('renders filename from metadata filename', async () => {
await expect(modal.fileNameText).toContainText(
'cinematic_details_v2.safetensors'
)
})
test('renders source link for editable model', async () => {
await expect(modal.sourceLink).toBeVisible()
})
test('maps civitai source_arn to expected URL', async () => {
await expect(modal.sourceLink).toHaveAttribute(
'href',
'https://civitai.com/models/12345?modelVersionId=67890'
)
})
test('renders trigger phrases copy-all button', async () => {
await expect(modal.triggerPhrasesCopyAllButton).toBeVisible()
})
test('renders trigger phrase buttons', async () => {
await expect
.poll(() => modal.triggerPhraseButtons.count())
.toBeGreaterThan(0)
})
test('renders metadata description paragraph', async () => {
await expect(modal.descriptionText).toContainText(
'cinematic detail enhancer'
)
})
test('renders user description in textarea', async () => {
await expect(modal.userDescriptionTextarea).toHaveValue(
'Great for close-up portraits and high-frequency details.'
)
})
test('hides optional metadata blocks for bare model', async () => {
await focusBareModel()
await expect(modal.sourceLink).toBeHidden()
await expect(modal.descriptionText).toBeHidden()
await expect(modal.triggerPhrasesCopyAllButton).toBeHidden()
})
})
test.describe('2) Immutable vs Mutable', () => {
test('hides display-name edit button for immutable asset', async () => {
await focusImmutableModel()
await expect(modal.editDisplayNameButton).toBeHidden()
})
test('does not render model type combobox for immutable asset', async () => {
await focusImmutableModel()
await expect(modal.modelTypeSelect).toBeHidden()
})
test('disables base-model tags input for immutable asset', async () => {
await focusImmutableModel()
await expect(modal.baseModelsInput).toBeDisabled()
})
test('disables additional-tags input for immutable asset', async () => {
await focusImmutableModel()
await expect(modal.additionalTagsInput).toBeDisabled()
})
test('disables user description textarea for immutable asset', async () => {
await focusImmutableModel()
await expect(modal.userDescriptionTextarea).toBeDisabled()
})
test('shows edit controls for mutable asset', async () => {
await focusImmutableModel()
await focusEditableModel()
await expect(modal.editDisplayNameButton).toBeVisible()
await expect(modal.modelTypeSelect).toBeVisible()
})
test('enables user description textarea for mutable asset', async () => {
await focusImmutableModel()
await focusEditableModel()
await expect(modal.userDescriptionTextarea).toBeEnabled()
})
})
test.describe('3) Display Name Editing', () => {
test('enters edit mode on display-name double-click', async () => {
await modal.displayNameText.dblclick()
await expect(modal.displayNameInput).toBeVisible()
})
test('enters edit mode on edit button click', async () => {
await modal.editDisplayNameButton.click()
await expect(modal.displayNameInput).toBeVisible()
})
test('submitting new display name sends metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
await modal.editDisplayNameButton.click()
await modal.displayNameInput.fill('My Renamed Model')
await modal.displayNameInput.press('Enter')
await expect
.poll(() => metadataMutations(comfyPage).length)
.toBeGreaterThan(initial)
const lastBody = getLastMetadataBody(comfyPage)
expect(lastBody?.user_metadata?.name).toBe('My Renamed Model')
})
test('submitting same display name does not send metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
await modal.editDisplayNameButton.click()
await modal.displayNameInput.fill('Cinematic Details v2')
await modal.displayNameInput.press('Enter')
await expect
.poll(() => metadataMutations(comfyPage).length, { timeout: 1200 })
.toBe(initial)
})
test('canceling display-name edit restores original text', async () => {
await modal.editDisplayNameButton.click()
await modal.displayNameInput.fill('Temporary Name')
await modal.displayNameInput.press('Escape')
await expect(modal.displayNameText).toContainText('Cinematic Details v2')
await expect(modal.displayNameInput).toBeHidden()
})
test('submitting empty display name does not send metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
await modal.editDisplayNameButton.click()
await modal.displayNameInput.fill('')
await modal.displayNameInput.press('Enter')
await expect
.poll(() => metadataMutations(comfyPage).length, { timeout: 1200 })
.toBe(initial)
})
})
test.describe('4) Model Type Selection', () => {
test('shows model type options when combobox is opened', async ({
comfyPage
}) => {
await modal.modelTypeSelect.click()
await expect(comfyPage.page.getByRole('option')).not.toHaveCount(0)
})
test('changing model type sends tag mutation requests', async () => {
const initial = tagCalls.getCalls().length
await modal.modelTypeSelect.click()
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
await expect
.poll(() => tagCalls.getCalls().length)
.toBeGreaterThan(initial)
const lastCall = tagCalls.getCalls().at(-1)
expect(lastCall).toBeDefined()
expect(lastCall?.body.tags).toContain('checkpoints')
})
test('selecting same model type does not send tag mutations', async () => {
const initial = tagCalls.getCalls().length
await modal.modelTypeSelect.click()
await modal.page.getByRole('option', { name: /lora/i }).click()
await expect
.poll(() => tagCalls.getCalls().length, { timeout: 1200 })
.toBe(initial)
})
test('updates combobox value immediately after selecting new model type', async () => {
await modal.modelTypeSelect.click()
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
await expect(modal.modelTypeSelect).toContainText(/checkpoints/i)
})
})
test.describe('5) Base Models & Additional Tags', () => {
test('shows existing base model values', async () => {
await expect(modal.modelTaggingSection).toContainText('sdxl')
await expect(modal.modelTaggingSection).toContainText('flux.1-dev')
})
test('shows existing additional tags values', async () => {
await expect(modal.modelTaggingSection).toContainText('portrait')
await expect(modal.modelTaggingSection).toContainText('detail')
})
test('adding a base model sends metadata update', async ({ comfyPage }) => {
const initial = metadataMutations(comfyPage).length
await modal.baseModelsInput.click()
await modal.baseModelsInput.fill('sd3.5-large')
await modal.baseModelsInput.press('Enter')
await expect
.poll(() => metadataMutations(comfyPage).length)
.toBeGreaterThan(initial)
const lastBody = getLastMetadataBody(comfyPage)
const baseModels = lastBody?.user_metadata?.base_model as
| string[]
| undefined
expect(baseModels).toContain('sd3.5-large')
expect(baseModels).toContain('sdxl')
expect(baseModels).toContain('flux.1-dev')
})
test('removing a base model sends metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
const removeButtons = modal.baseModelsField.getByRole('button', {
name: /remove/i
})
await removeButtons.first().click()
await expect
.poll(() => metadataMutations(comfyPage).length)
.toBeGreaterThan(initial)
const lastBody = getLastMetadataBody(comfyPage)
const baseModels = lastBody?.user_metadata?.base_model as
| string[]
| undefined
expect(baseModels).toBeDefined()
expect(baseModels!.length).toBeLessThan(2)
})
test('adding an additional tag sends metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
await modal.additionalTagsInput.click()
await modal.additionalTagsInput.fill('cinematic')
await modal.additionalTagsInput.press('Enter')
await expect
.poll(() => metadataMutations(comfyPage).length)
.toBeGreaterThan(initial)
const lastBody = getLastMetadataBody(comfyPage)
const tags = lastBody?.user_metadata?.additional_tags as
| string[]
| undefined
expect(tags).toContain('cinematic')
expect(tags).toContain('portrait')
expect(tags).toContain('detail')
})
test('removing an additional tag sends metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
const removeButtons = modal.additionalTagsField.getByRole('button', {
name: /remove/i
})
await removeButtons.first().click()
await expect
.poll(() => metadataMutations(comfyPage).length)
.toBeGreaterThan(initial)
const lastBody = getLastMetadataBody(comfyPage)
const tags = lastBody?.user_metadata?.additional_tags as
| string[]
| undefined
expect(tags).toBeDefined()
expect(tags!.length).toBeLessThan(2)
})
})
test.describe('6) User Description', () => {
test('shows existing user description value', async () => {
await expect(modal.userDescriptionTextarea).toHaveValue(
'Great for close-up portraits and high-frequency details.'
)
})
test('typing new description sends debounced metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
await modal.userDescriptionTextarea.fill('Updated description body')
await expect
.poll(() => metadataMutations(comfyPage).length)
.toBeGreaterThan(initial)
const lastBody = getLastMetadataBody(comfyPage)
expect(lastBody?.user_metadata?.user_description).toBe(
'Updated description body'
)
})
test('escape key blurs user description textarea', async () => {
await modal.userDescriptionTextarea.click()
await modal.userDescriptionTextarea.press('Escape')
await expect
.poll(() =>
modal.userDescriptionTextarea.evaluate(
(element) => element === document.activeElement
)
)
.toBe(false)
})
test('clearing description sends empty-string metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
await modal.userDescriptionTextarea.fill('')
await expect
.poll(() => metadataMutations(comfyPage).length)
.toBeGreaterThan(initial)
const lastBody = getLastMetadataBody(comfyPage)
expect(lastBody?.user_metadata?.user_description).toBe('')
})
})
test.describe('7) Watchers & State Reset', () => {
test('switching assets resets pending metadata updates', async () => {
await modal.userDescriptionTextarea.fill('pending draft')
await focusBareModel()
await focusEditableModel()
await expect(modal.userDescriptionTextarea).toHaveValue(
'Great for close-up portraits and high-frequency details.'
)
})
test('switching assets resets pending model-type state', async () => {
await modal.modelTypeSelect.click()
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
await expect(modal.modelTypeSelect).toContainText(/checkpoints/i)
await focusImmutableModel()
await focusEditableModel()
await expect(modal.modelTypeSelect).toContainText(/lora/i)
})
})
test.describe('8) Debounce Behavior', () => {
test('rapid description edits coalesce into one metadata update', async ({
comfyPage
}) => {
const initial = metadataMutations(comfyPage).length
await modal.userDescriptionTextarea.fill('draft 1')
await modal.userDescriptionTextarea.fill('draft 2')
await modal.userDescriptionTextarea.fill('final debounced value')
await expect
.poll(() => metadataMutations(comfyPage).length)
.toBe(initial + 1)
const lastBody = getLastMetadataBody(comfyPage)
expect(lastBody?.user_metadata?.user_description).toBe(
'final debounced value'
)
})
test('rapid model type changes coalesce to final debounced mutation set', async () => {
const initial = tagCalls.getCalls().length
await modal.modelTypeSelect.click()
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
await modal.modelTypeSelect.click()
await modal.page.getByRole('option', { name: /vae/i }).click()
await modal.modelTypeSelect.click()
await modal.page.getByRole('option', { name: /lora/i }).click()
await expect
.poll(() => tagCalls.getCalls().length, { timeout: 1200 })
.toBe(initial)
})
})
})

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

@@ -0,0 +1,280 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type { JobsRouteMocker } from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
const historyJobs: RawJobListItem[] = [
createRouteMockJob({
id: 'history-completed',
status: 'completed',
create_time: routeMockJobTimestamp - 60_000,
execution_start_time: routeMockJobTimestamp - 60_000,
execution_end_time: routeMockJobTimestamp - 55_000,
preview_output: {
filename: 'completed-output.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createRouteMockJob({
id: 'history-failed',
status: 'failed',
create_time: routeMockJobTimestamp - 120_000,
execution_start_time: routeMockJobTimestamp - 120_000,
execution_end_time: routeMockJobTimestamp - 118_000,
outputs_count: 0,
execution_error: {
node_id: '1',
node_type: 'SaveImage',
exception_message: 'Intentional fixture failure',
exception_type: 'Error',
traceback: [],
current_inputs: {},
current_outputs: {}
}
}),
createRouteMockJob({
id: 'history-cancelled',
status: 'cancelled',
create_time: routeMockJobTimestamp - 180_000,
execution_start_time: routeMockJobTimestamp - 180_000,
execution_end_time: routeMockJobTimestamp - 179_000,
outputs_count: 0
})
]
const activeJobs: RawJobListItem[] = [
createRouteMockJob({
id: 'queue-running',
status: 'in_progress',
create_time: routeMockJobTimestamp - 10_000,
execution_start_time: routeMockJobTimestamp - 9_000,
execution_end_time: null,
outputs_count: 0
}),
createRouteMockJob({
id: 'queue-pending',
status: 'pending',
create_time: routeMockJobTimestamp - 5_000,
execution_start_time: null,
execution_end_time: null,
outputs_count: 0
})
]
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
async function setupJobHistorySidebar(
comfyPage: ComfyPage,
jobsRoutes: JobsRouteMocker,
{
history = historyJobs,
queue = activeJobs
}: {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
} = {}
) {
await jobsRoutes.mockJobsScenario({ history, queue })
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
await comfyPage.setup()
await comfyPage.page
.getByTestId(TestIds.sidebar.toolbar)
.getByRole('button', { name: 'Job History', exact: true })
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
}
function jobRow(comfyPage: ComfyPage) {
const list = comfyPage.page.getByTestId(TestIds.queue.jobAssetsList)
return (jobId: string) => list.locator(`[data-job-id="${jobId}"]`)
}
function jobHistorySidebar(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.queue.jobHistorySidebar)
}
function clearQueueButton(comfyPage: ComfyPage) {
return jobHistorySidebar(comfyPage).getByRole('button', {
name: 'Clear queue',
exact: true
})
}
async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
await jobHistorySidebar(comfyPage)
.getByLabel(/More options/i)
.click()
await comfyPage.page.getByTestId(TestIds.queue.clearHistoryAction).click()
}
test.describe('Job history sidebar', { tag: '@ui' }, () => {
test('opens from the queue overlay docked history action', async ({
comfyPage,
jobsRoutes
}) => {
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: activeJobs
})
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
await comfyPage.queuePanel.moreOptionsButton.click()
await comfyPage.page
.getByTestId(TestIds.queue.dockedJobHistoryAction)
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
})
test('shows terminal and active job states', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeEnabled()
})
test('filters completed and failed history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await comfyPage.page
.getByRole('button', { name: 'Failed', exact: true })
.click()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
})
test('searches by job id and output filename', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
const row = jobRow(comfyPage)
const searchInput = comfyPage.page.getByPlaceholder('Search...')
await searchInput.fill('history-failed')
await expect(row('history-failed')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await searchInput.fill('completed-output')
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await searchInput.clear()
await expect(row('history-completed')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
})
test('disables clear queue when there are no pending jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes, {
queue: runningOnlyJobs
})
await expect(clearQueueButton(comfyPage)).toBeDisabled()
})
test('clears pending queue jobs and leaves running/history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: runningOnlyJobs
})
await clearQueueButton(comfyPage).click()
await expect.poll(() => clearQueueRequests.length).toBe(1)
expect(clearQueueRequests).toContainEqual({ clear: true })
await expect(row('queue-pending')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeDisabled()
expect(clearHistoryRequests).toHaveLength(0)
})
test('clears history from the sidebar menu and keeps active jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
await jobsRoutes.mockJobsScenario({
history: [],
queue: activeJobs
})
await openSidebarClearHistoryDialog(comfyPage)
await expect(
comfyPage.page.getByText('Clear your job queue history?')
).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Clear', exact: true })
.click()
await expect.poll(() => clearHistoryRequests.length).toBe(1)
expect(clearHistoryRequests).toContainEqual({ clear: true })
await expect(row('history-completed')).toBeHidden()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('queue-pending')).toBeVisible()
expect(clearQueueRequests).toHaveLength(0)
})
})

View File

@@ -0,0 +1,291 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
const apiMock = vi.hoisted(
() =>
new (class extends EventTarget {
clientId: string | null = 'test-client'
getRawLogs = vi.fn(async () => ({ entries: [{ m: 'log line\n' }] }))
subscribeLogs = vi.fn(async () => {})
})()
)
vi.mock('@/scripts/api', () => ({ api: apiMock }))
const terminalMock = vi.hoisted(() => ({
open: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
reset: vi.fn(),
scrollToBottom: vi.fn(),
onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn()
}))
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
useTerminal: vi.fn(() => ({
terminal: terminalMock,
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
}))
}))
vi.mock('@/components/bottomPanel/tabs/terminal/BaseTerminal.vue', async () => {
const { defineComponent, ref } = await import('vue')
const { useTerminal } =
await import('@/composables/bottomPanelTabs/useTerminal')
return {
default: defineComponent({
emits: ['created'],
setup(_, { emit }) {
const root = ref<HTMLElement | undefined>(undefined)
emit('created', useTerminal(root), root)
return () => null
}
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
logsTerminal: {
loadError:
'Unable to load logs, please ensure you have updated your ComfyUI backend.',
resyncError:
'Unable to resync logs after the backend reconnected. Reopen the console to retry.'
}
}
}
})
const renderLogsTerminal = () =>
render(LogsTerminal, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: { execution: { clientId: 'test-client' } }
}),
i18n
]
}
})
// Silence the production console.error calls in error-path tests. Vitest
// isolates this file's module graph so the spy does not affect other files.
vi.spyOn(console, 'error').mockImplementation(() => {})
// Resolve a getRawLogs call manually to drive deterministic timing in tests
// that need to observe behavior mid-fetch.
const deferredRawLogs = () => {
let resolve!: (value: { entries: { m: string }[] }) => void
let reject!: (err: unknown) => void
const promise = new Promise<{ entries: { m: string }[] }>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('LogsTerminal', () => {
beforeEach(() => {
vi.clearAllMocks()
apiMock.clientId = 'test-client'
})
it('loads logs and subscribes to streaming on mount', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
expect(terminalMock.write).toHaveBeenCalledWith('log line\n')
})
})
it('resyncs, snaps to tail, and re-subscribes on "reconnected"', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.scrollToBottom).toHaveBeenCalledTimes(1)
expect(apiMock.subscribeLogs).toHaveBeenCalledTimes(2)
expect(apiMock.subscribeLogs).toHaveBeenLastCalledWith(true)
})
// The full sequence must be: reset -> write -> scroll -> subscribe
const resetOrder = terminalMock.reset.mock.invocationCallOrder[0]
const writeOrder = terminalMock.write.mock.invocationCallOrder.at(-1)!
const scrollOrder = terminalMock.scrollToBottom.mock.invocationCallOrder[0]
const subscribeOrder =
apiMock.subscribeLogs.mock.invocationCallOrder.at(-1)!
expect(resetOrder).toBeLessThan(writeOrder)
expect(writeOrder).toBeLessThan(scrollOrder)
expect(scrollOrder).toBeLessThan(subscribeOrder)
})
it('aborts an in-flight resync when a second "reconnected" arrives', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
// First resync hangs on getRawLogs
const first = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => first.promise)
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
})
// Second resync resolves immediately
apiMock.getRawLogs.mockImplementationOnce(async () => ({
entries: [{ m: 'fresh\n' }]
}))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
})
// Now resolve the first (aborted) resync — none of its side effects must apply
first.resolve({ entries: [{ m: 'stale\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.write).not.toHaveBeenCalledWith('stale\n')
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
})
it('aborts an in-flight mount fetch when "reconnected" arrives first', async () => {
// Mount's getRawLogs hangs so we can drive the race deterministically.
const mount = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => mount.promise)
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
// Resync wins the race and writes the post-reboot snapshot.
apiMock.getRawLogs.mockImplementationOnce(async () => ({
entries: [{ m: 'fresh\n' }]
}))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
})
// Mount's late response must not stomp on the freshly-reset terminal.
mount.resolve({ entries: [{ m: 'stale-mount\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.write).not.toHaveBeenCalledWith('stale-mount\n')
})
it('surfaces an inline error when the resync fetch fails', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to resync logs')
})
})
it('shows a load error when the initial fetch fails', async () => {
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
renderLogsTerminal()
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to load logs')
})
})
it('recovers from an initial load failure when a reconnect arrives', async () => {
apiMock.getRawLogs
.mockRejectedValueOnce(new Error('initial fail'))
.mockResolvedValueOnce({ entries: [{ m: 'recovered\n' }] })
renderLogsTerminal()
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to load logs')
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(screen.queryByTestId('terminal-error-message')).toBeNull()
expect(screen.queryByTestId('terminal-loading-spinner')).toBeNull()
expect(terminalMock.write).toHaveBeenCalledWith('recovered\n')
})
})
it('cleans up listeners and unsubscribes on unmount', async () => {
const { unmount } = renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
unmount()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(false)
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await nextTick()
expect(terminalMock.reset).not.toHaveBeenCalled()
// No additional getRawLogs beyond the mount-time call
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
it('does not write to the terminal when unmount happens mid-fetch', async () => {
const pending = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => pending.promise)
const { unmount } = renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
unmount()
pending.resolve({ entries: [{ m: 'late\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.write).not.toHaveBeenCalled()
})
})

View File

@@ -12,79 +12,36 @@
data-testid="terminal-loading-spinner"
class="relative inset-0 z-10 flex h-full items-center justify-center"
/>
<BaseTerminal v-show="!loading" @created="terminalCreated" />
<BaseTerminal
v-show="!loading && !errorMessage"
@created="terminalCreated"
/>
</div>
</template>
<script setup lang="ts">
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Terminal } from '@xterm/xterm'
import ProgressSpinner from 'primevue/progressspinner'
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { shallowRef } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import { useLogsTerminal } from '@/composables/bottomPanelTabs/useLogsTerminal'
import BaseTerminal from './BaseTerminal.vue'
const errorMessage = ref('')
const loading = ref(true)
const terminal = shallowRef<Terminal>()
const { errorMessage, loading } = useLogsTerminal(terminal)
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
{ terminal: instance, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
// Auto-size terminal to fill container width.
// minCols: 80 ensures minimum width for colab environments.
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
const update = (entries: Array<LogEntry>) => {
terminal.write(entries.map((e) => e.m).join(''))
}
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
update(e.detail.entries)
}
const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries)
}
const watchLogs = async () => {
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) {
await until(clientId).not.toBeNull()
}
await api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
onMounted(async () => {
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints won't exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return
}
await watchLogs()
loading.value = false
})
onUnmounted(async () => {
if (api.clientId) {
await api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
})
terminal.value = instance
}
</script>

View File

@@ -4,6 +4,7 @@
:class="['flex', 'justify-end', 'w-full', 'pointer-events-none']"
>
<div
data-testid="queue-progress-overlay"
class="pointer-events-auto flex max-h-[60vh] w-[350px] min-w-[310px] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
:class="containerClass"
@mouseenter="isHovered = true"

View File

@@ -237,7 +237,7 @@ import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -309,8 +309,8 @@ const formattedExecutionTime = computed(() => {
const toast = useToast()
const inputAssets = useMediaAssets('input')
const outputAssets = useMediaAssets('output')
const inputAssets = useAssetsApi('input')
const outputAssets = useAssetsApi('output')
// Asset selection
const {

View File

@@ -1,5 +1,8 @@
<template>
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<SidebarTabTemplate
data-testid="job-history-sidebar"
:title="$t('queue.jobHistory')"
>
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="onClearHistory" />

View File

@@ -0,0 +1,123 @@
import { until, useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Ref } from 'vue'
import { onMounted, onScopeDispose, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
type TerminalLike = {
write: (data: string) => void
reset: () => void
scrollToBottom: () => void
}
/**
* Drives the built-in logs terminal: initial load, live `logs` stream, and
* full resync when the backend WebSocket reconnects (e.g., after a reboot).
*
* Listeners are registered synchronously so we cannot miss a `reconnected`
* event during the mount-time fetch/subscribe awaits. In-flight fetches are
* tied to AbortControllers so that:
* - rapid double-reconnects don't interleave writes / double-subscribe
* - unmount mid-fetch never writes to a disposed terminal
*/
export function useLogsTerminal(
terminal: Readonly<Ref<TerminalLike | undefined>>
) {
const { t } = useI18n()
const errorMessage = ref('')
const loading = ref(true)
let mountController: AbortController | undefined
let resyncController: AbortController | undefined
const writeEntries = (entries: LogEntry[]) => {
terminal.value?.write(entries.map((e) => e.m).join(''))
}
const resyncLogs = async () => {
// Cancel both the in-flight mount fetch and any prior resync so a late
// mount response can't write a stale snapshot on top of a freshly-reset
// terminal after we've already written the post-reconnect view.
mountController?.abort()
resyncController?.abort()
const controller = new AbortController()
resyncController = controller
const { signal } = controller
try {
const logs = await api.getRawLogs()
if (signal.aborted || !terminal.value) return
terminal.value.reset()
writeEntries(logs.entries)
terminal.value.scrollToBottom()
// Backend lost the per-client log subscription across the restart;
// re-subscribe so new runtime logs stream over the fresh WebSocket.
await api.subscribeLogs(true)
if (signal.aborted) return
errorMessage.value = ''
loading.value = false
} catch (err) {
if (signal.aborted) return
console.error('Error resyncing logs after reconnect', err)
errorMessage.value = t('logsTerminal.resyncError')
}
}
// Register listeners synchronously, before any awaits, so a reconnect
// fired during mount cannot be missed. useEventListener handles cleanup
// on scope dispose.
useEventListener(api, 'logs', (e: CustomEvent<LogsWsMessage>) => {
writeEntries(e.detail.entries)
})
useEventListener(api, 'reconnected', () => {
void resyncLogs()
})
onMounted(async () => {
if (!terminal.value) await until(terminal).toBeTruthy()
const controller = new AbortController()
mountController = controller
const { signal } = controller
try {
const logs = await api.getRawLogs()
if (signal.aborted || !terminal.value) return
writeEntries(logs.entries)
} catch (err) {
if (signal.aborted) return
console.error('Error loading logs', err)
errorMessage.value = t('logsTerminal.loadError')
loading.value = false
return
}
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) await until(clientId).not.toBeNull()
if (signal.aborted) return
try {
await api.subscribeLogs(true)
} catch (err) {
if (signal.aborted) return
console.error('Error subscribing to logs', err)
}
if (!signal.aborted) loading.value = false
})
onScopeDispose(() => {
mountController?.abort()
resyncController?.abort()
if (!api.clientId) return
api.subscribeLogs(false).catch((err) => {
console.error('Error unsubscribing from logs', err)
})
})
return { errorMessage, loading }
}

View File

@@ -1160,6 +1160,10 @@
"saveAsTemplate": "Save as template",
"enterName": "Enter name"
},
"logsTerminal": {
"loadError": "Unable to load logs, please ensure you have updated your ComfyUI backend.",
"resyncError": "Unable to resync logs after the backend reconnected. Reopen the console to retry."
},
"workflowService": {
"exportWorkflow": "Export Workflow",
"enterFilename": "Enter the filename",

View File

@@ -1,63 +0,0 @@
import { computed } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
/**
* Composable for fetching media assets from local environment
* Uses AssetsStore for centralized state management
*/
export function useInternalFilesApi(directory: 'input' | 'output') {
const assetsStore = useAssetsStore()
const media = computed(() =>
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
)
const loading = computed(() =>
directory === 'input'
? assetsStore.inputLoading
: assetsStore.historyLoading
)
const error = computed(() =>
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
)
const fetchMediaList = async (): Promise<AssetItem[]> => {
if (directory === 'input') {
await assetsStore.updateInputs()
return assetsStore.inputAssets
} else {
await assetsStore.updateHistory()
return assetsStore.historyAssets
}
}
const refresh = () => fetchMediaList()
const loadMore = async (): Promise<void> => {
if (directory === 'output') {
await assetsStore.loadMoreHistory()
}
}
const hasMore = computed(() => {
return directory === 'output' ? assetsStore.hasMoreHistory : false
})
const isLoadingMore = computed(() => {
return directory === 'output' ? assetsStore.isLoadingMore : false
})
return {
media,
loading,
error,
fetchMediaList,
refresh,
loadMore,
hasMore,
isLoadingMore
}
}

View File

@@ -1,15 +0,0 @@
import { isCloud } from '@/platform/distribution/types'
import type { IAssetsProvider } from './IAssetsProvider'
import { useAssetsApi } from './useAssetsApi'
import { useInternalFilesApi } from './useInternalFilesApi'
/**
* Factory function that returns the appropriate media assets implementation
* based on the current distribution (cloud vs internal)
* @param directory - The directory to fetch assets from
* @returns IAssetsProvider implementation
*/
export function useMediaAssets(directory: 'input' | 'output'): IAssetsProvider {
return isCloud ? useAssetsApi(directory) : useInternalFilesApi(directory)
}

View File

@@ -0,0 +1,91 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { getSurveyCompletedStatus } from './auth'
const fetchApi = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => fetchApi(...args)
}
}))
vi.mock('@sentry/vue', () => ({
addBreadcrumb: vi.fn(),
captureException: vi.fn()
}))
function mockResponse({
ok,
status,
body
}: {
ok: boolean
status: number
body?: unknown
}): Response {
return fromPartial<Response>({
ok,
status,
statusText: '',
json: async () => body
})
}
describe('getSurveyCompletedStatus', () => {
beforeEach(() => {
fetchApi.mockReset()
})
test('200 with non-empty value → true', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: { q1: 'a' } } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('200 with empty value → false (the only "not completed" signal)', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: {} } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
})
test('200 with null value → false', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: null } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
})
test('404 → true (do not bounce on missing key)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 404 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('500 → true (do not bounce on transient backend error)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
// 401/403 fall under the same "ambiguous => treat as completed" policy.
// The dedicated auth layer handles re-authentication on the next API
// call; this function deliberately does not try to disambiguate auth
// failures from other non-OK responses. Locking with tests so the
// policy can't drift back to a "throw on auth error" branch.
test('401 → true (auth layer handles re-auth on next call)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 401 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('403 → true (auth layer handles re-auth on next call)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 403 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('network rejection → true (do not bounce on network error)', async () => {
fetchApi.mockRejectedValueOnce(new TypeError('Network request failed'))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
})

View File

@@ -96,23 +96,24 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
}
})
if (!response.ok) {
// Not an error case - survey not completed is a valid state
// Ambiguous response (404/5xx/etc). Treat as completed to avoid
// bouncing working customers to /cloud/survey on transient hiccups.
// Real "not completed" only comes from a 200 with empty value.
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey status check returned non-ok response',
level: 'info',
level: 'warning',
data: {
status: response.status,
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
}
})
return false
return true
}
const data = await response.json()
// Check if data exists and is not empty
return !isEmpty(data.value)
} catch (error) {
// Network error - still capture it as it's not thrown from above
// Network/parse failure — same policy as ambiguous HTTP responses.
Sentry.captureException(error, {
tags: {
api_endpoint: '/settings/{key}',
@@ -124,7 +125,7 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
},
level: 'warning'
})
return false
return true
}
}

View File

@@ -23,8 +23,8 @@ const selectAsLatestFn = vi.fn()
const resolveIfReadyFn = vi.fn()
const resolvedOutputsCacheRef = new Map<string, ResultItemImpl[]>()
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: () => ({
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
useAssetsApi: () => ({
media: mediaRef,
loading: ref(false),
error: ref(null),

View File

@@ -3,7 +3,7 @@ import type { ComputedRef } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -25,7 +25,7 @@ export function useOutputHistory(): {
isWorkflowActive: ComputedRef<boolean>
cancelActiveWorkflowJobs: () => Promise<void>
} {
const backingOutputs = useMediaAssets('output')
const backingOutputs = useAssetsApi('output')
void backingOutputs.fetchMediaList()
const linearStore = useLinearOutputStore()
const workflowStore = useWorkflowStore()

View File

@@ -69,8 +69,8 @@ const { mockMediaAssets } = vi.hoisted(() => {
}
})
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: () => mockMediaAssets
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
useAssetsApi: () => mockMediaAssets
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
@@ -47,7 +47,7 @@ const modelValue = defineModel<string | undefined>({
const { t } = useI18n()
const outputMediaAssets = useMediaAssets('output')
const outputMediaAssets = useAssetsApi('output')
const transformCompatProps = useTransformCompatOverlayProps()

View File

@@ -37,8 +37,8 @@ function createMockMediaAssets() {
let mockMediaAssets = createMockMediaAssets()
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: () => mockMediaAssets
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
useAssetsApi: () => mockMediaAssets
}))
vi.mock('@/platform/assets/composables/useAssetFilterOptions', () => ({

View File

@@ -25,7 +25,7 @@ import type { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import type { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
import type { AssetKind } from '@/types/widgetTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -65,7 +65,7 @@ interface UseWidgetSelectItemsOptions {
>
modelValue: Ref<string | undefined>
assetKind: MaybeRefOrGetter<AssetKind | undefined>
outputMediaAssets: ReturnType<typeof useMediaAssets>
outputMediaAssets: IAssetsProvider
assetData: ReturnType<typeof useAssetWidgetData> | null
isAssetMode: MaybeRefOrGetter<boolean | undefined>
}