mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
*PR Created by the Glary-Bot Agent* --- ## Summary - Replace all `as unknown as Type` assertions in 59 unit test files with type-safe `@total-typescript/shoehorn` functions - Use `fromPartial<Type>()` for partial mock objects where deep-partial type-checks (21 files) - Use `fromAny<Type>()` for fundamentally incompatible types: null, undefined, primitives, variables, class expressions, and mocks with test-specific extra properties that `PartialDeepObject` rejects (remaining files) - All explicit type parameters preserved so TypeScript return types are correct - Browser test `.spec.ts` files excluded (shoehorn unavailable in `page.evaluate` browser context) ## Verification - `pnpm typecheck` ✅ - `pnpm lint` ✅ - `pnpm format` ✅ - Pre-commit hooks passed (format + oxlint + eslint + typecheck) - Migrated test files verified passing (ran representative subset) - No test behavior changes — only type assertion syntax changed - No UI changes — screenshots not applicable ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10761-test-migrate-as-unknown-as-to-total-typescript-shoehorn-3336d73d365081f6b8adc44db5dcc380) by [Unito](https://www.unito.io) --------- Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com> Co-authored-by: Amp <amp@ampcode.com>
488 lines
14 KiB
TypeScript
488 lines
14 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { fromAny } from '@total-typescript/shoehorn'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
import { useMediaAssetActions } from './useMediaAssetActions'
|
|
|
|
// Use vi.hoisted to create a mutable reference for isCloud
|
|
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
|
|
|
// Track the filename passed to createAnnotatedPath
|
|
const capturedFilenames = vi.hoisted(() => ({ values: [] as string[] }))
|
|
|
|
vi.mock('@/platform/distribution/types', () => ({
|
|
get isCloud() {
|
|
return mockIsCloud.value
|
|
}
|
|
}))
|
|
|
|
vi.mock('primevue/usetoast', () => ({
|
|
useToast: () => ({
|
|
add: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock('vue-i18n', () => ({
|
|
useI18n: () => ({
|
|
t: (key: string) => key
|
|
}),
|
|
createI18n: () => ({
|
|
global: {
|
|
t: (key: string) => key
|
|
}
|
|
})
|
|
}))
|
|
|
|
const mockShowDialog = vi.hoisted(() => vi.fn())
|
|
vi.mock('@/stores/dialogStore', () => ({
|
|
useDialogStore: () => ({
|
|
showDialog: mockShowDialog
|
|
})
|
|
}))
|
|
|
|
const mockInvalidateModelsForCategory = vi.hoisted(() => vi.fn())
|
|
const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
|
|
const mockUpdateHistory = vi.hoisted(() => vi.fn())
|
|
const mockUpdateInputs = vi.hoisted(() => vi.fn())
|
|
const mockHasCategory = vi.hoisted(() => vi.fn())
|
|
vi.mock('@/stores/assetsStore', () => ({
|
|
useAssetsStore: () => ({
|
|
setAssetDeleting: mockSetAssetDeleting,
|
|
updateHistory: mockUpdateHistory,
|
|
updateInputs: mockUpdateInputs,
|
|
invalidateModelsForCategory: mockInvalidateModelsForCategory,
|
|
hasCategory: mockHasCategory
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/stores/modelToNodeStore', () => ({
|
|
useModelToNodeStore: () => ({})
|
|
}))
|
|
|
|
vi.mock('@/composables/useCopyToClipboard', () => ({
|
|
useCopyToClipboard: () => ({
|
|
copyToClipboard: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/platform/workflow/core/services/workflowActionsService', () => ({
|
|
useWorkflowActionsService: () => ({
|
|
openWorkflowAction: vi.fn(),
|
|
exportWorkflowAction: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/services/litegraphService', () => ({
|
|
useLitegraphService: () => ({
|
|
addNodeOnGraph: vi.fn().mockReturnValue(
|
|
fromAny<LGraphNode, unknown>({
|
|
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
|
|
graph: { setDirtyCanvas: vi.fn() }
|
|
})
|
|
),
|
|
getCanvasCenter: vi.fn().mockReturnValue([100, 100])
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/stores/nodeDefStore', () => ({
|
|
useNodeDefStore: () => ({
|
|
nodeDefsByName: {
|
|
LoadImage: {
|
|
name: 'LoadImage',
|
|
display_name: 'Load Image'
|
|
}
|
|
}
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/utils/createAnnotatedPath', () => ({
|
|
createAnnotatedPath: vi.fn((item: { filename: string }) => {
|
|
capturedFilenames.values.push(item.filename)
|
|
return item.filename
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/utils/loaderNodeUtil', () => ({
|
|
detectNodeTypeFromFilename: vi.fn().mockReturnValue({
|
|
nodeType: 'LoadImage',
|
|
widgetName: 'image'
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/utils/typeGuardUtil', () => ({
|
|
isResultItemType: vi.fn().mockReturnValue(true)
|
|
}))
|
|
|
|
const mockGetAssetType = vi.hoisted(() => vi.fn())
|
|
vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
|
|
getAssetType: mockGetAssetType
|
|
}))
|
|
|
|
const mockGetOutputAssetMetadata = vi.hoisted(() =>
|
|
vi.fn().mockReturnValue(null)
|
|
)
|
|
vi.mock('../schemas/assetMetadataSchema', () => ({
|
|
getOutputAssetMetadata: mockGetOutputAssetMetadata
|
|
}))
|
|
|
|
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
|
const mockCreateAssetExport = vi.hoisted(() =>
|
|
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
|
|
)
|
|
vi.mock('../services/assetService', () => ({
|
|
assetService: {
|
|
deleteAsset: mockDeleteAsset,
|
|
createAssetExport: mockCreateAssetExport
|
|
}
|
|
}))
|
|
|
|
const mockTrackExport = vi.hoisted(() => vi.fn())
|
|
vi.mock('@/stores/assetExportStore', () => ({
|
|
useAssetExportStore: () => ({
|
|
trackExport: mockTrackExport
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/scripts/api', () => ({
|
|
api: {
|
|
deleteItem: vi.fn(),
|
|
apiURL: vi.fn((path: string) => `http://localhost:8188/api${path}`),
|
|
internalURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
user: 'test-user'
|
|
}
|
|
}))
|
|
|
|
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
|
return {
|
|
id: 'test-asset-id',
|
|
name: 'original-name.jpeg',
|
|
size: 1024,
|
|
created_at: '2025-01-01T00:00:00Z',
|
|
tags: ['input'],
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('useMediaAssetActions', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.clearAllMocks()
|
|
capturedFilenames.values = []
|
|
mockIsCloud.value = false
|
|
})
|
|
|
|
describe('addWorkflow', () => {
|
|
describe('OSS mode (isCloud = false)', () => {
|
|
beforeEach(() => {
|
|
mockIsCloud.value = false
|
|
})
|
|
|
|
it('should use asset.name as filename', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
const asset = createMockAsset({
|
|
name: 'my-image.jpeg',
|
|
asset_hash: 'hash123.jpeg'
|
|
})
|
|
|
|
await actions.addWorkflow(asset)
|
|
|
|
expect(capturedFilenames.values).toContain('my-image.jpeg')
|
|
})
|
|
})
|
|
|
|
describe('Cloud mode (isCloud = true)', () => {
|
|
beforeEach(() => {
|
|
mockIsCloud.value = true
|
|
})
|
|
|
|
it('should use asset_hash as filename when available', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
const asset = createMockAsset({
|
|
name: 'original.jpeg',
|
|
asset_hash: 'abc123hash.jpeg'
|
|
})
|
|
|
|
await actions.addWorkflow(asset)
|
|
|
|
expect(capturedFilenames.values).toContain('abc123hash.jpeg')
|
|
})
|
|
|
|
it('should fall back to asset.name when asset_hash is not available', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
const asset = createMockAsset({
|
|
name: 'fallback-name.jpeg',
|
|
asset_hash: undefined
|
|
})
|
|
|
|
await actions.addWorkflow(asset)
|
|
|
|
expect(capturedFilenames.values).toContain('fallback-name.jpeg')
|
|
})
|
|
|
|
it('should fall back to asset.name when asset_hash is null', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
const asset = createMockAsset({
|
|
name: 'fallback-null.jpeg',
|
|
asset_hash: null
|
|
})
|
|
|
|
await actions.addWorkflow(asset)
|
|
|
|
expect(capturedFilenames.values).toContain('fallback-null.jpeg')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('addMultipleToWorkflow', () => {
|
|
describe('Cloud mode (isCloud = true)', () => {
|
|
beforeEach(() => {
|
|
mockIsCloud.value = true
|
|
})
|
|
|
|
it('should use asset_hash for each asset', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
const assets = [
|
|
createMockAsset({
|
|
id: '1',
|
|
name: 'file1.jpeg',
|
|
asset_hash: 'hash1.jpeg'
|
|
}),
|
|
createMockAsset({
|
|
id: '2',
|
|
name: 'file2.jpeg',
|
|
asset_hash: 'hash2.jpeg'
|
|
})
|
|
]
|
|
|
|
await actions.addMultipleToWorkflow(assets)
|
|
|
|
expect(capturedFilenames.values).toContain('hash1.jpeg')
|
|
expect(capturedFilenames.values).toContain('hash2.jpeg')
|
|
expect(capturedFilenames.values).not.toContain('file1.jpeg')
|
|
expect(capturedFilenames.values).not.toContain('file2.jpeg')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('downloadMultipleAssets - job_asset_name_filters', () => {
|
|
beforeEach(() => {
|
|
mockIsCloud.value = true
|
|
mockCreateAssetExport.mockClear()
|
|
mockTrackExport.mockClear()
|
|
mockGetAssetType.mockReturnValue('output')
|
|
mockGetOutputAssetMetadata.mockImplementation(
|
|
(meta: Record<string, unknown> | undefined) =>
|
|
meta && 'jobId' in meta ? meta : null
|
|
)
|
|
})
|
|
|
|
function createOutputAsset(
|
|
id: string,
|
|
name: string,
|
|
jobId: string,
|
|
outputCount?: number
|
|
): AssetItem {
|
|
return createMockAsset({
|
|
id,
|
|
name,
|
|
tags: ['output'],
|
|
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
|
|
})
|
|
}
|
|
|
|
it('should omit name filters for job-level selections (outputCount known)', async () => {
|
|
const assets = [
|
|
createOutputAsset('a1', 'img1.png', 'job1', 3),
|
|
createOutputAsset('a2', 'img2.png', 'job1', 3),
|
|
createOutputAsset('a3', 'img3.png', 'job1', 3)
|
|
]
|
|
|
|
const actions = useMediaAssetActions()
|
|
actions.downloadMultipleAssets(assets)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
const payload = mockCreateAssetExport.mock.calls[0][0]
|
|
expect(payload.job_ids).toEqual(['job1'])
|
|
expect(payload.job_asset_name_filters).toBeUndefined()
|
|
})
|
|
|
|
it('should omit name filters for multiple job-level selections', async () => {
|
|
const j1a = createOutputAsset('a1', 'out1a.png', 'job1', 2)
|
|
const j1b = createOutputAsset('a2', 'out1b.png', 'job1', 2)
|
|
const j2 = createOutputAsset('a3', 'out2.png', 'job2', 1)
|
|
|
|
const actions = useMediaAssetActions()
|
|
actions.downloadMultipleAssets([j1a, j1b, j2])
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
const payload = mockCreateAssetExport.mock.calls[0][0]
|
|
expect(payload.job_ids).toEqual(['job1', 'job2'])
|
|
expect(payload.job_asset_name_filters).toBeUndefined()
|
|
})
|
|
|
|
it('should include name filters when outputCount is unknown', async () => {
|
|
const asset1 = createOutputAsset('a1', 'img1.png', 'job1')
|
|
const asset2 = createOutputAsset('a2', 'img2.png', 'job2')
|
|
|
|
const actions = useMediaAssetActions()
|
|
actions.downloadMultipleAssets([asset1, asset2])
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
const payload = mockCreateAssetExport.mock.calls[0][0]
|
|
expect(payload.job_asset_name_filters).toEqual({
|
|
job1: ['img1.png'],
|
|
job2: ['img2.png']
|
|
})
|
|
})
|
|
|
|
it('should mix: omit filters for known outputCount, keep for unknown', async () => {
|
|
const j1a = createOutputAsset('a1', 'img1a.png', 'job1', 2)
|
|
const j1b = createOutputAsset('a2', 'img1b.png', 'job1', 2)
|
|
const j2 = createOutputAsset('a3', 'img2.png', 'job2')
|
|
|
|
const actions = useMediaAssetActions()
|
|
actions.downloadMultipleAssets([j1a, j1b, j2])
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
const payload = mockCreateAssetExport.mock.calls[0][0]
|
|
expect(payload.job_ids).toEqual(['job1', 'job2'])
|
|
expect(payload.job_asset_name_filters).toEqual({
|
|
job2: ['img2.png']
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('deleteAssets - model cache invalidation', () => {
|
|
beforeEach(() => {
|
|
mockIsCloud.value = true
|
|
mockGetAssetType.mockReturnValue('input')
|
|
mockDeleteAsset.mockResolvedValue(undefined)
|
|
mockInvalidateModelsForCategory.mockClear()
|
|
mockSetAssetDeleting.mockClear()
|
|
mockUpdateHistory.mockClear()
|
|
mockUpdateInputs.mockClear()
|
|
mockHasCategory.mockClear()
|
|
// By default, hasCategory returns true for model categories
|
|
mockHasCategory.mockImplementation(
|
|
(tag: string) => tag === 'checkpoints' || tag === 'loras'
|
|
)
|
|
})
|
|
|
|
it('should invalidate model cache when deleting a model asset', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
const modelAsset = createMockAsset({
|
|
id: 'checkpoint-1',
|
|
name: 'model.safetensors',
|
|
tags: ['models', 'checkpoints']
|
|
})
|
|
|
|
mockShowDialog.mockImplementation(
|
|
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
|
void props.onConfirm()
|
|
}
|
|
)
|
|
|
|
await actions.deleteAssets(modelAsset)
|
|
|
|
// Only 'checkpoints' exists in cache; 'models' is excluded
|
|
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
|
|
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
|
'checkpoints'
|
|
)
|
|
})
|
|
|
|
it('should invalidate multiple categories for multiple assets', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
const assets = [
|
|
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
|
createMockAsset({ id: '2', tags: ['models', 'loras'] })
|
|
]
|
|
|
|
mockShowDialog.mockImplementation(
|
|
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
|
void props.onConfirm()
|
|
}
|
|
)
|
|
|
|
await actions.deleteAssets(assets)
|
|
|
|
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
|
'checkpoints'
|
|
)
|
|
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith('loras')
|
|
})
|
|
|
|
it('should not invalidate model cache for non-model assets', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
const inputAsset = createMockAsset({
|
|
id: 'input-1',
|
|
name: 'image.png',
|
|
tags: ['input']
|
|
})
|
|
|
|
mockShowDialog.mockImplementation(
|
|
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
|
void props.onConfirm()
|
|
}
|
|
)
|
|
|
|
await actions.deleteAssets(inputAsset)
|
|
|
|
// 'input' tag is excluded, so no cache invalidation
|
|
expect(mockInvalidateModelsForCategory).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should only invalidate categories that exist in cache', async () => {
|
|
const actions = useMediaAssetActions()
|
|
|
|
// hasCategory returns false for 'unknown-category'
|
|
mockHasCategory.mockImplementation((tag: string) => tag === 'checkpoints')
|
|
|
|
const assets = [
|
|
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
|
createMockAsset({ id: '2', tags: ['models', 'unknown-category'] })
|
|
]
|
|
|
|
mockShowDialog.mockImplementation(
|
|
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
|
void props.onConfirm()
|
|
}
|
|
)
|
|
|
|
await actions.deleteAssets(assets)
|
|
|
|
// Only checkpoints should be invalidated (unknown-category not in cache)
|
|
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
|
|
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
|
'checkpoints'
|
|
)
|
|
})
|
|
})
|
|
})
|