Compare commits
33 Commits
test/cov-a
...
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 | ||
|
|
4931b0c4b2 | ||
|
|
fc7e6a0935 | ||
|
|
a97f46b497 |
@@ -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
|
||||
14
apps/website/public/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class QueuePanel {
|
||||
readonly overlayToggle: Locator
|
||||
readonly overlay: Locator
|
||||
readonly moreOptionsButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
|
||||
this.moreOptionsButton = page.getByLabel(/More options/i).first()
|
||||
this.overlay = page.getByTestId(TestIds.queue.progressOverlay)
|
||||
this.moreOptionsButton = this.overlay.getByLabel(/More options/i)
|
||||
}
|
||||
|
||||
async openClearHistoryDialog() {
|
||||
|
||||
@@ -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,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,19 +1,26 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import type { Page, Route, WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { LogsRawResponse } from '@/schemas/apiSchema'
|
||||
|
||||
const RAW_LOGS_URL = '**/internal/logs/raw**'
|
||||
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
|
||||
|
||||
export class LogsTerminalHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockRawLogs(messages: string[]) {
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
route.fulfill({
|
||||
async mockRawLogs(messages: string[]): Promise<() => number> {
|
||||
let count = 0
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
|
||||
count += 1
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
|
||||
})
|
||||
)
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
|
||||
@@ -21,7 +28,8 @@ export class LogsTerminalHelper {
|
||||
const pending = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
|
||||
await pending
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -33,15 +41,39 @@ export class LogsTerminalHelper {
|
||||
}
|
||||
|
||||
async mockRawLogsError() {
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, (route: Route) =>
|
||||
route.fulfill({ status: 500, body: 'Internal Server Error' })
|
||||
)
|
||||
}
|
||||
|
||||
async mockSubscribeLogs() {
|
||||
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
|
||||
route.fulfill({ status: 200, body: '' })
|
||||
)
|
||||
async mockSubscribeLogs(): Promise<() => number> {
|
||||
let count = 0
|
||||
await this.page.unroute(SUBSCRIBE_LOGS_URL)
|
||||
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
|
||||
count += 1
|
||||
await route.fulfill({ status: 200, body: '' })
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the frontend to reconnect by closing the proxied WebSocket. The
|
||||
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
|
||||
* handler fires again, and on `open` with `isReconnect=true` it dispatches
|
||||
* `'reconnected'`, which triggers the logs-terminal resync.
|
||||
*
|
||||
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
|
||||
* the time the count goes up, the new socket is open and resync has
|
||||
* completed enough to assert against the terminal.
|
||||
*/
|
||||
async triggerReconnect(
|
||||
ws: WebSocketRoute,
|
||||
subscribeFetches: () => number
|
||||
): Promise<void> {
|
||||
const before = subscribeFetches()
|
||||
await ws.close()
|
||||
await expect.poll(subscribeFetches).toBeGreaterThan(before)
|
||||
}
|
||||
|
||||
static buildWsLogFrame(messages: string[]): string {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
import {
|
||||
zHistoryManageRequest,
|
||||
zQueueManageRequest,
|
||||
zQueueManageResponse
|
||||
} from '@comfyorg/ingest-types/zod'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
JobStatus,
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
type HistoryManageRequest = z.infer<typeof zHistoryManageRequest>
|
||||
type QueueManageRequest = z.infer<typeof zQueueManageRequest>
|
||||
|
||||
const terminalJobStatuses = [
|
||||
'completed',
|
||||
@@ -22,7 +30,8 @@ const activeJobStatuses = [
|
||||
const defaultJobsListLimit = 200
|
||||
const defaultScenarioHistoryLimit = 64
|
||||
const defaultJobsListOffset = 0
|
||||
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
export const routeMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
interface JobsListRoute {
|
||||
statuses: readonly JobStatus[]
|
||||
@@ -32,7 +41,7 @@ interface JobsListRoute {
|
||||
responseLimit?: number
|
||||
}
|
||||
|
||||
interface JobsScenario {
|
||||
export interface JobsScenario {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
}
|
||||
@@ -65,11 +74,9 @@ function hasJobsListPageParams(
|
||||
)
|
||||
}
|
||||
|
||||
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
|
||||
function matchesJobsListRoute(url: URL, route: JobsListRoute): boolean {
|
||||
return (
|
||||
url.pathname.endsWith('/api/jobs') &&
|
||||
hasExactStatuses(url, route.statuses) &&
|
||||
hasJobsListPageParams(url, route)
|
||||
hasExactStatuses(url, route.statuses) && hasJobsListPageParams(url, route)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,9 +106,9 @@ export function createRouteMockJob({
|
||||
return {
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: defaultRouteMockJobTimestamp,
|
||||
execution_start_time: defaultRouteMockJobTimestamp,
|
||||
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
|
||||
create_time: routeMockJobTimestamp,
|
||||
execution_start_time: routeMockJobTimestamp,
|
||||
execution_end_time: routeMockJobTimestamp + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${id}.png`,
|
||||
subfolder: '',
|
||||
@@ -150,7 +157,8 @@ export class JobsRouteMocker {
|
||||
const response = createJobsListResponse(route)
|
||||
|
||||
await this.page.route(
|
||||
(url) => isJobsListRequest(url, route),
|
||||
(url) =>
|
||||
url.pathname.endsWith('/api/jobs') && matchesJobsListRoute(url, route),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'GET') {
|
||||
await requestRoute.fallback()
|
||||
@@ -161,6 +169,62 @@ export class JobsRouteMocker {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async mockClearQueue(): Promise<QueueManageRequest[]> {
|
||||
const response = zQueueManageResponse.parse({ cleared: true })
|
||||
return await this.mockPostManageRoute(
|
||||
'queue',
|
||||
zQueueManageRequest,
|
||||
response
|
||||
)
|
||||
}
|
||||
|
||||
async mockClearHistory(): Promise<HistoryManageRequest[]> {
|
||||
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
|
||||
}
|
||||
|
||||
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>,
|
||||
response: unknown
|
||||
): Promise<TRequest[]> {
|
||||
const requests: TRequest[] = []
|
||||
|
||||
await this.page.route(
|
||||
(url) => url.pathname.endsWith(`/api/${type}`),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'POST') {
|
||||
await requestRoute.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
requests.push(
|
||||
requestSchema.parse(requestRoute.request().postDataJSON())
|
||||
)
|
||||
await requestRoute.fulfill({ json: response })
|
||||
}
|
||||
)
|
||||
|
||||
return requests
|
||||
}
|
||||
}
|
||||
|
||||
export const jobsRouteFixture = base.extend<{
|
||||
@@ -168,6 +232,5 @@ export const jobsRouteFixture = base.extend<{
|
||||
}>({
|
||||
jobsRoutes: async ({ page }, use) => {
|
||||
await use(new JobsRouteMocker(page))
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -226,7 +234,10 @@ export const TestIds = {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
queue: {
|
||||
jobHistorySidebar: 'job-history-sidebar',
|
||||
progressOverlay: 'queue-progress-overlay',
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
dockedJobHistoryAction: 'docked-job-history-action',
|
||||
jobDetailsPopover: 'queue-job-details-popover',
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list',
|
||||
|
||||
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").
|
||||
|
||||
@@ -147,5 +147,68 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
|
||||
})
|
||||
|
||||
test('resyncs the terminal when the WebSocket reconnects', async ({
|
||||
comfyPage,
|
||||
logsTerminal,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
|
||||
const initialLine = 'pre-reboot log line'
|
||||
const postRebootLineA = 'post-reboot line A'
|
||||
const postRebootLineB = 'post-reboot line B'
|
||||
|
||||
await logsTerminal.mockRawLogs([initialLine])
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
initialLine
|
||||
)
|
||||
|
||||
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
|
||||
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
|
||||
|
||||
const ws = await getWebSocket()
|
||||
await logsTerminal.triggerReconnect(ws, subscribeFetches)
|
||||
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
postRebootLineA
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
postRebootLineB
|
||||
)
|
||||
// reset() before write means the pre-reboot line must be gone.
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
|
||||
initialLine
|
||||
)
|
||||
})
|
||||
|
||||
test('resumes WebSocket log streaming after the reconnect', async ({
|
||||
comfyPage,
|
||||
logsTerminal,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
|
||||
await logsTerminal.mockRawLogs(['initial'])
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
'initial'
|
||||
)
|
||||
|
||||
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
|
||||
|
||||
const ws = await getWebSocket()
|
||||
await logsTerminal.triggerReconnect(ws, subscribeFetches)
|
||||
|
||||
// The route handler fires again on the new connection; pull the latest
|
||||
// WebSocketRoute and push a live frame to prove the 'logs' listener
|
||||
// survived the reconnect.
|
||||
const liveLine = 'live log emitted after the reconnect'
|
||||
const newWs = await getWebSocket()
|
||||
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
|
||||
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
liveLine
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
274
browser_tests/tests/sidebar/jobHistory.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture,
|
||||
routeMockJobTimestamp
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type { JobsScenario } from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const historyJobs: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'history-completed',
|
||||
status: 'completed',
|
||||
create_time: routeMockJobTimestamp - 60_000,
|
||||
execution_start_time: routeMockJobTimestamp - 60_000,
|
||||
execution_end_time: routeMockJobTimestamp - 55_000,
|
||||
preview_output: {
|
||||
filename: 'completed-output.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createRouteMockJob({
|
||||
id: 'history-failed',
|
||||
status: 'failed',
|
||||
create_time: routeMockJobTimestamp - 120_000,
|
||||
execution_start_time: routeMockJobTimestamp - 120_000,
|
||||
execution_end_time: routeMockJobTimestamp - 118_000,
|
||||
outputs_count: 0,
|
||||
execution_error: {
|
||||
node_id: '1',
|
||||
node_type: 'SaveImage',
|
||||
exception_message: 'Intentional fixture failure',
|
||||
exception_type: 'Error',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
}),
|
||||
createRouteMockJob({
|
||||
id: 'history-cancelled',
|
||||
status: 'cancelled',
|
||||
create_time: routeMockJobTimestamp - 180_000,
|
||||
execution_start_time: routeMockJobTimestamp - 180_000,
|
||||
execution_end_time: routeMockJobTimestamp - 179_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
const activeJobs: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'queue-running',
|
||||
status: 'in_progress',
|
||||
create_time: routeMockJobTimestamp - 10_000,
|
||||
execution_start_time: routeMockJobTimestamp - 9_000,
|
||||
execution_end_time: null,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createRouteMockJob({
|
||||
id: 'queue-pending',
|
||||
status: 'pending',
|
||||
create_time: routeMockJobTimestamp - 5_000,
|
||||
execution_start_time: null,
|
||||
execution_end_time: null,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
|
||||
|
||||
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 })
|
||||
.click()
|
||||
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
|
||||
}
|
||||
|
||||
function jobRow(comfyPage: ComfyPage) {
|
||||
const list = comfyPage.page.getByTestId(TestIds.queue.jobAssetsList)
|
||||
|
||||
return (jobId: string) => list.locator(`[data-job-id="${jobId}"]`)
|
||||
}
|
||||
|
||||
function jobHistorySidebar(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.queue.jobHistorySidebar)
|
||||
}
|
||||
|
||||
function clearQueueButton(comfyPage: ComfyPage) {
|
||||
return jobHistorySidebar(comfyPage).getByRole('button', {
|
||||
name: 'Clear queue',
|
||||
exact: true
|
||||
})
|
||||
}
|
||||
|
||||
async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
|
||||
await jobHistorySidebar(comfyPage)
|
||||
.getByLabel(/More options/i)
|
||||
.click()
|
||||
await comfyPage.page.getByTestId(TestIds.queue.clearHistoryAction).click()
|
||||
}
|
||||
|
||||
test.describe('Job history sidebar', { tag: '@ui' }, () => {
|
||||
test.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()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('expanded history tab', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': true } })
|
||||
|
||||
test('shows terminal and active job states', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeEnabled()
|
||||
})
|
||||
|
||||
test('filters completed and failed history jobs', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
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 }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
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('clears pending queue jobs and leaves running/history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
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 |
291
src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
|
||||
|
||||
const apiMock = vi.hoisted(
|
||||
() =>
|
||||
new (class extends EventTarget {
|
||||
clientId: string | null = 'test-client'
|
||||
getRawLogs = vi.fn(async () => ({ entries: [{ m: 'log line\n' }] }))
|
||||
subscribeLogs = vi.fn(async () => {})
|
||||
})()
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/api', () => ({ api: apiMock }))
|
||||
|
||||
const terminalMock = vi.hoisted(() => ({
|
||||
open: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
write: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
scrollToBottom: vi.fn(),
|
||||
onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
hasSelection: vi.fn(() => false),
|
||||
getSelection: vi.fn(() => ''),
|
||||
selectAll: vi.fn(),
|
||||
clearSelection: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
|
||||
useTerminal: vi.fn(() => ({
|
||||
terminal: terminalMock,
|
||||
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/components/bottomPanel/tabs/terminal/BaseTerminal.vue', async () => {
|
||||
const { defineComponent, ref } = await import('vue')
|
||||
const { useTerminal } =
|
||||
await import('@/composables/bottomPanelTabs/useTerminal')
|
||||
return {
|
||||
default: defineComponent({
|
||||
emits: ['created'],
|
||||
setup(_, { emit }) {
|
||||
const root = ref<HTMLElement | undefined>(undefined)
|
||||
emit('created', useTerminal(root), root)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
logsTerminal: {
|
||||
loadError:
|
||||
'Unable to load logs, please ensure you have updated your ComfyUI backend.',
|
||||
resyncError:
|
||||
'Unable to resync logs after the backend reconnected. Reopen the console to retry.'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderLogsTerminal = () =>
|
||||
render(LogsTerminal, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false,
|
||||
initialState: { execution: { clientId: 'test-client' } }
|
||||
}),
|
||||
i18n
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Silence the production console.error calls in error-path tests. Vitest
|
||||
// isolates this file's module graph so the spy does not affect other files.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Resolve a getRawLogs call manually to drive deterministic timing in tests
|
||||
// that need to observe behavior mid-fetch.
|
||||
const deferredRawLogs = () => {
|
||||
let resolve!: (value: { entries: { m: string }[] }) => void
|
||||
let reject!: (err: unknown) => void
|
||||
const promise = new Promise<{ entries: { m: string }[] }>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('LogsTerminal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiMock.clientId = 'test-client'
|
||||
})
|
||||
|
||||
it('loads logs and subscribes to streaming on mount', async () => {
|
||||
renderLogsTerminal()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
expect(terminalMock.write).toHaveBeenCalledWith('log line\n')
|
||||
})
|
||||
})
|
||||
|
||||
it('resyncs, snaps to tail, and re-subscribes on "reconnected"', async () => {
|
||||
renderLogsTerminal()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
|
||||
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
|
||||
expect(terminalMock.scrollToBottom).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledTimes(2)
|
||||
expect(apiMock.subscribeLogs).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
|
||||
// The full sequence must be: reset -> write -> scroll -> subscribe
|
||||
const resetOrder = terminalMock.reset.mock.invocationCallOrder[0]
|
||||
const writeOrder = terminalMock.write.mock.invocationCallOrder.at(-1)!
|
||||
const scrollOrder = terminalMock.scrollToBottom.mock.invocationCallOrder[0]
|
||||
const subscribeOrder =
|
||||
apiMock.subscribeLogs.mock.invocationCallOrder.at(-1)!
|
||||
expect(resetOrder).toBeLessThan(writeOrder)
|
||||
expect(writeOrder).toBeLessThan(scrollOrder)
|
||||
expect(scrollOrder).toBeLessThan(subscribeOrder)
|
||||
})
|
||||
|
||||
it('aborts an in-flight resync when a second "reconnected" arrives', async () => {
|
||||
renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
// First resync hangs on getRawLogs
|
||||
const first = deferredRawLogs()
|
||||
apiMock.getRawLogs.mockImplementationOnce(() => first.promise)
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Second resync resolves immediately
|
||||
apiMock.getRawLogs.mockImplementationOnce(async () => ({
|
||||
entries: [{ m: 'fresh\n' }]
|
||||
}))
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
await vi.waitFor(() => {
|
||||
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Now resolve the first (aborted) resync — none of its side effects must apply
|
||||
first.resolve({ entries: [{ m: 'stale\n' }] })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
|
||||
expect(terminalMock.write).not.toHaveBeenCalledWith('stale\n')
|
||||
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
|
||||
})
|
||||
|
||||
it('aborts an in-flight mount fetch when "reconnected" arrives first', async () => {
|
||||
// Mount's getRawLogs hangs so we can drive the race deterministically.
|
||||
const mount = deferredRawLogs()
|
||||
apiMock.getRawLogs.mockImplementationOnce(() => mount.promise)
|
||||
renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Resync wins the race and writes the post-reboot snapshot.
|
||||
apiMock.getRawLogs.mockImplementationOnce(async () => ({
|
||||
entries: [{ m: 'fresh\n' }]
|
||||
}))
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
await vi.waitFor(() => {
|
||||
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
|
||||
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
|
||||
})
|
||||
|
||||
// Mount's late response must not stomp on the freshly-reset terminal.
|
||||
mount.resolve({ entries: [{ m: 'stale-mount\n' }] })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(terminalMock.write).not.toHaveBeenCalledWith('stale-mount\n')
|
||||
})
|
||||
|
||||
it('surfaces an inline error when the resync fetch fails', async () => {
|
||||
renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('terminal-error-message').textContent
|
||||
).toContain('Unable to resync logs')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a load error when the initial fetch fails', async () => {
|
||||
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
renderLogsTerminal()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('terminal-error-message').textContent
|
||||
).toContain('Unable to load logs')
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers from an initial load failure when a reconnect arrives', async () => {
|
||||
apiMock.getRawLogs
|
||||
.mockRejectedValueOnce(new Error('initial fail'))
|
||||
.mockResolvedValueOnce({ entries: [{ m: 'recovered\n' }] })
|
||||
|
||||
renderLogsTerminal()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('terminal-error-message').textContent
|
||||
).toContain('Unable to load logs')
|
||||
})
|
||||
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId('terminal-error-message')).toBeNull()
|
||||
expect(screen.queryByTestId('terminal-loading-spinner')).toBeNull()
|
||||
expect(terminalMock.write).toHaveBeenCalledWith('recovered\n')
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans up listeners and unsubscribes on unmount', async () => {
|
||||
const { unmount } = renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
unmount()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
apiMock.dispatchEvent(new CustomEvent('reconnected'))
|
||||
await nextTick()
|
||||
|
||||
expect(terminalMock.reset).not.toHaveBeenCalled()
|
||||
// No additional getRawLogs beyond the mount-time call
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not write to the terminal when unmount happens mid-fetch', async () => {
|
||||
const pending = deferredRawLogs()
|
||||
apiMock.getRawLogs.mockImplementationOnce(() => pending.promise)
|
||||
|
||||
const { unmount } = renderLogsTerminal()
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
unmount()
|
||||
pending.resolve({ entries: [{ m: 'late\n' }] })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(terminalMock.write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -12,79 +12,36 @@
|
||||
data-testid="terminal-loading-spinner"
|
||||
class="relative inset-0 z-10 flex h-full items-center justify-center"
|
||||
/>
|
||||
<BaseTerminal v-show="!loading" @created="terminalCreated" />
|
||||
<BaseTerminal
|
||||
v-show="!loading && !errorMessage"
|
||||
@created="terminalCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useLogsTerminal } from '@/composables/bottomPanelTabs/useLogsTerminal'
|
||||
|
||||
import BaseTerminal from './BaseTerminal.vue'
|
||||
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(true)
|
||||
const terminal = shallowRef<Terminal>()
|
||||
const { errorMessage, loading } = useLogsTerminal(terminal)
|
||||
|
||||
const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
{ terminal: instance, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
// Auto-size terminal to fill container width.
|
||||
// minCols: 80 ensures minimum width for colab environments.
|
||||
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
|
||||
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
|
||||
|
||||
const update = (entries: Array<LogEntry>) => {
|
||||
terminal.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
|
||||
update(e.detail.entries)
|
||||
}
|
||||
|
||||
const loadLogEntries = async () => {
|
||||
const logs = await api.getRawLogs()
|
||||
update(logs.entries)
|
||||
}
|
||||
|
||||
const watchLogs = async () => {
|
||||
const { clientId } = storeToRefs(useExecutionStore())
|
||||
if (!clientId.value) {
|
||||
await until(clientId).not.toBeNull()
|
||||
}
|
||||
await api.subscribeLogs(true)
|
||||
api.addEventListener('logs', logReceived)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadLogEntries()
|
||||
} catch (err) {
|
||||
console.error('Error loading logs', err)
|
||||
// On older backends the endpoints won't exist
|
||||
errorMessage.value =
|
||||
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
|
||||
return
|
||||
}
|
||||
|
||||
await watchLogs()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
if (api.clientId) {
|
||||
await api.subscribeLogs(false)
|
||||
}
|
||||
api.removeEventListener('logs', logReceived)
|
||||
})
|
||||
terminal.value = instance
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
:class="['flex', 'justify-end', 'w-full', 'pointer-events-none']"
|
||||
>
|
||||
<div
|
||||
data-testid="queue-progress-overlay"
|
||||
class="pointer-events-auto flex max-h-[60vh] w-[350px] min-w-[310px] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
|
||||
:class="containerClass"
|
||||
@mouseenter="isHovered = true"
|
||||
|
||||
@@ -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;',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('queue.jobHistory')">
|
||||
<SidebarTabTemplate
|
||||
data-testid="job-history-sidebar"
|
||||
:title="$t('queue.jobHistory')"
|
||||
>
|
||||
<template #alt-title>
|
||||
<div class="ml-auto flex shrink-0 items-center">
|
||||
<JobHistoryActionsMenu @clear-history="onClearHistory" />
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
123
src/composables/bottomPanelTabs/useLogsTerminal.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { until, useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onScopeDispose, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
type TerminalLike = {
|
||||
write: (data: string) => void
|
||||
reset: () => void
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the built-in logs terminal: initial load, live `logs` stream, and
|
||||
* full resync when the backend WebSocket reconnects (e.g., after a reboot).
|
||||
*
|
||||
* Listeners are registered synchronously so we cannot miss a `reconnected`
|
||||
* event during the mount-time fetch/subscribe awaits. In-flight fetches are
|
||||
* tied to AbortControllers so that:
|
||||
* - rapid double-reconnects don't interleave writes / double-subscribe
|
||||
* - unmount mid-fetch never writes to a disposed terminal
|
||||
*/
|
||||
export function useLogsTerminal(
|
||||
terminal: Readonly<Ref<TerminalLike | undefined>>
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
let mountController: AbortController | undefined
|
||||
let resyncController: AbortController | undefined
|
||||
|
||||
const writeEntries = (entries: LogEntry[]) => {
|
||||
terminal.value?.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const resyncLogs = async () => {
|
||||
// Cancel both the in-flight mount fetch and any prior resync so a late
|
||||
// mount response can't write a stale snapshot on top of a freshly-reset
|
||||
// terminal after we've already written the post-reconnect view.
|
||||
mountController?.abort()
|
||||
resyncController?.abort()
|
||||
const controller = new AbortController()
|
||||
resyncController = controller
|
||||
const { signal } = controller
|
||||
|
||||
try {
|
||||
const logs = await api.getRawLogs()
|
||||
if (signal.aborted || !terminal.value) return
|
||||
terminal.value.reset()
|
||||
writeEntries(logs.entries)
|
||||
terminal.value.scrollToBottom()
|
||||
// Backend lost the per-client log subscription across the restart;
|
||||
// re-subscribe so new runtime logs stream over the fresh WebSocket.
|
||||
await api.subscribeLogs(true)
|
||||
if (signal.aborted) return
|
||||
errorMessage.value = ''
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error resyncing logs after reconnect', err)
|
||||
errorMessage.value = t('logsTerminal.resyncError')
|
||||
}
|
||||
}
|
||||
|
||||
// Register listeners synchronously, before any awaits, so a reconnect
|
||||
// fired during mount cannot be missed. useEventListener handles cleanup
|
||||
// on scope dispose.
|
||||
useEventListener(api, 'logs', (e: CustomEvent<LogsWsMessage>) => {
|
||||
writeEntries(e.detail.entries)
|
||||
})
|
||||
useEventListener(api, 'reconnected', () => {
|
||||
void resyncLogs()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!terminal.value) await until(terminal).toBeTruthy()
|
||||
|
||||
const controller = new AbortController()
|
||||
mountController = controller
|
||||
const { signal } = controller
|
||||
|
||||
try {
|
||||
const logs = await api.getRawLogs()
|
||||
if (signal.aborted || !terminal.value) return
|
||||
writeEntries(logs.entries)
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error loading logs', err)
|
||||
errorMessage.value = t('logsTerminal.loadError')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const { clientId } = storeToRefs(useExecutionStore())
|
||||
if (!clientId.value) await until(clientId).not.toBeNull()
|
||||
if (signal.aborted) return
|
||||
|
||||
try {
|
||||
await api.subscribeLogs(true)
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error subscribing to logs', err)
|
||||
}
|
||||
|
||||
if (!signal.aborted) loading.value = false
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
mountController?.abort()
|
||||
resyncController?.abort()
|
||||
if (!api.clientId) return
|
||||
api.subscribeLogs(false).catch((err) => {
|
||||
console.error('Error unsubscribing from logs', err)
|
||||
})
|
||||
})
|
||||
|
||||
return { errorMessage, loading }
|
||||
}
|
||||
@@ -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"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||