mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-28 00:45:03 +00:00
Compare commits
5 Commits
refactor/m
...
glary/mode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b87b5eba54 | ||
|
|
3b75d9c1e1 | ||
|
|
954dbd1f4a | ||
|
|
ae7e16c7fc | ||
|
|
8d2b1d16e6 |
103
browser_tests/fixtures/components/AssetBrowserModal.ts
Normal file
103
browser_tests/fixtures/components/AssetBrowserModal.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
64
browser_tests/fixtures/data/assetBrowserFixtures.ts
Normal file
64
browser_tests/fixtures/data/assetBrowserFixtures.ts
Normal 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'] }
|
||||
]
|
||||
146
browser_tests/fixtures/helpers/AssetBrowserHelper.ts
Normal file
146
browser_tests/fixtures/helpers/AssetBrowserHelper.ts
Normal 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
|
||||
}
|
||||
25
browser_tests/tests/assetBrowser/AGENTS.md
Normal file
25
browser_tests/tests/assetBrowser/AGENTS.md
Normal 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()`.
|
||||
514
browser_tests/tests/assetBrowser/modelInfoPanel.spec.ts
Normal file
514
browser_tests/tests/assetBrowser/modelInfoPanel.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user