Compare commits
30 Commits
glary/mode
...
v1.45.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e31ce77c6 | ||
|
|
551c595bbb | ||
|
|
ee286291d4 | ||
|
|
efb214efe7 | ||
|
|
9a2bea7283 | ||
|
|
0a07781a76 | ||
|
|
b3ba6c9344 | ||
|
|
a50b3d16da | ||
|
|
3ce0c07af2 | ||
|
|
52d77e6ee0 | ||
|
|
f1f65cff61 | ||
|
|
b0144db644 | ||
|
|
8ee8dd03c4 | ||
|
|
d472ca783b | ||
|
|
e80ec6e3d4 | ||
|
|
2717d59451 | ||
|
|
d63b0f05bf | ||
|
|
cd2f4677c2 | ||
|
|
38fed22140 | ||
|
|
a95e53bf6d | ||
|
|
246b79dda9 | ||
|
|
7325c715c7 | ||
|
|
98a8a614e8 | ||
|
|
95b5207c06 | ||
|
|
2ab1abb898 | ||
|
|
64c75bfce5 | ||
|
|
cfd3f9e67b | ||
|
|
8af8a5f0b1 | ||
|
|
3b37488eee | ||
|
|
42dcb1cf7b |
@@ -41,6 +41,10 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
pnpm add -g typescript @vue/compiler-sfc
|
||||
|
||||
- name: Run Claude PR Review
|
||||
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
|
||||
|
||||
6
.github/workflows/weekly-docs-check.yaml
vendored
@@ -40,11 +40,11 @@ jobs:
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
# Check if packages are already available locally
|
||||
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
|
||||
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
|
||||
echo "Installing TypeScript and Vue compiler globally..."
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
pnpm add -g typescript @vue/compiler-sfc
|
||||
else
|
||||
echo "TypeScript and Vue compiler already available locally"
|
||||
echo "TypeScript and Vue compiler already available globally"
|
||||
fi
|
||||
|
||||
- name: Run Claude Documentation Review
|
||||
|
||||
3
.npmrc
@@ -1,3 +0,0 @@
|
||||
ignore-workspace-root-check=true
|
||||
catalog-mode=prefer
|
||||
public-hoist-pattern[]=@parcel/watcher
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-12T16:10:34.114Z",
|
||||
"fetchedAt": "2026-05-22T00:07:48.353Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
@@ -36,14 +36,14 @@
|
||||
"id": "6a6d865eeb3c10a8",
|
||||
"title": "Senior Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
|
||||
},
|
||||
{
|
||||
"id": "1b4f7f1da9616e14",
|
||||
"title": "Senior Software Engineer, Backend Generalist",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
|
||||
},
|
||||
{
|
||||
@@ -71,14 +71,14 @@
|
||||
"id": "91604c4182a1bc3c",
|
||||
"title": "Software Engineer, Core ComfyUI Contributor",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
|
||||
},
|
||||
{
|
||||
"id": "a1dbc0576ab14034",
|
||||
"title": "Software Engineer, ComfyUI Desktop",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
|
||||
},
|
||||
{
|
||||
@@ -105,21 +105,21 @@
|
||||
"id": "23dd98cab77ff459",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
|
||||
},
|
||||
{
|
||||
"id": "a998b9fc973ff3c0",
|
||||
"title": "Creative Artist",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
|
||||
},
|
||||
{
|
||||
"id": "3e730938026d6e70",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
|
||||
},
|
||||
{
|
||||
@@ -135,6 +135,20 @@
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
|
||||
},
|
||||
{
|
||||
"id": "e11f8b9e58dbea81",
|
||||
"title": "Creative Producer",
|
||||
"department": "Marketing",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
|
||||
},
|
||||
{
|
||||
"id": "6eac654593208ec3",
|
||||
"title": "Forward Deployed Creative Technologist",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_REGISTRY_BASE_URL,
|
||||
fetchRegistryPacks
|
||||
fetchRegistryPacks,
|
||||
fetchRegistryPacksWithNodes
|
||||
} from './cloudNodes.registry'
|
||||
|
||||
function jsonResponse(
|
||||
@@ -142,3 +143,315 @@ describe('fetchRegistryPacks', () => {
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchRegistryPacksWithNodes', () => {
|
||||
it('fetches pack metadata and comfy nodes for each pack', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
// Pack metadata request
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
|
||||
latest_version: { version: '8.0.0', createdAt: '2026-01-01' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// Comfy nodes request
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
return jsonResponse({
|
||||
comfy_nodes: [
|
||||
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
|
||||
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
|
||||
],
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['comfyui-impact-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
const packData = result.get('comfyui-impact-pack')
|
||||
expect(packData).not.toBeNull()
|
||||
expect(packData?.pack.name).toBe('ComfyUI Impact Pack')
|
||||
expect(packData?.nodes).toHaveLength(2)
|
||||
expect(packData?.nodes[0]?.comfy_node_name).toBe('FaceDetailer')
|
||||
})
|
||||
|
||||
it('handles pagination for comfy nodes', async () => {
|
||||
let comfyNodesCallCount = 0
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'big-pack',
|
||||
name: 'Big Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
comfyNodesCallCount++
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
|
||||
if (page === 1) {
|
||||
return jsonResponse({
|
||||
comfy_nodes: [
|
||||
{ comfy_node_name: 'Node1', category: 'cat1' },
|
||||
{ comfy_node_name: 'Node2', category: 'cat1' }
|
||||
],
|
||||
totalNumberOfPages: 2
|
||||
})
|
||||
} else {
|
||||
return jsonResponse({
|
||||
comfy_nodes: [{ comfy_node_name: 'Node3', category: 'cat2' }],
|
||||
totalNumberOfPages: 2
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['big-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(comfyNodesCallCount).toBe(2)
|
||||
const packData = result.get('big-pack')
|
||||
expect(packData?.nodes).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('returns null for packs without latest_version', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'no-version-pack',
|
||||
name: 'No Version Pack',
|
||||
latest_version: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['no-version-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(result.get('no-version-pack')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty nodes array when comfy-nodes request fails', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'failing-pack',
|
||||
name: 'Failing Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
return new Response('Server error', { status: 500 })
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['failing-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const packData = result.get('failing-pack')
|
||||
expect(packData).not.toBeNull()
|
||||
expect(packData?.pack.name).toBe('Failing Pack')
|
||||
expect(packData?.nodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles null comfy_nodes in response', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'null-nodes-pack',
|
||||
name: 'Null Nodes Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
return jsonResponse({
|
||||
comfy_nodes: null,
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['null-nodes-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const packData = result.get('null-nodes-pack')
|
||||
expect(packData?.nodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fetches nodes for multiple packs in parallel', async () => {
|
||||
const packIds = ['pack-a', 'pack-b', 'pack-c']
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
const requestedIds = url.searchParams.getAll('node_id')
|
||||
return jsonResponse({
|
||||
nodes: requestedIds.map((id) => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
latest_version: { version: '1.0.0' }
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
const packId = url.pathname.split('/nodes/')[1]?.split('/')[0]
|
||||
return jsonResponse({
|
||||
comfy_nodes: [
|
||||
{ comfy_node_name: `${packId}-node`, category: 'test' }
|
||||
],
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(packIds, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(result.size).toBe(3)
|
||||
for (const packId of packIds) {
|
||||
const packData = result.get(packId)
|
||||
expect(packData).not.toBeNull()
|
||||
expect(packData?.nodes[0]?.comfy_node_name).toBe(`${packId}-node`)
|
||||
}
|
||||
})
|
||||
|
||||
it('retries comfy-nodes fetch once on failure', async () => {
|
||||
let comfyNodesAttempts = 0
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'retry-pack',
|
||||
name: 'Retry Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
comfyNodesAttempts++
|
||||
if (comfyNodesAttempts === 1) {
|
||||
return new Response('Server error', { status: 500 })
|
||||
}
|
||||
return jsonResponse({
|
||||
comfy_nodes: [{ comfy_node_name: 'RetryNode', category: 'test' }],
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['retry-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(comfyNodesAttempts).toBe(2)
|
||||
const packData = result.get('retry-pack')
|
||||
expect(packData?.nodes).toHaveLength(1)
|
||||
expect(packData?.nodes[0]?.comfy_node_name).toBe('RetryNode')
|
||||
})
|
||||
|
||||
it('normalizes null boolean fields in comfy nodes', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'bool-pack',
|
||||
name: 'Bool Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
return jsonResponse({
|
||||
comfy_nodes: [
|
||||
{
|
||||
comfy_node_name: 'TestNode',
|
||||
category: 'test',
|
||||
deprecated: null,
|
||||
experimental: null
|
||||
}
|
||||
],
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['bool-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const packData = result.get('bool-pack')
|
||||
expect(packData?.nodes[0]?.deprecated).toBeUndefined()
|
||||
expect(packData?.nodes[0]?.experimental).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,10 @@ import type { components } from '@comfyorg/registry-types'
|
||||
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 5_000
|
||||
const BATCH_SIZE = 50
|
||||
const COMFY_NODES_PAGE_SIZE = 500
|
||||
|
||||
export type RegistryPack = components['schemas']['Node']
|
||||
export type RegistryComfyNode = components['schemas']['ComfyNode']
|
||||
|
||||
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
|
||||
return value ?? undefined
|
||||
@@ -58,6 +60,29 @@ const RegistryListResponseSchema = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RegistryComfyNodeSchema = z
|
||||
.object({
|
||||
comfy_node_name: optionalString,
|
||||
category: optionalString,
|
||||
description: optionalString,
|
||||
deprecated: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((v) => v ?? undefined),
|
||||
experimental: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((v) => v ?? undefined)
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RegistryComfyNodesResponseSchema = z
|
||||
.object({
|
||||
comfy_nodes: z.array(RegistryComfyNodeSchema).nullish(),
|
||||
totalNumberOfPages: z.number().nullish()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
interface FetchRegistryOptions {
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
@@ -122,6 +147,142 @@ export async function fetchRegistryPacks(
|
||||
return resolved
|
||||
}
|
||||
|
||||
export interface RegistryPackWithNodes {
|
||||
pack: RegistryPack
|
||||
nodes: RegistryComfyNode[]
|
||||
}
|
||||
|
||||
export async function fetchRegistryPacksWithNodes(
|
||||
packIds: readonly string[],
|
||||
options: FetchRegistryOptions = {}
|
||||
): Promise<Map<string, RegistryPackWithNodes | null>> {
|
||||
const packs = await fetchRegistryPacks(packIds, options)
|
||||
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
|
||||
const timeoutMs = clampTimeoutMs(options.timeoutMs)
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...packs.entries()].map(
|
||||
async ([packId, pack]): Promise<
|
||||
[string, RegistryPackWithNodes | null]
|
||||
> => {
|
||||
if (!pack?.latest_version?.version) {
|
||||
return [packId, null]
|
||||
}
|
||||
|
||||
const nodes = await fetchComfyNodesForPack(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
packId,
|
||||
pack.latest_version.version,
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
return [packId, { pack, nodes }]
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return new Map(entries)
|
||||
}
|
||||
|
||||
async function fetchComfyNodesForPack(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packId: string,
|
||||
version: string,
|
||||
timeoutMs: number
|
||||
): Promise<RegistryComfyNode[]> {
|
||||
const allNodes: RegistryComfyNode[] = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
while (page <= totalPages) {
|
||||
const result = await fetchComfyNodesPageWithRetry(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
packId,
|
||||
version,
|
||||
page,
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
if (!result) break
|
||||
|
||||
allNodes.push(...result.nodes)
|
||||
totalPages = result.totalPages
|
||||
page++
|
||||
}
|
||||
|
||||
return allNodes
|
||||
}
|
||||
|
||||
async function fetchComfyNodesPageWithRetry(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packId: string,
|
||||
version: string,
|
||||
page: number,
|
||||
timeoutMs: number
|
||||
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
|
||||
const firstAttempt = await fetchComfyNodesPage(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
packId,
|
||||
version,
|
||||
page,
|
||||
timeoutMs
|
||||
)
|
||||
if (firstAttempt) return firstAttempt
|
||||
|
||||
// Retry once on failure
|
||||
return fetchComfyNodesPage(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
packId,
|
||||
version,
|
||||
page,
|
||||
timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchComfyNodesPage(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packId: string,
|
||||
version: string,
|
||||
page: number,
|
||||
timeoutMs: number
|
||||
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const url = `${baseUrl}/nodes/${encodeURIComponent(packId)}/versions/${encodeURIComponent(version)}/comfy-nodes?limit=${COMFY_NODES_PAGE_SIZE}&page=${page}`
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!res.ok) return null
|
||||
|
||||
const rawBody: unknown = await res.json()
|
||||
const parsed = RegistryComfyNodesResponseSchema.safeParse(rawBody)
|
||||
if (!parsed.success) return null
|
||||
|
||||
return {
|
||||
nodes: (parsed.data.comfy_nodes ?? []) as RegistryComfyNode[],
|
||||
totalPages: parsed.data.totalNumberOfPages ?? 1
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBatchWithRetry(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
|
||||
@@ -8,12 +8,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
|
||||
|
||||
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
|
||||
import type { RegistryPackWithNodes } from './cloudNodes.registry'
|
||||
|
||||
const fetchRegistryPacksWithNodesMock = vi.hoisted(() =>
|
||||
vi.fn(async () => new Map<string, RegistryPackWithNodes | null>())
|
||||
)
|
||||
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes.registry', () => ({
|
||||
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
|
||||
fetchRegistryPacks: fetchRegistryPacksMock
|
||||
fetchRegistryPacksWithNodes: fetchRegistryPacksWithNodesMock
|
||||
}))
|
||||
|
||||
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
|
||||
@@ -90,8 +94,8 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesFetcherForTests()
|
||||
fetchRegistryPacksMock.mockReset()
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
fetchRegistryPacksWithNodesMock.mockReset()
|
||||
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
|
||||
sanitizeCallSpy.mockReset()
|
||||
delete process.env.WEBSITE_CLOUD_API_KEY
|
||||
})
|
||||
@@ -102,14 +106,21 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
})
|
||||
|
||||
it('returns fresh when API succeeds', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map([
|
||||
fetchRegistryPacksWithNodesMock.mockResolvedValue(
|
||||
new Map<string, RegistryPackWithNodes | null>([
|
||||
[
|
||||
'comfyui-impact-pack',
|
||||
{
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
pack: {
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
},
|
||||
nodes: [
|
||||
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
|
||||
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
|
||||
]
|
||||
}
|
||||
]
|
||||
])
|
||||
@@ -129,6 +140,10 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
||||
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
)
|
||||
// Nodes should come from registry, not object_info
|
||||
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(2)
|
||||
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('DetailerForEach')
|
||||
expect(outcome.snapshot.packs[0]?.nodes[1]?.name).toBe('FaceDetailer')
|
||||
})
|
||||
|
||||
it('drops invalid nodes individually and keeps valid nodes', async () => {
|
||||
@@ -297,7 +312,7 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
})
|
||||
|
||||
it('returns fresh even when registry enrichment fails', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
@@ -305,5 +320,8 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
// Falls back to object_info nodes when registry fails
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('ImpactNode')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
validateComfyNodeDef
|
||||
} from '@comfyorg/object-info-parser'
|
||||
|
||||
import type { RegistryPack } from './cloudNodes.registry'
|
||||
import type {
|
||||
RegistryComfyNode,
|
||||
RegistryPackWithNodes
|
||||
} from './cloudNodes.registry'
|
||||
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
|
||||
|
||||
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
|
||||
import { isNodesSnapshot } from '../data/cloudNodes'
|
||||
import { fetchRegistryPacks } from './cloudNodes.registry'
|
||||
import { fetchRegistryPacksWithNodes } from './cloudNodes.registry'
|
||||
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
@@ -235,26 +238,28 @@ async function parseCloudNodes(
|
||||
const sanitizedDefs = sanitizeUserContent(
|
||||
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
|
||||
)
|
||||
const grouped = groupNodesByPack(sanitizedDefs)
|
||||
|
||||
let registryMap = new Map<string, RegistryPack | null>()
|
||||
// Use object_info to determine which packs are cloud-supported
|
||||
const grouped = groupNodesByPack(sanitizedDefs)
|
||||
const packIds = grouped.map((pack) => pack.id)
|
||||
|
||||
// Fetch full pack metadata and node list from registry
|
||||
let registryMap = new Map<string, RegistryPackWithNodes | null>()
|
||||
try {
|
||||
registryMap = await fetchRegistryPacks(
|
||||
grouped.map((pack) => pack.id),
|
||||
{ fetchImpl: options.fetchImpl }
|
||||
)
|
||||
registryMap = await fetchRegistryPacksWithNodes(packIds, {
|
||||
fetchImpl: options.fetchImpl
|
||||
})
|
||||
} catch {
|
||||
registryMap = new Map()
|
||||
}
|
||||
|
||||
const packs = grouped.map((pack) =>
|
||||
toDomainPack(
|
||||
pack.id,
|
||||
pack.displayName,
|
||||
pack.nodes,
|
||||
registryMap.get(pack.id)
|
||||
)
|
||||
)
|
||||
const packs = grouped
|
||||
.map((pack) => {
|
||||
const registryData = registryMap.get(pack.id)
|
||||
// Use registry nodes if available, otherwise fall back to object_info nodes
|
||||
return toDomainPack(pack.id, pack.displayName, pack.nodes, registryData)
|
||||
})
|
||||
.filter((pack) => pack.nodes.length > 0)
|
||||
|
||||
return { kind: 'ok', packs, droppedNodes }
|
||||
}
|
||||
@@ -274,7 +279,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
function toDomainPack(
|
||||
packId: string,
|
||||
fallbackDisplayName: string,
|
||||
nodes: Array<{
|
||||
objectInfoNodes: Array<{
|
||||
className: string
|
||||
def: {
|
||||
display_name: string
|
||||
@@ -284,8 +289,18 @@ function toDomainPack(
|
||||
experimental?: boolean
|
||||
}
|
||||
}>,
|
||||
registryPack: RegistryPack | null | undefined
|
||||
registryData: RegistryPackWithNodes | null | undefined
|
||||
): Pack {
|
||||
const registryPack = registryData?.pack
|
||||
|
||||
// Prefer registry nodes if available, fall back to object_info nodes
|
||||
const nodes =
|
||||
registryData?.nodes && registryData.nodes.length > 0
|
||||
? registryData.nodes
|
||||
.map((node) => toDomainNodeFromRegistry(node))
|
||||
.filter((n): n is PackNode => n !== null)
|
||||
: objectInfoNodes.map((node) => toDomainNode(node.className, node.def))
|
||||
|
||||
return {
|
||||
id: packId,
|
||||
registryId: registryPack?.id,
|
||||
@@ -308,9 +323,20 @@ function toDomainPack(
|
||||
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
|
||||
supportedOs: registryPack?.supported_os,
|
||||
supportedAccelerators: registryPack?.supported_accelerators,
|
||||
nodes: nodes
|
||||
.map((node) => toDomainNode(node.className, node.def))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
nodes: nodes.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainNodeFromRegistry(node: RegistryComfyNode): PackNode | null {
|
||||
if (!node.comfy_node_name) return null
|
||||
|
||||
return {
|
||||
name: node.comfy_node_name,
|
||||
displayName: node.comfy_node_name,
|
||||
category: node.category || '',
|
||||
description: node.description || undefined,
|
||||
deprecated: node.deprecated,
|
||||
experimental: node.experimental
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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' })
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
@@ -84,11 +86,12 @@ export class ComfyNodeSearchBoxV2 {
|
||||
await this.input.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async openByDoubleClickCanvas(): Promise<void> {
|
||||
async openByDoubleClickCanvas(position?: Position) {
|
||||
const { x, y } = position ?? { x: 200, y: 200 }
|
||||
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
|
||||
// does not intercept; coords target a viewport spot that is on the canvas
|
||||
// and clear of both the side toolbar and any default-graph nodes.
|
||||
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
await this.comfyPage.page.mouse.dblclick(x, y, { delay: 5 })
|
||||
}
|
||||
|
||||
async ensureV2Search(): Promise<void> {
|
||||
@@ -109,4 +112,14 @@ export class ComfyNodeSearchBoxV2 {
|
||||
'search box'
|
||||
)
|
||||
}
|
||||
|
||||
async addNode(query: string, options: { position?: Position } = {}) {
|
||||
const position = options.position ?? { x: 200, y: 200 }
|
||||
await this.openByDoubleClickCanvas(position)
|
||||
await this.input.fill(query)
|
||||
await expect(this.results.first()).toContainText(query)
|
||||
await this.comfyPage.page.keyboard.press('Enter')
|
||||
await expect(this.dialog).toBeHidden()
|
||||
await this.comfyPage.page.mouse.click(position.x, position.y)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,24 @@ import type { Locator } from '@playwright/test'
|
||||
|
||||
export class WidgetSelectDropdownFixture {
|
||||
public readonly selection: Locator
|
||||
public readonly trigger: Locator
|
||||
|
||||
constructor(public readonly root: Locator) {
|
||||
this.trigger = root.locator('button:has(> span)').first()
|
||||
this.selection = root.locator('button span span')
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.trigger.click()
|
||||
}
|
||||
|
||||
async searchAndSelectTop(popover: Locator, query: string): Promise<void> {
|
||||
await this.open()
|
||||
const searchInput = popover.getByRole('textbox')
|
||||
await searchInput.fill(query)
|
||||
await searchInput.press('Enter')
|
||||
}
|
||||
|
||||
async selectedItem(): Promise<string> {
|
||||
return await this.selection.innerText()
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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'] }
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
|
||||
/**
|
||||
* Helper for interacting with widgets rendered in app mode (linear view).
|
||||
@@ -24,6 +25,11 @@ export class AppModeWidgetHelper {
|
||||
return this.container.locator(`[data-widget-key="${key}"]`)
|
||||
}
|
||||
|
||||
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
|
||||
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
|
||||
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
|
||||
}
|
||||
|
||||
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
|
||||
async fillTextarea(key: string, value: string) {
|
||||
const widget = this.getWidgetItem(key)
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
@@ -241,6 +242,17 @@ export class SubgraphHelper {
|
||||
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
|
||||
}
|
||||
|
||||
async getInputBounds(): Promise<Position & Size> {
|
||||
return await this.comfyPage.page.evaluate(() => {
|
||||
const graph = app!.canvas.graph as Subgraph
|
||||
const inputNode = graph.inputNode
|
||||
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
|
||||
const width = inputNode.size[0] * app!.canvas.ds.scale
|
||||
const height = inputNode.size[1] * app!.canvas.ds.scale
|
||||
return { x, y, width, height }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph input.
|
||||
* This creates a new input slot on the subgraph if targetInputName is not provided.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@comfyorg/ingest-types/zod'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
JobStatus,
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
@@ -40,7 +41,7 @@ interface JobsListRoute {
|
||||
responseLimit?: number
|
||||
}
|
||||
|
||||
interface JobsScenario {
|
||||
export interface JobsScenario {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
}
|
||||
@@ -182,6 +183,24 @@ export class JobsRouteMocker {
|
||||
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
|
||||
}
|
||||
|
||||
async mockDeleteHistory(): Promise<HistoryManageRequest[]> {
|
||||
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
|
||||
}
|
||||
|
||||
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
|
||||
await this.page.route(
|
||||
(url) => url.pathname.endsWith(`/api/jobs/${encodeURIComponent(jobId)}`),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'GET') {
|
||||
await requestRoute.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
await requestRoute.fulfill({ json: detail })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async mockPostManageRoute<TRequest>(
|
||||
type: 'queue' | 'history',
|
||||
requestSchema: z.ZodType<TRequest>,
|
||||
|
||||
@@ -76,7 +76,15 @@ export const TestIds = {
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-dialog',
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
cloudNotification: 'cloud-notification-dialog',
|
||||
openSharedWorkflow: 'open-shared-workflow-dialog',
|
||||
openSharedWorkflowTitle: 'open-shared-workflow-title',
|
||||
openSharedWorkflowClose: 'open-shared-workflow-close',
|
||||
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
|
||||
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
|
||||
openSharedWorkflowOpenWithoutImporting:
|
||||
'open-shared-workflow-open-without-importing',
|
||||
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
250
browser_tests/fixtures/sharedWorkflowImportFixture.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ImportPublishedAssetsRequest,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
export const sharedWorkflowImportScenario = {
|
||||
shareId: 'shared-missing-media-e2e',
|
||||
workflowId: 'shared-missing-media-workflow',
|
||||
publishedAssetId: 'published-input-asset-1',
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
export interface SharedWorkflowImportMocks {
|
||||
resetAndStartRecording: () => void
|
||||
getImportBody: () => ImportPublishedAssetsRequest | undefined
|
||||
getRequestEvents: () => SharedWorkflowRequestEvent[]
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
|
||||
}
|
||||
|
||||
const defaultInputFileName = '00000000000000000000000Aexample.png'
|
||||
|
||||
const sharedWorkflowAsset: AssetInfo = {
|
||||
id: sharedWorkflowImportScenario.publishedAssetId,
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const sharedWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: sharedWorkflowImportScenario.shareId,
|
||||
workflow_id: sharedWorkflowImportScenario.workflowId,
|
||||
name: 'Shared Missing Media Workflow',
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 10,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'LoadImage',
|
||||
pos: [50, 200],
|
||||
size: [315, 314],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
links: null
|
||||
},
|
||||
{
|
||||
name: 'MASK',
|
||||
type: 'MASK',
|
||||
links: null
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'LoadImage'
|
||||
},
|
||||
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
assets: [sharedWorkflowAsset]
|
||||
}
|
||||
|
||||
export const sharedWorkflowImportFixture = base.extend<{
|
||||
sharedWorkflowImportMocks: SharedWorkflowImportMocks
|
||||
}>({
|
||||
sharedWorkflowImportMocks: async ({ page }, use) => {
|
||||
const mocks = await mockSharedWorkflowImportFlow(page)
|
||||
await use(mocks)
|
||||
}
|
||||
})
|
||||
|
||||
async function mockSharedWorkflowImportFlow(
|
||||
page: Page
|
||||
): Promise<SharedWorkflowImportMocks> {
|
||||
let isRecording = false
|
||||
let importEndpointCalled = false
|
||||
let importBody: ImportPublishedAssetsRequest | undefined
|
||||
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
|
||||
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
const requestEvents: SharedWorkflowRequestEvent[] = []
|
||||
|
||||
function resetPublicInclusiveInputAssetResponseWaiter() {
|
||||
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
|
||||
if (isRecording) requestEvents.push(event)
|
||||
}
|
||||
|
||||
await page.route(
|
||||
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(sharedWorkflowResponse)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await page.route('**/api/assets/import', async (route) => {
|
||||
recordRequestEvent('import')
|
||||
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
|
||||
importEndpointCalled = true
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
|
||||
// Excludes `/api/assets/import` so the specific route above
|
||||
// remains isolated from the general asset listing mock.
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = getTagParam(url, 'include_tags')
|
||||
const isInputAssetRequest = includeTags.includes('input')
|
||||
const includesPublicAssets =
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
const isPublicInclusiveInputAssetRequest =
|
||||
isInputAssetRequest && includesPublicAssets
|
||||
const isAfterImportPublicInclusiveInputAssetRequest =
|
||||
isPublicInclusiveInputAssetRequest && importEndpointCalled
|
||||
|
||||
if (isPublicInclusiveInputAssetRequest) {
|
||||
recordRequestEvent(
|
||||
importEndpointCalled
|
||||
? 'input-assets-including-public-after-import'
|
||||
: 'input-assets-including-public-before-import'
|
||||
)
|
||||
}
|
||||
|
||||
const allAssets = [
|
||||
defaultInputAsset,
|
||||
...(importEndpointCalled ? [importedInputAsset] : [])
|
||||
]
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
|
||||
if (isAfterImportPublicInclusiveInputAssetRequest) {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
resetAndStartRecording: () => {
|
||||
isRecording = true
|
||||
importEndpointCalled = false
|
||||
importBody = undefined
|
||||
requestEvents.length = 0
|
||||
resetPublicInclusiveInputAssetResponseWaiter()
|
||||
},
|
||||
getImportBody: () => importBody,
|
||||
getRequestEvents: () => [...requestEvents],
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
|
||||
publicInclusiveInputAssetResponseAfterImport
|
||||
}
|
||||
}
|
||||
|
||||
function getTagParam(url: URL, key: string): string[] {
|
||||
return (
|
||||
url.searchParams
|
||||
.get(key)
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
28
browser_tests/fixtures/utils/selectionToolboxMoreOptions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export async function openMoreOptionsMenu(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
) {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
if (nodes.length === 0) {
|
||||
throw new Error(`No "${nodeTitle}" nodes found`)
|
||||
}
|
||||
|
||||
await nodes[0].centerOnNode()
|
||||
await nodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
return menu
|
||||
}
|
||||
@@ -2,16 +2,10 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
|
||||
const { centerPanel } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(centerPanel, 'Enter app mode').toBeVisible()
|
||||
@@ -25,15 +19,12 @@ test.describe('App mode usage', () => {
|
||||
//prep a load image
|
||||
await test.step('Add a load image node', async () => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(loadImage).toBeVisible()
|
||||
})
|
||||
|
||||
const imageInput = new WidgetSelectDropdownFixture(
|
||||
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
|
||||
)
|
||||
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
|
||||
|
||||
await test.step('Enter app mode with image input', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
|
||||
@@ -107,6 +98,45 @@ test.describe('App mode usage', () => {
|
||||
//verify values are consistent with litegraph
|
||||
})
|
||||
|
||||
test('FormDropdown search Enter selects the top filtered item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const fileComboWidget = await loadImageNode.getWidget(0)
|
||||
const targetImage = String(await fileComboWidget.getValue())
|
||||
const initialImage = 'not-selected.png'
|
||||
await comfyPage.page.evaluate(
|
||||
([nodeId, value]) => {
|
||||
const node = window.app!.graph!.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.[0]
|
||||
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
|
||||
|
||||
widget.value = value
|
||||
},
|
||||
[loadImageNode.id, initialImage] as const
|
||||
)
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
[String(loadImageNode.id), 'image']
|
||||
])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
|
||||
`${loadImageNode.id}:image`
|
||||
)
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
|
||||
await expect(imageInput.root).toBeVisible()
|
||||
await imageInput.searchAndSelectTop(popover, targetImage)
|
||||
|
||||
await expect(popover).toBeHidden()
|
||||
await expect(imageInput.selection).toHaveText(targetImage)
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
|
||||
@@ -75,33 +75,28 @@ test.describe('App mode builder selection', () => {
|
||||
})
|
||||
|
||||
test('Marks canvas readOnly', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
comfyPage.searchBoxV2.input,
|
||||
'Canvas is initially editable'
|
||||
).toHaveCount(1)
|
||||
).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
comfyPage.searchBoxV2.input,
|
||||
'Entering builder makes the canvas readonly'
|
||||
).toHaveCount(0)
|
||||
).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Space')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
comfyPage.searchBoxV2.input,
|
||||
'Canvas remains readonly after pressing space'
|
||||
).toHaveCount(0)
|
||||
).toBeHidden()
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
@@ -112,10 +107,10 @@ test.describe('App mode builder selection', () => {
|
||||
).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
comfyPage.searchBoxV2.input,
|
||||
'Canvas is no longer readonly after exiting'
|
||||
).toHaveCount(1)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,13 +131,10 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the FormDropdown trigger button for the image widget.
|
||||
// The button emits 'select-click' which toggles the Popover.
|
||||
const imageRow = widgetList.locator(
|
||||
'div:has(> div > span:text-is("image"))'
|
||||
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
|
||||
`${loadImageId}:image`
|
||||
)
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
await imageInput.open()
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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()`.
|
||||
@@ -1,514 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -361,3 +361,15 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
@@ -43,4 +44,45 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
|
||||
await expect(comfyPage.canvas).toBeHidden()
|
||||
})
|
||||
|
||||
test('Spinner persists until workflow loaded', async ({
|
||||
page,
|
||||
request
|
||||
}, testInfo) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
const { parallelIndex } = testInfo
|
||||
const username = `playwright-test-${parallelIndex}`
|
||||
const userId = await comfyPage.setupUser(username)
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
await page.goto(`${comfyPage.url}/api/users`)
|
||||
await page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, comfyPage.id)
|
||||
|
||||
const splash = page.locator('#splash-loader')
|
||||
|
||||
let notifyWorkflowRequested!: () => void
|
||||
const workflowRequested = new Promise<void>(
|
||||
(r) => (notifyWorkflowRequested = r)
|
||||
)
|
||||
let unblockRequest!: () => void
|
||||
const requestUnblocked = new Promise<void>((r) => (unblockRequest = r))
|
||||
|
||||
await page.route('**/templates/default.json', async (route) => {
|
||||
notifyWorkflowRequested()
|
||||
await requestUnblocked
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await comfyPage.goto({ url: `${comfyPage.url}/?template=default` })
|
||||
await workflowRequested
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await expect(splash).toBeVisible()
|
||||
unblockRequest()
|
||||
await expect(splash).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 55 KiB |
65
browser_tests/tests/nodeContextMenuShapeSubmenu.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.describe(
|
||||
'Node context menu shape submenu (FE-570)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
|
||||
const popover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(popover).toBeVisible()
|
||||
await expect(popover).toContainText('Box')
|
||||
await expect(popover).toContainText('Card')
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(popoverBox!.width).toBeGreaterThan(0)
|
||||
expect(popoverBox!.height).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test('Shape popover opens when the menu fits in the viewport', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
|
||||
.toBe('visible')
|
||||
|
||||
await menu.getByRole('menuitem', { name: 'Shape' }).click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
await shapeItem.click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
}
|
||||
)
|
||||
38
browser_tests/tests/priceBadge.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -18,61 +19,8 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
// Drag the KSampler to the center of the screen
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
if (!viewportSize) {
|
||||
throw new Error(
|
||||
'Viewport size is null - page may not be properly initialized'
|
||||
)
|
||||
}
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height / 2
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisible = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisibleAfterClick) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
const openMoreOptions = (comfyPage: ComfyPage) =>
|
||||
openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
|
||||
test('hides Node Info from More Options menu when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
@@ -92,11 +40,14 @@ test.describe(
|
||||
)[0]
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Box', { exact: true })
|
||||
).toBeVisible()
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
// Shape now opens via body-appended popover (FE-570); a hover no
|
||||
// longer reveals the submenu — match the Color flow and click.
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).click()
|
||||
const shapePopover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
|
||||
await shapePopover.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
|
||||
|
||||
147
browser_tests/tests/sharedWorkflowMissingMedia.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
sharedWorkflowImportFixture,
|
||||
sharedWorkflowImportScenario
|
||||
} from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
const IMPORT_ORDER_TIMEOUT_MS = 5_000
|
||||
|
||||
async function expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await expect(async () => {
|
||||
const events = mocks.getRequestEvents()
|
||||
const importIndex = events.indexOf('import')
|
||||
const afterImportIndex = events.indexOf(
|
||||
'input-assets-including-public-after-import'
|
||||
)
|
||||
|
||||
expect(
|
||||
events,
|
||||
'public-inclusive input assets must not be scanned before import'
|
||||
).not.toContain('input-assets-including-public-before-import')
|
||||
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
|
||||
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
|
||||
importIndex
|
||||
)
|
||||
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage: ComfyPage,
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
async function openPanelAndExpectNoMissingMedia(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const page = comfyPage.page
|
||||
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
|
||||
|
||||
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
|
||||
// Missing media only surfaces the overlay when the Errors tab is enabled
|
||||
// (src/stores/executionErrorStore.ts).
|
||||
test.use({
|
||||
initialSettings: {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': true
|
||||
}
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
|
||||
sharedWorkflowImportMocks.resetAndStartRecording()
|
||||
await comfyPage.setup({
|
||||
clearStorage: false,
|
||||
url: `/?share=${sharedWorkflowImportScenario.shareId}`
|
||||
})
|
||||
})
|
||||
|
||||
test('imports shared media before loading workflow so missing media is not surfaced', async ({
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((node) => ({
|
||||
type: node.type,
|
||||
value: node.widgets?.[0]?.value
|
||||
}))
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
{
|
||||
type: 'LoadImage',
|
||||
value: sharedWorkflowImportScenario.inputFileName
|
||||
}
|
||||
])
|
||||
await expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
|
||||
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
|
||||
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
|
||||
share_id: sharedWorkflowImportScenario.shareId
|
||||
})
|
||||
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
|
||||
await openPanelAndExpectNoMissingMedia(comfyPage)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,9 @@ import type {
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
|
||||
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
278
browser_tests/tests/sidebar/assetsSidebarTab.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture,
|
||||
routeMockJobTimestamp
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
|
||||
interface ViewFile {
|
||||
body?: Buffer | string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
type ViewFilesByName = Readonly<Record<string, ViewFile>>
|
||||
|
||||
const transparentPng = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
)
|
||||
|
||||
const alphaJob = createRouteMockJob({
|
||||
id: 'alpha',
|
||||
create_time: routeMockJobTimestamp - 1_000,
|
||||
execution_start_time: routeMockJobTimestamp - 1_000,
|
||||
execution_end_time: routeMockJobTimestamp,
|
||||
preview_output: {
|
||||
filename: 'alpha.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
|
||||
const betaJob = createRouteMockJob({
|
||||
id: 'beta',
|
||||
create_time: routeMockJobTimestamp - 2_000,
|
||||
execution_start_time: routeMockJobTimestamp - 2_000,
|
||||
execution_end_time: routeMockJobTimestamp,
|
||||
preview_output: {
|
||||
filename: 'beta.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
|
||||
const multiOutputJob = createRouteMockJob({
|
||||
id: 'multi-output',
|
||||
create_time: routeMockJobTimestamp - 3_000,
|
||||
execution_start_time: routeMockJobTimestamp - 3_000,
|
||||
execution_end_time: routeMockJobTimestamp,
|
||||
preview_output: {
|
||||
filename: 'multi-output-a.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
|
||||
const multiOutputJobDetail: JobDetail = {
|
||||
...multiOutputJob,
|
||||
outputs: {
|
||||
'3': {
|
||||
images: [
|
||||
{
|
||||
filename: 'multi-output-a.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
},
|
||||
{
|
||||
filename: 'multi-output-b.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generatedJobs: RawJobListItem[] = [alphaJob, betaJob]
|
||||
|
||||
const viewFiles = {
|
||||
'alpha.png': {},
|
||||
'beta.png': {},
|
||||
'imported.png': {},
|
||||
'multi-output-a.png': {},
|
||||
'multi-output-b.png': {}
|
||||
}
|
||||
|
||||
async function mockInputFiles(page: Page, files: readonly string[]) {
|
||||
await page.route('**/internal/files/input**', async (route) => {
|
||||
if (route.request().method().toUpperCase() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({ json: [...files] })
|
||||
})
|
||||
}
|
||||
|
||||
async function mockViewFiles(page: Page, filesByName: ViewFilesByName) {
|
||||
await page.route('**/api/view**', async (route) => {
|
||||
if (route.request().method().toUpperCase() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url())
|
||||
const filename = url.searchParams.get('filename')
|
||||
if (!filename) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
json: { error: 'Missing filename' } satisfies { error: string }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const file = filesByName[filename]
|
||||
if (!file) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
json: {
|
||||
error: `Unknown filename: ${filename}`
|
||||
} satisfies { error: string }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
body: file.body ?? transparentPng,
|
||||
contentType: file.contentType ?? 'image/png'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
test.beforeEach(async ({ jobsRoutes, page }) => {
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
await jobsRoutes.mockJobsHistory(generatedJobs)
|
||||
await mockInputFiles(page, ['imported.png'])
|
||||
await mockViewFiles(page, viewFiles)
|
||||
})
|
||||
|
||||
test('renders generated and imported assets with image previews', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
|
||||
await expect(tab.getAssetCardByName('beta')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('img', { name: 'alpha.png' })
|
||||
).toHaveJSProperty('naturalWidth', 1)
|
||||
|
||||
await tab.switchToImported()
|
||||
|
||||
await expect(tab.getAssetCardByName('imported')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('img', { name: 'imported.png' })
|
||||
).toHaveJSProperty('naturalWidth', 1)
|
||||
})
|
||||
|
||||
test('opens previews for generated and imported images', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.page.getByRole('img', { name: 'alpha.png' }).dblclick()
|
||||
await expect(comfyPage.mediaLightbox.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.mediaLightbox.root.getByRole('img', {
|
||||
name: 'alpha.png'
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.mediaLightbox.closeButton.click()
|
||||
await expect(comfyPage.mediaLightbox.root).toBeHidden()
|
||||
|
||||
await tab.switchToImported()
|
||||
|
||||
await comfyPage.page.getByRole('img', { name: 'imported.png' }).dblclick()
|
||||
await expect(comfyPage.mediaLightbox.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.mediaLightbox.root.getByRole('img', {
|
||||
name: 'imported.png'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows footer actions for single and multiple generated selections', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.getAssetCardByName('beta').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('loads full generated job outputs from job detail', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await jobsRoutes.mockJobsHistory([multiOutputJob])
|
||||
await jobsRoutes.mockJobDetail('multi-output', multiOutputJobDetail)
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
|
||||
await tab
|
||||
.getAssetCardByName('multi-output-a')
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect(tab.getAssetCardByName('multi-output-b')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('img', { name: 'multi-output-b.png' })
|
||||
).toHaveJSProperty('naturalWidth', 1)
|
||||
})
|
||||
|
||||
test('deletes a generated output asset through explicit history refresh', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
|
||||
|
||||
const deleteRequests = await jobsRoutes.mockDeleteHistory()
|
||||
await jobsRoutes.mockJobsHistory([betaJob])
|
||||
|
||||
await tab.getAssetCardByName('alpha').click({ button: 'right' })
|
||||
await tab.contextMenuItem('Delete').click()
|
||||
await comfyPage.confirmDialog.delete.click()
|
||||
|
||||
await expect.poll(() => deleteRequests).toHaveLength(1)
|
||||
expect(deleteRequests[0]).toEqual({ delete: ['alpha'] })
|
||||
await expect(tab.getAssetCardByName('alpha')).toHaveCount(0)
|
||||
await expect(comfyPage.toast.toastSuccesses).toContainText(
|
||||
'Asset deleted successfully'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
jobsRouteFixture,
|
||||
routeMockJobTimestamp
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type { JobsRouteMocker } from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type { JobsScenario } 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',
|
||||
@@ -75,21 +73,24 @@ const activeJobs: RawJobListItem[] = [
|
||||
]
|
||||
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()
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture).extend<{
|
||||
initialJobsScenario: JobsScenario
|
||||
mockInitialJobsScenario: void
|
||||
}>({
|
||||
initialJobsScenario: [
|
||||
{ history: historyJobs, queue: activeJobs },
|
||||
{ option: true }
|
||||
],
|
||||
mockInitialJobsScenario: [
|
||||
async ({ jobsRoutes, initialJobsScenario }, use) => {
|
||||
await jobsRoutes.mockJobsScenario(initialJobsScenario)
|
||||
await use()
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
async function openJobHistorySidebar(comfyPage: ComfyPage) {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.sidebar.toolbar)
|
||||
.getByRole('button', { name: 'Job History', exact: true })
|
||||
@@ -122,159 +123,152 @@ async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
|
||||
}
|
||||
|
||||
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
|
||||
test.describe('docked overlay action', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': false } })
|
||||
|
||||
test('opens from the queue overlay docked history action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
})
|
||||
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)
|
||||
test.describe('expanded history tab', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': true } })
|
||||
|
||||
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()
|
||||
test('shows terminal and active job states', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeEnabled()
|
||||
})
|
||||
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()
|
||||
|
||||
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)).toBeEnabled()
|
||||
})
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
})
|
||||
test('filters completed and failed history jobs', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
test('clears pending queue jobs and leaves running/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('queue-pending')).toBeVisible()
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: runningOnlyJobs
|
||||
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()
|
||||
})
|
||||
|
||||
await clearQueueButton(comfyPage).click()
|
||||
test('searches by job id and output filename', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
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)
|
||||
})
|
||||
const row = jobRow(comfyPage)
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search...')
|
||||
|
||||
test('clears history from the sidebar menu and keeps active jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
await searchInput.fill('history-failed')
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await searchInput.fill('completed-output')
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: [],
|
||||
queue: activeJobs
|
||||
await searchInput.clear()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
})
|
||||
|
||||
await openSidebarClearHistoryDialog(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click()
|
||||
test('clears pending queue jobs and leaves running/history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
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)
|
||||
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 openJobHistorySidebar(comfyPage)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('without pending queue jobs', () => {
|
||||
test.use({
|
||||
initialJobsScenario: { history: historyJobs, queue: runningOnlyJobs },
|
||||
initialSettings: { 'Comfy.Queue.QPOV2': true }
|
||||
})
|
||||
|
||||
test('disables clear queue', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,4 +129,26 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
})
|
||||
|
||||
test('Click-to-place from sidebar selects the newly added node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await tab.expandFolder('sampling')
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
const target = {
|
||||
x: canvasBox.width / 2,
|
||||
y: canvasBox.height / 2
|
||||
}
|
||||
|
||||
await tab.getNode('KSampler (Advanced)').click()
|
||||
await comfyPage.canvas.click({ position: target })
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Subgraph Clipboard Operations', () => {
|
||||
@@ -58,8 +54,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.searchBoxV2.addNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
@@ -745,20 +745,19 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
|
||||
})
|
||||
|
||||
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const position = { x: 300, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const position = { x: 600, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
|
||||
@@ -632,3 +632,72 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'link interactions',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const seedSlot = ksampler.getSlot('seed')
|
||||
const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed')
|
||||
|
||||
await test.step('Make second INT typed connection', async () => {
|
||||
const toPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(seedSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
})
|
||||
|
||||
const stepsSlot = ksampler.getSlot('steps')
|
||||
|
||||
await test.step('Node -> I/O hover effect', async () => {
|
||||
await stepsSlot.hover()
|
||||
await stepsSlot.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
|
||||
clip
|
||||
})
|
||||
|
||||
//cancel link operation
|
||||
await stepsSlot.hover()
|
||||
await comfyPage.page.mouse.up()
|
||||
})
|
||||
|
||||
await ksampler.title.hover()
|
||||
|
||||
const slotParent = stepsSlot.locator('../..')
|
||||
await expect(slotParent, 'unconnected slot is hidden').toHaveCSS(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
|
||||
await test.step('Connect I/O to node with snap', async () => {
|
||||
const hasSnap = () =>
|
||||
comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
|
||||
expect(await hasSnap()).toBe(false)
|
||||
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
await stepsSlot.hover()
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
//move hover off the slot
|
||||
await ksampler.title.hover()
|
||||
})
|
||||
|
||||
await expect(slotParent, 'connected slot is visible').not.toHaveCSS(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
|
||||
@@ -1082,17 +1082,10 @@ test.describe(
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
// Setup workflow with a KSampler node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nodeOps.waitForGraphNodes(0)
|
||||
await comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
await comfyPage.searchBoxV2.addNode('KSampler')
|
||||
await comfyPage.nodeOps.waitForGraphNodes(1)
|
||||
|
||||
// Convert the KSampler node to a subgraph
|
||||
|
||||
@@ -19,3 +19,19 @@ test('Can display a slot mismatched from widget type', async ({
|
||||
await expect(width.locator('path[fill*="INT"]')).toBeVisible()
|
||||
await expect(width.locator('path[fill*="FLOAT"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('MatchType updates output color @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await comfyPage.searchBoxV2.addNode('Switch', {
|
||||
position: { x: 600, y: 200 }
|
||||
})
|
||||
const switchNode = await comfyPage.vueNodes.getFixtureByTitle('switch')
|
||||
|
||||
await loadImage.getSlot('MASK').dragTo(switchNode.getSlot('on_false'))
|
||||
const slotEl = switchNode.getSlot('output').locator('.slot-dot')
|
||||
await expect.poll(() => slotEl.getAttribute('style')).toContain('MASK')
|
||||
})
|
||||
|
||||
@@ -9,8 +9,6 @@ const file1 = 'workflow.mp4' as const
|
||||
const file2 = 'workflow.webm' as const
|
||||
|
||||
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
|
||||
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
|
||||
const loadVideo = new VideoPreview(loadVideoNode)
|
||||
|
||||
@@ -18,9 +16,7 @@ test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(500, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Video')
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Video')
|
||||
await expect(loadVideoNode).toHaveCount(1)
|
||||
await expect(loadVideoNode).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
|
||||
const loadAudioNode = comfyPage.vueNodes.getNodeByTitle('Load Audio')
|
||||
const audioPreview = new AudioPreview(loadAudioNode)
|
||||
|
||||
@@ -14,9 +12,7 @@ test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
//await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Audio')
|
||||
await comfyPage.searchBoxV2.addNode('Load Audio')
|
||||
await expect(loadAudioNode).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.9",
|
||||
"version": "1.45.13",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -37,7 +37,7 @@
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src browser_tests --type-aware",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"storybook": "nx storybook",
|
||||
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
|
||||
@@ -60,6 +60,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/fbx-exporter-three": "^1.0.1",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
@@ -112,7 +113,7 @@
|
||||
"primevue": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"semver": "^7.7.2",
|
||||
"three": "^0.170.0",
|
||||
"three": "catalog:",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
@@ -210,20 +211,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x",
|
||||
"pnpm": ">=10"
|
||||
"pnpm": ">=11"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@firebase/util",
|
||||
"core-js",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"unrs-resolver",
|
||||
"vue-demi"
|
||||
]
|
||||
}
|
||||
"packageManager": "pnpm@11.1.1"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.93.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"exports": {
|
||||
".": "./src/comfyRegistryTypes.ts"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
|
||||
@@ -18,6 +18,5 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ describe('formatUtil', () => {
|
||||
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -591,7 +591,7 @@ const IMAGE_EXTENSIONS = [
|
||||
] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
|
||||
1145
pnpm-lock.yaml
generated
@@ -2,6 +2,11 @@ packages:
|
||||
- apps/**
|
||||
- packages/**
|
||||
|
||||
ignoreWorkspaceRootCheck: true
|
||||
catalogMode: prefer
|
||||
publicHoistPattern:
|
||||
- '@parcel/watcher'
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@astrojs/check': ^0.9.8
|
||||
@@ -31,7 +36,7 @@ catalog:
|
||||
'@primevue/themes': ^4.2.5
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@sparkjsdev/spark': ^2.1.0
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
@@ -54,7 +59,7 @@ catalog:
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.170.0
|
||||
'@types/three': ^0.184.1
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
@@ -113,7 +118,7 @@ catalog:
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
three: ^0.170.0
|
||||
three: ^0.184.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
@@ -144,22 +149,20 @@ catalog:
|
||||
|
||||
cleanupUnusedCatalogs: true
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- '@firebase/util'
|
||||
- protobufjs
|
||||
- unrs-resolver
|
||||
- vue-demi
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@playwright/browser-chromium'
|
||||
- '@playwright/browser-firefox'
|
||||
- '@playwright/browser-webkit'
|
||||
- '@sentry/cli'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- nx
|
||||
- oxc-resolver
|
||||
allowBuilds:
|
||||
'@firebase/util': false
|
||||
'@sentry/cli': true
|
||||
'@tailwindcss/oxide': true
|
||||
core-js: false
|
||||
esbuild: true
|
||||
nx: true
|
||||
oxc-resolver: true
|
||||
protobufjs: false
|
||||
sharp: false
|
||||
unrs-resolver: false
|
||||
vue-demi: false
|
||||
|
||||
overrides:
|
||||
vite: 'catalog:'
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 274 B |
|
Before Width: | Height: | Size: 674 B After Width: | Height: | Size: 277 B |
|
Before Width: | Height: | Size: 674 B After Width: | Height: | Size: 269 B |
|
Before Width: | Height: | Size: 674 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 698 B After Width: | Height: | Size: 281 B |
|
Before Width: | Height: | Size: 700 B After Width: | Height: | Size: 277 B |
|
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 280 B |
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 302 B |
|
Before Width: | Height: | Size: 708 B After Width: | Height: | Size: 285 B |
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 296 B |
@@ -744,10 +744,6 @@ const sortOptions = computed(() => [
|
||||
value: 'popular'
|
||||
},
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
value: 'vram-low-to-high'
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
'templateWorkflows.sort.modelSizeLowToHigh',
|
||||
|
||||
@@ -541,32 +541,26 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
|
||||
// Load color palette
|
||||
colorPaletteStore.customPalettes = settingStore.get(
|
||||
'Comfy.CustomColorPalettes'
|
||||
)
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
}
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => canvasStore.updateSelectedItems()
|
||||
)
|
||||
|
||||
// Load color palette
|
||||
colorPaletteStore.customPalettes = settingStore.get(
|
||||
'Comfy.CustomColorPalettes'
|
||||
)
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
const sharedWorkflowLoadStatus =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
// Load template from URL if present
|
||||
if (sharedWorkflowLoadStatus === 'not-present') {
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
}
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<a
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2 px-3 py-1.5"
|
||||
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
|
||||
@click="onItemClick($event, item)"
|
||||
>
|
||||
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
@@ -21,20 +21,27 @@
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
<i
|
||||
v-if="hasSubmenu || item.isColorSubmenu"
|
||||
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
|
||||
class="icon-[lucide--chevron-right] size-4 opacity-60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<!-- Color picker menu (custom with color circles) -->
|
||||
<ColorPickerMenu
|
||||
<SubmenuPopover
|
||||
v-if="colorOption"
|
||||
ref="colorPickerMenu"
|
||||
key="color-picker-menu"
|
||||
ref="colorSubmenu"
|
||||
key="color-submenu"
|
||||
:option="colorOption"
|
||||
@submenu-click="handleColorSelect"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
/>
|
||||
|
||||
<SubmenuPopover
|
||||
v-if="shapeOption"
|
||||
ref="shapeSubmenu"
|
||||
key="shape-submenu"
|
||||
:option="shapeOption"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -54,16 +61,18 @@ import type {
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
|
||||
|
||||
interface ExtendedMenuItem extends MenuItem {
|
||||
isColorSubmenu?: boolean
|
||||
isShapeSubmenu?: boolean
|
||||
shortcut?: string
|
||||
originalOption?: MenuOption
|
||||
}
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
|
||||
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
@@ -150,21 +159,20 @@ useEventListener(
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
// Find color picker option
|
||||
const colorOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isColorPicker)
|
||||
)
|
||||
|
||||
// Check if option is the color picker
|
||||
function isColorOption(option: MenuOption): boolean {
|
||||
return Boolean(option.isColorPicker)
|
||||
}
|
||||
const shapeOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isShapePicker)
|
||||
)
|
||||
|
||||
// Convert MenuOption to PrimeVue MenuItem
|
||||
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
if (option.type === 'divider') return { separator: true }
|
||||
|
||||
const isColor = isColorOption(option)
|
||||
const isColor = Boolean(option.isColorPicker)
|
||||
const isShape = Boolean(option.isShapePicker)
|
||||
const usesPopover = isColor || isShape
|
||||
|
||||
const item: ExtendedMenuItem = {
|
||||
label: option.label,
|
||||
@@ -172,11 +180,14 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
disabled: option.disabled,
|
||||
shortcut: option.shortcut,
|
||||
isColorSubmenu: isColor,
|
||||
isShapeSubmenu: isShape,
|
||||
originalOption: option
|
||||
}
|
||||
|
||||
// Native submenus for non-color options
|
||||
if (option.hasSubmenu && option.submenu && !isColor) {
|
||||
// Submenus opened via popover (color, shape) deliberately omit `items` so
|
||||
// PrimeVue does not render a nested <ul> inside the scrollable root list,
|
||||
// which would be clipped when the menu overflows the viewport (FE-570).
|
||||
if (option.hasSubmenu && option.submenu && !usesPopover) {
|
||||
item.items = option.submenu.map((sub) => ({
|
||||
label: sub.label,
|
||||
icon: sub.icon,
|
||||
@@ -188,7 +199,6 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
}))
|
||||
}
|
||||
|
||||
// Regular action items
|
||||
if (!option.hasSubmenu && option.action) {
|
||||
item.command = () => {
|
||||
option.action?.()
|
||||
@@ -245,17 +255,30 @@ function toggle(event: Event) {
|
||||
|
||||
defineExpose({ toggle, hide, isOpen, show })
|
||||
|
||||
function showColorPopover(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
const target = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
colorPickerMenu.value?.toggle(event, target)
|
||||
function onItemClick(event: MouseEvent, item: ExtendedMenuItem) {
|
||||
if (item.isColorSubmenu) {
|
||||
openSubmenuPopover(event, colorSubmenu.value, shapeSubmenu.value)
|
||||
} else if (item.isShapeSubmenu) {
|
||||
openSubmenuPopover(event, shapeSubmenu.value, colorSubmenu.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle color selection
|
||||
function handleColorSelect(subOption: SubMenuOption) {
|
||||
function openSubmenuPopover(
|
||||
event: MouseEvent,
|
||||
target: InstanceType<typeof SubmenuPopover> | undefined,
|
||||
other: InstanceType<typeof SubmenuPopover> | undefined
|
||||
) {
|
||||
if (!target) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
other?.hide()
|
||||
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
target.toggle(event, anchor)
|
||||
}
|
||||
|
||||
function handleSubmenuSelect(subOption: SubMenuOption) {
|
||||
subOption.action()
|
||||
hide()
|
||||
}
|
||||
@@ -270,11 +293,17 @@ function constrainMenuHeight() {
|
||||
if (!rootList) return
|
||||
|
||||
const rect = rootList.getBoundingClientRect()
|
||||
const maxHeight = window.innerHeight - rect.top - 8
|
||||
if (maxHeight > 0) {
|
||||
rootList.style.maxHeight = `${maxHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
const availableHeight = window.innerHeight - rect.top - 8
|
||||
if (availableHeight <= 0) return
|
||||
|
||||
// Setting overflow-y to auto/scroll on the root <ul> coerces overflow-x
|
||||
// to a non-visible value too (CSS spec), which clips horizontally-opening
|
||||
// submenus like Shape. Only apply the constraint when content truly
|
||||
// overflows so the common case keeps overflow visible.
|
||||
if (rootList.scrollHeight <= availableHeight) return
|
||||
|
||||
rootList.style.maxHeight = `${availableHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
|
||||
function onMenuShow() {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-60'
|
||||
class: 'p-popover absolute z-60'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
@@ -90,8 +90,12 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const toggle = (event: Event, target?: HTMLElement) => {
|
||||
popoverRef.value?.toggle(event, target)
|
||||
}
|
||||
const hide = () => {
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
defineExpose({
|
||||
toggle
|
||||
toggle,
|
||||
hide
|
||||
})
|
||||
|
||||
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||
@@ -4,8 +4,6 @@
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<Load3DScene
|
||||
v-if="node"
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
data-capture-wheel="true"
|
||||
tabindex="-1"
|
||||
@pointerdown.stop="focusContainer"
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
@mousedown.stop
|
||||
@mousemove.stop
|
||||
@mouseup.stop
|
||||
@contextmenu.stop.prevent
|
||||
@dragover.prevent.stop="handleDragOver"
|
||||
@dragleave.stop="handleDragLeave"
|
||||
|
||||
@@ -48,7 +48,8 @@ const showExportFormats = ref(false)
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' }
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
function toggleExportFormats() {
|
||||
|
||||
@@ -81,12 +81,12 @@ function renderComponent(onExportModel?: (format: string) => void) {
|
||||
}
|
||||
|
||||
describe('ViewerExportControls', () => {
|
||||
it('renders all three export format options', () => {
|
||||
it('renders all four export format options', () => {
|
||||
renderComponent()
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
const optionValues = Array.from(select.options).map((o) => o.value)
|
||||
|
||||
expect(optionValues).toEqual(['glb', 'obj', 'stl'])
|
||||
expect(optionValues).toEqual(['glb', 'obj', 'stl', 'fbx'])
|
||||
})
|
||||
|
||||
it('defaults the export format to obj', () => {
|
||||
|
||||
@@ -42,7 +42,8 @@ const emit = defineEmits<{
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' }
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
const exportFormat = ref('obj')
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-if="rest"
|
||||
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
|
||||
class="-ml-2.5 flex h-5 max-w-max min-w-0 grow basis-0 items-center truncate rounded-r-full bg-component-node-widget-background text-xs"
|
||||
>
|
||||
<span class="pr-2" v-text="rest" />
|
||||
</span>
|
||||
|
||||
@@ -246,6 +246,7 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -582,7 +583,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
title: getAssetDisplayName(asset),
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: asset.preview_url || ''
|
||||
modelUrl: asset.preview_url || getAssetUrl(asset)
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
|
||||
@@ -25,17 +25,19 @@ type Searcher = NonNullable<ComponentProps<typeof AsyncSearchInput>['searcher']>
|
||||
function renderSearch(
|
||||
initialQuery: string = '',
|
||||
searcher?: Searcher,
|
||||
updateKey?: { value: unknown }
|
||||
updateKey?: { value: unknown },
|
||||
onEnter?: (event: KeyboardEvent) => void
|
||||
) {
|
||||
const query = ref(initialQuery)
|
||||
const key = updateKey
|
||||
const Harness = defineComponent({
|
||||
components: { AsyncSearchInput },
|
||||
setup: () => ({ query, searcher, key }),
|
||||
setup: () => ({ query, searcher, key, onEnter }),
|
||||
template: `<AsyncSearchInput
|
||||
v-model="query"
|
||||
:searcher="searcher"
|
||||
:update-key="key"
|
||||
@enter="onEnter"
|
||||
/>`
|
||||
})
|
||||
const utils = render(Harness, { global: { plugins: [i18n] } })
|
||||
@@ -63,6 +65,14 @@ describe('AsyncSearchInput', () => {
|
||||
await user.type(screen.getByRole('textbox'), 'abc')
|
||||
expect(query.value).toBe('abc')
|
||||
})
|
||||
|
||||
it('emits enter when the user presses Enter in the textbox', async () => {
|
||||
const onEnter = vi.fn()
|
||||
renderSearch('', undefined, undefined, onEnter)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await user.type(screen.getByRole('textbox'), '{Enter}')
|
||||
expect(onEnter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clear button', () => {
|
||||
|
||||
@@ -23,6 +23,9 @@ const {
|
||||
debounceMaxWaitMs?: number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
enter: [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>({ default: '' })
|
||||
|
||||
@@ -62,6 +65,11 @@ function handleFocus(event: FocusEvent) {
|
||||
target.select()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydownEnter(event: KeyboardEvent) {
|
||||
if (event.isComposing) return
|
||||
emit('enter', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,6 +105,7 @@ function handleFocus(event: FocusEvent) {
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
|
||||
:autofocus
|
||||
@focus="handleFocus"
|
||||
@keydown.enter="handleKeydownEnter"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery.trim().length > 0"
|
||||
|
||||
@@ -53,7 +53,6 @@ const sortOptions: SelectOption[] = [
|
||||
{ name: 'Recommended', value: 'recommended' },
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
|
||||
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
|
||||
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
|
||||
]
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface WidgetSlotMetadata {
|
||||
type: string
|
||||
}
|
||||
|
||||
type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
|
||||
/**
|
||||
* Minimal render-specific widget data extracted from LiteGraph widgets.
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
@@ -107,7 +109,7 @@ export interface VueNodeData {
|
||||
title: string
|
||||
type: string
|
||||
apiNode?: boolean
|
||||
badges?: (LGraphBadge | (() => LGraphBadge))[]
|
||||
badges?: Badges
|
||||
bgcolor?: string
|
||||
color?: string
|
||||
flags?: {
|
||||
@@ -786,6 +788,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
showAdvanced: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'badges':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
badges: propertyEvent.newValue as Badges
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MenuOption {
|
||||
disabled?: boolean
|
||||
source?: 'litegraph' | 'vue'
|
||||
isColorPicker?: boolean
|
||||
isShapePicker?: boolean
|
||||
}
|
||||
|
||||
export interface SubMenuOption {
|
||||
|
||||
@@ -66,6 +66,7 @@ export function useNodeMenuOptions() {
|
||||
icon: 'icon-[lucide--box]',
|
||||
hasSubmenu: true,
|
||||
submenu: shapeSubmenu.value,
|
||||
isShapePicker: true,
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,20 +3,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
|
||||
|
||||
const { mockAddNodeOnGraph, mockConvertEventToCanvasOffset, mockCanvas } =
|
||||
vi.hoisted(() => {
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
return {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
},
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset
|
||||
}
|
||||
const {
|
||||
mockAddNodeOnGraph,
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas
|
||||
} = vi.hoisted(() => {
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
const mockSelectItems = vi.fn()
|
||||
return {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
},
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
|
||||
selectItems: mockSelectItems
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
@@ -119,6 +126,11 @@ describe('useNodeDragToCanvas', () => {
|
||||
'pointermove',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
@@ -239,6 +251,57 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should select the placed node when one is returned from the graph', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1 }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
})
|
||||
|
||||
it('should not call selectItems when graph returns no node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockSelectItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not add node on pointerup when in native drag mode', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
@@ -339,4 +402,58 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
describe('blockCommitPointerDown', () => {
|
||||
function dispatchPointerDown(x: number, y: number) {
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
const stopSpy = vi.spyOn(event, 'stopImmediatePropagation')
|
||||
document.dispatchEvent(event)
|
||||
return stopSpy
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop propagation when in click-drag mode over canvas', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when not dragging', () => {
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation in native drag mode', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when pointer is outside canvas', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,31 +22,33 @@ function cancelDrag() {
|
||||
dragMode.value = 'click'
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
if (!draggedNode.value) return false
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) return false
|
||||
|
||||
const canvasElement = canvas.canvas as HTMLCanvasElement
|
||||
function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
const canvasElement = useCanvasStore().canvas?.canvas as
|
||||
| HTMLCanvasElement
|
||||
| undefined
|
||||
if (!canvasElement) return false
|
||||
const rect = canvasElement.getBoundingClientRect()
|
||||
const isOverCanvas =
|
||||
return (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
if (isOverCanvas) {
|
||||
const pos = canvas.convertEventToCanvasOffset({
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const litegraphService = useLitegraphService()
|
||||
litegraphService.addNodeOnGraph(draggedNode.value, { pos })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
if (!draggedNode.value) return false
|
||||
const canvas = useCanvasStore().canvas
|
||||
if (!canvas) return false
|
||||
if (!isOverCanvas(clientX, clientY)) return false
|
||||
|
||||
const pos = canvas.convertEventToCanvasOffset({
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
|
||||
if (node) canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
function endDrag(e: PointerEvent) {
|
||||
@@ -64,11 +66,19 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancelDrag()
|
||||
}
|
||||
|
||||
// Prevent LiteGraph's empty-canvas hit-test from deselecting the placed node on pointerup.
|
||||
function blockCommitPointerDown(e: PointerEvent) {
|
||||
if (!isDragging.value || dragMode.value !== 'click') return
|
||||
if (!isOverCanvas(e.clientX, e.clientY)) return
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
function setupGlobalListeners() {
|
||||
if (listenersSetup) return
|
||||
listenersSetup = true
|
||||
|
||||
document.addEventListener('pointermove', updatePosition)
|
||||
document.addEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.addEventListener('pointerup', endDrag, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
@@ -78,6 +88,7 @@ function cleanupGlobalListeners() {
|
||||
listenersSetup = false
|
||||
|
||||
document.removeEventListener('pointermove', updatePosition)
|
||||
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.removeEventListener('pointerup', endDrag, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
|
||||
|
||||
@@ -625,9 +625,9 @@ describe('useNodePricing', () => {
|
||||
getNodeDisplayPrice(node)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
// VueNodes path bumps per-node ref instead of the global tick.
|
||||
// VueNodes path bumps per-node ref and the global tick.
|
||||
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
|
||||
expect(pricingRevision.value).toBe(tickBefore)
|
||||
expect(pricingRevision.value).toBeGreaterThan(tickBefore)
|
||||
} finally {
|
||||
LiteGraph.vueNodesMode = false
|
||||
}
|
||||
|
||||
@@ -509,10 +509,8 @@ const scheduleEvaluation = (
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
// VueNodes mode: bump per-node revision (only this node re-renders)
|
||||
getNodeRevisionRef(node.id).value++
|
||||
} else {
|
||||
// Nodes 1.0 mode: bump global tick to trigger setDirtyCanvas
|
||||
pricingTick.value++
|
||||
}
|
||||
pricingTick.value++
|
||||
})
|
||||
|
||||
inflight.set(node, { sig, promise })
|
||||
|
||||
@@ -18,6 +18,15 @@ export const usePriceBadge = () => {
|
||||
} else {
|
||||
node.badges.push(...newBadges)
|
||||
}
|
||||
const graph = node.graph
|
||||
if (!graph) return
|
||||
graph.trigger('node:property:changed', {
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'badges',
|
||||
oldValue: node.badges,
|
||||
newValue: node.badges
|
||||
})
|
||||
}
|
||||
function collectCreditsBadges(
|
||||
graph: LGraph,
|
||||
|
||||
@@ -78,59 +78,6 @@ describe('useTemplateFiltering', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('sorts templates by VRAM from low to high and pushes missing values last', () => {
|
||||
const gb = (value: number) => value * 1024 ** 3
|
||||
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'missing-vram',
|
||||
description: 'no vram value',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'highest-vram',
|
||||
description: 'high usage',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
vram: gb(12)
|
||||
},
|
||||
{
|
||||
name: 'mid-vram',
|
||||
description: 'medium usage',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
vram: gb(7.5)
|
||||
},
|
||||
{
|
||||
name: 'low-vram',
|
||||
description: 'low usage',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
vram: gb(5)
|
||||
},
|
||||
{
|
||||
name: 'zero-vram',
|
||||
description: 'unknown usage',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
vram: 0
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
|
||||
sortBy.value = 'vram-low-to-high'
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'low-vram',
|
||||
'mid-vram',
|
||||
'highest-vram',
|
||||
'missing-vram',
|
||||
'zero-vram'
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by search text, models, tags, and license with debounce handling', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@@ -220,17 +220,6 @@ export function useTemplateFiltering(
|
||||
})
|
||||
})
|
||||
|
||||
const getVramMetric = (template: TemplateInfo) => {
|
||||
if (
|
||||
typeof template.vram === 'number' &&
|
||||
Number.isFinite(template.vram) &&
|
||||
template.vram > 0
|
||||
) {
|
||||
return template.vram
|
||||
}
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
watch(
|
||||
filteredByRunsOn,
|
||||
(templates) => {
|
||||
@@ -279,22 +268,6 @@ export function useTemplateFiltering(
|
||||
const dateB = new Date(b.date || '1970-01-01')
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
case 'vram-low-to-high':
|
||||
return templates.sort((a, b) => {
|
||||
const vramA = getVramMetric(a)
|
||||
const vramB = getVramMetric(b)
|
||||
|
||||
if (vramA === vramB) {
|
||||
const nameA = a.title || a.name || ''
|
||||
const nameB = b.title || b.name || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
}
|
||||
|
||||
if (vramA === Number.POSITIVE_INFINITY) return 1
|
||||
if (vramB === Number.POSITIVE_INFINITY) return -1
|
||||
|
||||
return vramA - vramB
|
||||
})
|
||||
case 'model-size-low-to-high':
|
||||
return templates.sort((a, b) => {
|
||||
const sizeA =
|
||||
|
||||
@@ -12,11 +12,12 @@ const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org'
|
||||
|
||||
const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__
|
||||
? PROD_API_BASE_URL
|
||||
: STAGING_API_BASE_URL
|
||||
: (import.meta.env.VITE_STAGING_API_BASE_URL ?? STAGING_API_BASE_URL)
|
||||
|
||||
const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
? PROD_PLATFORM_BASE_URL
|
||||
: STAGING_PLATFORM_BASE_URL
|
||||
: (import.meta.env.VITE_STAGING_PLATFORM_BASE_URL ??
|
||||
STAGING_PLATFORM_BASE_URL)
|
||||
|
||||
export function getComfyApiBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
|
||||
@@ -321,12 +321,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
if (!outputType) throw new Error('invalid connection')
|
||||
this.outputs.forEach((output, idx) => {
|
||||
if (!(outputGroups?.[idx] == matchKey)) return
|
||||
this.outputs[idx] = shallowReactive(this.outputs[idx])
|
||||
changeOutputType(this, output, outputType)
|
||||
})
|
||||
// Force Vue reactivity update for output slot types.
|
||||
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
|
||||
// so mutating output.type alone doesn't trigger re-render.
|
||||
this.outputs = [...this.outputs]
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const {
|
||||
cloneSkinnedMock,
|
||||
exportGLBMock,
|
||||
exportOBJMock,
|
||||
exportSTLMock,
|
||||
exportFBXMock
|
||||
} = vi.hoisted(() => ({
|
||||
cloneSkinnedMock: vi.fn(),
|
||||
exportGLBMock: vi.fn(),
|
||||
exportOBJMock: vi.fn(),
|
||||
exportSTLMock: vi.fn(),
|
||||
exportFBXMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({
|
||||
clone: cloneSkinnedMock
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/ModelExporter', () => ({
|
||||
ModelExporter: {
|
||||
exportGLB: exportGLBMock,
|
||||
exportOBJ: exportOBJMock,
|
||||
exportSTL: exportSTLMock,
|
||||
exportFBX: exportFBXMock
|
||||
}
|
||||
}))
|
||||
|
||||
type GizmoStub = {
|
||||
setEnabled: ReturnType<typeof vi.fn>
|
||||
setMode: ReturnType<typeof vi.fn>
|
||||
@@ -849,4 +876,189 @@ describe('Load3d', () => {
|
||||
expect(ctx.forceRender).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportModel', () => {
|
||||
beforeEach(() => {
|
||||
cloneSkinnedMock.mockReset()
|
||||
exportGLBMock.mockReset()
|
||||
exportOBJMock.mockReset()
|
||||
exportSTLMock.mockReset()
|
||||
exportFBXMock.mockReset()
|
||||
})
|
||||
|
||||
function setupForExport(overrides: {
|
||||
currentModel: THREE.Object3D | null
|
||||
originalModel?: THREE.Object3D | null
|
||||
originalFileName?: string | null
|
||||
originalURL?: string | null
|
||||
}) {
|
||||
Object.assign(ctx.load3d, {
|
||||
modelManager: {
|
||||
...ctx.modelManager,
|
||||
currentModel: overrides.currentModel,
|
||||
originalModel: overrides.originalModel ?? null,
|
||||
originalFileName: overrides.originalFileName ?? 'cube',
|
||||
originalURL: overrides.originalURL ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('throws when no model is loaded', async () => {
|
||||
setupForExport({ currentModel: null })
|
||||
|
||||
await expect(ctx.load3d.exportModel('fbx')).rejects.toThrow(
|
||||
'No model to export'
|
||||
)
|
||||
})
|
||||
|
||||
it('zeroes the source transform during export, then restores it', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(5, 6, 7)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
model.scale.set(2, 3, 4)
|
||||
|
||||
let transformDuringExport: {
|
||||
position: THREE.Vector3
|
||||
rotation: THREE.Euler
|
||||
scale: THREE.Vector3
|
||||
} | null = null
|
||||
exportGLBMock.mockImplementation(async () => {
|
||||
transformDuringExport = {
|
||||
position: model.position.clone(),
|
||||
rotation: model.rotation.clone(),
|
||||
scale: model.scale.clone()
|
||||
}
|
||||
})
|
||||
|
||||
setupForExport({ currentModel: model })
|
||||
|
||||
await ctx.load3d.exportModel('glb')
|
||||
|
||||
expect(transformDuringExport!.position.x).toBe(0)
|
||||
expect(transformDuringExport!.position.y).toBe(0)
|
||||
expect(transformDuringExport!.position.z).toBe(0)
|
||||
expect(transformDuringExport!.rotation.x).toBe(0)
|
||||
expect(transformDuringExport!.scale.x).toBe(1)
|
||||
expect(transformDuringExport!.scale.y).toBe(1)
|
||||
expect(transformDuringExport!.scale.z).toBe(1)
|
||||
|
||||
expect(model.position.x).toBe(5)
|
||||
expect(model.position.y).toBe(6)
|
||||
expect(model.position.z).toBe(7)
|
||||
expect(model.rotation.x).toBeCloseTo(0.1)
|
||||
expect(model.scale.x).toBe(2)
|
||||
expect(model.scale.z).toBe(4)
|
||||
})
|
||||
|
||||
it('restores the source transform even when the exporter throws', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(3, 4, 5)
|
||||
model.scale.set(7, 7, 7)
|
||||
exportGLBMock.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
setupForExport({ currentModel: model })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await expect(ctx.load3d.exportModel('glb')).rejects.toThrow('boom')
|
||||
|
||||
expect(model.position.x).toBe(3)
|
||||
expect(model.scale.x).toBe(7)
|
||||
})
|
||||
|
||||
it('routes fbx through SkeletonUtils.clone and attaches the source animations', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
const clip = { name: 'walk' } as unknown as THREE.AnimationClip
|
||||
model.animations = [clip]
|
||||
const cloned = new THREE.Object3D()
|
||||
cloneSkinnedMock.mockReturnValueOnce(cloned)
|
||||
|
||||
setupForExport({
|
||||
currentModel: model,
|
||||
originalFileName: 'rig',
|
||||
originalURL: 'http://example.com/api/view?filename=rig.fbx'
|
||||
})
|
||||
|
||||
await ctx.load3d.exportModel('fbx')
|
||||
|
||||
expect(cloneSkinnedMock).toHaveBeenCalledWith(model)
|
||||
expect(exportFBXMock).toHaveBeenCalledOnce()
|
||||
const [exportedModel, filename, originalURL] = exportFBXMock.mock
|
||||
.calls[0] as [
|
||||
THREE.Object3D & { animations: THREE.AnimationClip[] },
|
||||
string,
|
||||
string | null
|
||||
]
|
||||
expect(exportedModel).toBe(cloned)
|
||||
expect(exportedModel.animations).toEqual([clip])
|
||||
expect(filename).toBe('rig.fbx')
|
||||
expect(originalURL).toBe('http://example.com/api/view?filename=rig.fbx')
|
||||
})
|
||||
|
||||
it('falls back to originalModel.animations when the working model has none (fbx)', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
const original = new THREE.Object3D()
|
||||
const clip = { name: 'idle' } as unknown as THREE.AnimationClip
|
||||
original.animations = [clip]
|
||||
const cloned = new THREE.Object3D()
|
||||
cloneSkinnedMock.mockReturnValueOnce(cloned)
|
||||
|
||||
setupForExport({ currentModel: model, originalModel: original })
|
||||
|
||||
await ctx.load3d.exportModel('fbx')
|
||||
|
||||
const [exportedModel] = exportFBXMock.mock.calls[0] as [
|
||||
THREE.Object3D & { animations: THREE.AnimationClip[] }
|
||||
]
|
||||
expect(exportedModel.animations).toEqual([clip])
|
||||
})
|
||||
|
||||
it('uses Object3D.clone (not SkeletonUtils) for non-fbx formats', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
const cloneSpy = vi.spyOn(model, 'clone')
|
||||
|
||||
setupForExport({
|
||||
currentModel: model,
|
||||
originalFileName: 'cube',
|
||||
originalURL: null
|
||||
})
|
||||
|
||||
await ctx.load3d.exportModel('glb')
|
||||
|
||||
expect(cloneSpy).toHaveBeenCalled()
|
||||
expect(cloneSkinnedMock).not.toHaveBeenCalled()
|
||||
expect(exportGLBMock).toHaveBeenCalledOnce()
|
||||
const [, filename] = exportGLBMock.mock.calls[0] as [
|
||||
unknown,
|
||||
string,
|
||||
unknown
|
||||
]
|
||||
expect(filename).toBe('cube.glb')
|
||||
})
|
||||
|
||||
it('emits exportLoadingStart and exportLoadingEnd around the export', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
setupForExport({ currentModel: model })
|
||||
|
||||
await ctx.load3d.exportModel('glb')
|
||||
|
||||
expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'exportLoadingStart',
|
||||
'Exporting as GLB...'
|
||||
)
|
||||
expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'exportLoadingEnd',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('throws on unsupported format', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
setupForExport({ currentModel: model })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await expect(ctx.load3d.exportModel('xyz')).rejects.toThrow(
|
||||
'Unsupported export format: xyz'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from 'three'
|
||||
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'
|
||||
|
||||
import type { AnimationManager } from './AnimationManager'
|
||||
import type { CameraManager } from './CameraManager'
|
||||
@@ -344,8 +345,30 @@ class Load3d {
|
||||
const exportMessage = `Exporting as ${format.toUpperCase()}...`
|
||||
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
|
||||
|
||||
const source = this.modelManager.currentModel
|
||||
const savedPos = source.position.clone()
|
||||
const savedRot = source.rotation.clone()
|
||||
const savedScale = source.scale.clone()
|
||||
source.position.set(0, 0, 0)
|
||||
source.rotation.set(0, 0, 0)
|
||||
source.scale.set(1, 1, 1)
|
||||
source.updateMatrixWorld(true)
|
||||
|
||||
try {
|
||||
const model = this.modelManager.currentModel.clone()
|
||||
const original = this.modelManager.originalModel
|
||||
const clipsFromOriginal =
|
||||
original &&
|
||||
'animations' in original &&
|
||||
Array.isArray(original.animations)
|
||||
? original.animations
|
||||
: []
|
||||
const clips = source.animations?.length
|
||||
? source.animations
|
||||
: clipsFromOriginal
|
||||
const model =
|
||||
format === 'fbx'
|
||||
? Object.assign(cloneSkinned(source), { animations: clips })
|
||||
: source.clone()
|
||||
|
||||
const originalFileName = this.modelManager.originalFileName || 'model'
|
||||
const filename = `${originalFileName}.${format}`
|
||||
@@ -364,6 +387,9 @@ class Load3d {
|
||||
case 'stl':
|
||||
;(await ModelExporter.exportSTL(model, filename), originalURL)
|
||||
break
|
||||
case 'fbx':
|
||||
await ModelExporter.exportFBX(model, filename, originalURL)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported export format: ${format}`)
|
||||
}
|
||||
@@ -373,6 +399,10 @@ class Load3d {
|
||||
console.error(`Error exporting model as ${format}:`, error)
|
||||
throw error
|
||||
} finally {
|
||||
source.position.copy(savedPos)
|
||||
source.rotation.copy(savedRot)
|
||||
source.scale.copy(savedScale)
|
||||
source.updateMatrixWorld(true)
|
||||
this.eventManager.emitEvent('exportLoadingEnd', null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ const {
|
||||
addAlertMock,
|
||||
gltfParseMock,
|
||||
objParseMock,
|
||||
stlParseMock
|
||||
stlParseMock,
|
||||
fbxParseAsyncMock
|
||||
} = vi.hoisted(() => ({
|
||||
downloadBlobMock: vi.fn(),
|
||||
addAlertMock: vi.fn(),
|
||||
gltfParseMock: vi.fn(),
|
||||
objParseMock: vi.fn(),
|
||||
stlParseMock: vi.fn()
|
||||
stlParseMock: vi.fn(),
|
||||
fbxParseAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
@@ -48,6 +50,12 @@ vi.mock('three/examples/jsm/exporters/STLExporter', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@comfyorg/fbx-exporter-three', () => ({
|
||||
FBXExporter: class {
|
||||
parseAsync = fbxParseAsyncMock
|
||||
}
|
||||
}))
|
||||
|
||||
describe('ModelExporter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -125,7 +133,9 @@ describe('ModelExporter', () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.downloadFromURL(
|
||||
@@ -149,6 +159,27 @@ describe('ModelExporter', () => {
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('rethrows and shows a toast alert when the response status is not ok', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: () => Promise.resolve(new Blob(['x']))
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
ModelExporter.downloadFromURL('http://example.com/cube.glb', 'cube.glb')
|
||||
).rejects.toThrow('HTTP 404')
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToDownloadFile'
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportGLB', () => {
|
||||
@@ -156,7 +187,9 @@ describe('ModelExporter', () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
const model = new THREE.Object3D()
|
||||
|
||||
@@ -214,7 +247,9 @@ describe('ModelExporter', () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportOBJ(
|
||||
@@ -260,7 +295,9 @@ describe('ModelExporter', () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportSTL(
|
||||
@@ -300,4 +337,51 @@ describe('ModelExporter', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportFBX', () => {
|
||||
it('uses the direct-URL fast path for matching .fbx URLs', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportFBX(
|
||||
new THREE.Object3D(),
|
||||
'out.fbx',
|
||||
'http://example.com/api/view?filename=src.fbx'
|
||||
)
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.fbx', blob)
|
||||
expect(fbxParseAsyncMock).not.toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('serializes via FBXExporter and downloads as binary when there is no direct URL', async () => {
|
||||
const bytes = new Uint8Array([0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61])
|
||||
fbxParseAsyncMock.mockResolvedValue(bytes)
|
||||
|
||||
const promise = ModelExporter.exportFBX(new THREE.Object3D(), 'out.fbx')
|
||||
await vi.runAllTimersAsync()
|
||||
await promise
|
||||
|
||||
expect(fbxParseAsyncMock).toHaveBeenCalled()
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.fbx', expect.any(Blob))
|
||||
})
|
||||
|
||||
it('alerts and rethrows when FBXExporter throws', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fbxParseAsyncMock.mockRejectedValue(new Error('fbx fail'))
|
||||
|
||||
const promise = ModelExporter.exportFBX(new THREE.Object3D(), 'out.fbx')
|
||||
const assertion = expect(promise).rejects.toThrow('fbx fail')
|
||||
await vi.runAllTimersAsync()
|
||||
await assertion
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToExportModel:{"format":"FBX"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FBXExporter } from '@comfyorg/fbx-exporter-three'
|
||||
import * as THREE from 'three'
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter'
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter'
|
||||
@@ -38,6 +39,9 @@ export class ModelExporter {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download file (HTTP ${response.status})`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
downloadBlob(desiredFilename, blob)
|
||||
} catch (error) {
|
||||
@@ -116,6 +120,41 @@ export class ModelExporter {
|
||||
}
|
||||
}
|
||||
|
||||
static async exportFBX(
|
||||
model: THREE.Object3D,
|
||||
filename: string = 'model.fbx',
|
||||
originalURL?: string | null
|
||||
): Promise<void> {
|
||||
if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'fbx')) {
|
||||
return ModelExporter.downloadFromURL(originalURL, filename)
|
||||
}
|
||||
|
||||
const exporter = new FBXExporter()
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const bytes = await exporter.parseAsync(model)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// FBXExporter returns Uint8Array — wrap into ArrayBuffer for download.
|
||||
ModelExporter.saveArrayBuffer(
|
||||
bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer,
|
||||
filename
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error exporting FBX:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.failedToExportModel', { format: 'FBX' })
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static async exportSTL(
|
||||
model: THREE.Object3D,
|
||||
filename: string = 'model.stl',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SparkRenderer } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -137,6 +138,13 @@ describe('SceneManager', () => {
|
||||
expect(manager.scene.children).toContain(manager.gridHelper)
|
||||
})
|
||||
|
||||
it('adds a SparkRenderer to the scene so SplatMesh instances render', () => {
|
||||
const sparkRenderers = manager.scene.children.filter(
|
||||
(child) => child instanceof SparkRenderer
|
||||
)
|
||||
expect(sparkRenderers).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('builds a separate background scene with a tiled mesh', () => {
|
||||
expect(manager.backgroundScene).toBeInstanceOf(THREE.Scene)
|
||||
expect(manager.backgroundMesh).toBeInstanceOf(THREE.Mesh)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SparkRenderer } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
export class SceneManager implements SceneManagerInterface {
|
||||
scene!: THREE.Scene
|
||||
gridHelper: THREE.GridHelper
|
||||
private sparkRenderer: SparkRenderer
|
||||
|
||||
backgroundScene!: THREE.Scene
|
||||
backgroundCamera: THREE.OrthographicCamera
|
||||
@@ -42,6 +44,12 @@ export class SceneManager implements SceneManagerInterface {
|
||||
|
||||
this.getActiveCamera = getActiveCamera
|
||||
|
||||
// Spark 2.x requires a SparkRenderer in the scene tree to render SplatMesh
|
||||
// (Gaussian splat) instances; without it splats are silent no-ops. Kept
|
||||
// alive across model reloads by SceneModelManager.clearModel.
|
||||
this.sparkRenderer = new SparkRenderer({ renderer })
|
||||
this.scene.add(this.sparkRenderer)
|
||||
|
||||
this.gridHelper = new THREE.GridHelper(20, 20)
|
||||
this.gridHelper.position.set(0, 0, 0)
|
||||
this.scene.add(this.gridHelper)
|
||||
@@ -277,8 +285,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
|
||||
if (!material.map) return
|
||||
|
||||
const imageAspect =
|
||||
backgroundTexture.image.width / backgroundTexture.image.height
|
||||
const image = backgroundTexture.image as { width: number; height: number }
|
||||
const imageAspect = image.width / image.height
|
||||
const targetAspect = targetWidth / targetHeight
|
||||
|
||||
if (imageAspect > targetAspect) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SparkRenderer } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -355,6 +356,20 @@ describe('SceneModelManager', () => {
|
||||
expect(geoDispose).toHaveBeenCalled()
|
||||
expect(matDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves SparkRenderer across model reloads', async () => {
|
||||
const { manager, scene } = createManager()
|
||||
const sparkRenderer = new SparkRenderer({
|
||||
renderer: {} as THREE.WebGLRenderer
|
||||
})
|
||||
scene.add(sparkRenderer)
|
||||
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
manager.clearModel()
|
||||
|
||||
expect(scene.children).toContain(sparkRenderer)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SparkRenderer } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
@@ -317,6 +318,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
object instanceof THREE.GridHelper ||
|
||||
object instanceof THREE.Light ||
|
||||
object instanceof THREE.Camera ||
|
||||
object instanceof SparkRenderer ||
|
||||
object.name === 'GizmoTransformControls'
|
||||
|
||||
if (!isEnvironmentObject) {
|
||||
|
||||