Compare commits

...

5 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
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
5 changed files with 852 additions and 0 deletions

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

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

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