Compare commits
19 Commits
cloud/v1.4
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81965d4c5f | ||
|
|
219a574eed | ||
|
|
fef2cab31e | ||
|
|
20ee262f78 | ||
|
|
6a8c453659 | ||
|
|
ea277dec4d | ||
|
|
a7aa124c10 | ||
|
|
9c62bbc74a | ||
|
|
f0e16cdf46 | ||
|
|
0658c1ac9c | ||
|
|
997501d8fb | ||
|
|
ab6e5ba094 | ||
|
|
2322a5a497 | ||
|
|
0bc951fd12 | ||
|
|
0446ca7a18 | ||
|
|
653ee48444 | ||
|
|
81d9df61f2 | ||
|
|
f4358cb161 | ||
|
|
5948002dee |
36
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -20,6 +20,8 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -37,31 +39,33 @@ jobs:
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Detect shard coverage data
|
||||
id: coverage-shards
|
||||
run: |
|
||||
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
|
||||
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Install lcov
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: sudo apt-get install -y -qq lcov
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
|
||||
if [ -z "$LCOV_FILES" ]; then
|
||||
echo "No coverage.lcov files found"
|
||||
touch coverage/playwright/coverage.lcov
|
||||
exit 0
|
||||
fi
|
||||
ADD_ARGS=""
|
||||
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
@@ -91,7 +95,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
@@ -100,6 +104,7 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
@@ -114,6 +119,7 @@ jobs:
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
@@ -122,7 +128,9 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import { getDefaultLocale } from '@frontend-locales/localeConfig'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
function buildLocale<
|
||||
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
|
||||
33
apps/website/e2e/customers.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customers @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/customers')
|
||||
})
|
||||
|
||||
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
|
||||
await expect(heroImage).toBeVisible()
|
||||
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
|
||||
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
|
||||
|
||||
// Regression guard: an unloaded <img> without intrinsic dimensions
|
||||
// collapses to ~0px, then jumps to its natural size on load and pushes
|
||||
// the video below it. Reserved space must persist before bytes arrive.
|
||||
const heightWhileUnloaded = await page.evaluate(() => {
|
||||
const img = document.querySelector<HTMLImageElement>(
|
||||
'img[alt="Comfy 3D logo"]'
|
||||
)
|
||||
if (!img) return null
|
||||
img.removeAttribute('src')
|
||||
return img.getBoundingClientRect().height
|
||||
})
|
||||
|
||||
expect(heightWhileUnloaded).not.toBeNull()
|
||||
expect(heightWhileUnloaded!).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
|
||||
}
|
||||
|
||||
async function navigateAndSettle(page: Page, url: string) {
|
||||
await page.goto(url)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('load')
|
||||
}
|
||||
|
||||
test.describe('Home', { tag: '@visual' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 99 KiB |
@@ -28,7 +28,7 @@ export default defineConfig({
|
||||
? [['html'], ['json', { outputFile: 'results.json' }]]
|
||||
: 'html',
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 50 }
|
||||
toHaveScreenshot: { maxDiffPixels: 100 }
|
||||
},
|
||||
...maybeLocalOptions,
|
||||
webServer: {
|
||||
|
||||
@@ -13,7 +13,7 @@ const { stars } = defineProps<{
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="`ComfyUI on GitHub — ${stars} stars`"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
class="hidden shrink-0 items-center gap-1 lg:flex"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="[{ text: stars }]"
|
||||
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
|
||||
size-class="h-5 sm:h-5"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7"
|
||||
class="bg-primary-comfy-yellow block size-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { ScrollTrigger } from '../../scripts/gsapSetup'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -22,6 +23,10 @@ useHeroAnimation({
|
||||
logo: logoRef,
|
||||
video: videoRef
|
||||
})
|
||||
|
||||
function handleLogoLoad() {
|
||||
ScrollTrigger.refresh(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,7 +42,10 @@ useHeroAnimation({
|
||||
<img
|
||||
src="https://media.comfy.org/website/customers/c-projection.webp"
|
||||
alt="Comfy 3D logo"
|
||||
class="mx-auto w-full max-w-md lg:max-w-none"
|
||||
width="1568"
|
||||
height="1763"
|
||||
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
|
||||
@load="handleLogoLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const categories: Category[] = [
|
||||
{
|
||||
label: t('useCase.vfx', locale),
|
||||
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
|
||||
},
|
||||
{
|
||||
label: t('useCase.advertising', locale),
|
||||
|
||||
85
apps/website/src/i18n/translations.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { t, hasKey, translationKeys } from './translations'
|
||||
|
||||
describe('translations', () => {
|
||||
describe('pricing.plan.creator keys', () => {
|
||||
it('feature1 key exists with correct English text', () => {
|
||||
expect(hasKey('pricing.plan.creator.feature1')).toBe(true)
|
||||
expect(t('pricing.plan.creator.feature1')).toBe('Import your own LoRAs')
|
||||
})
|
||||
|
||||
it('feature1 key returns correct zh-CN translation', () => {
|
||||
expect(t('pricing.plan.creator.feature1', 'zh-CN')).toBe(
|
||||
'导入你自己的 LoRA'
|
||||
)
|
||||
})
|
||||
|
||||
it('feature2 key does not exist (removed concurrent API jobs entry)', () => {
|
||||
expect(hasKey('pricing.plan.creator.feature2')).toBe(false)
|
||||
})
|
||||
|
||||
it('translationKeys array does not include removed creator feature2', () => {
|
||||
expect(translationKeys).not.toContain('pricing.plan.creator.feature2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pricing.plan.pro keys', () => {
|
||||
it('feature1 key exists with correct English text', () => {
|
||||
expect(hasKey('pricing.plan.pro.feature1')).toBe(true)
|
||||
expect(t('pricing.plan.pro.feature1')).toBe(
|
||||
'Longer workflow runtime (up to 1 hour)'
|
||||
)
|
||||
})
|
||||
|
||||
it('feature1 key returns correct zh-CN translation', () => {
|
||||
expect(t('pricing.plan.pro.feature1', 'zh-CN')).toBe(
|
||||
'更长工作流运行时长(最长 1 小时)'
|
||||
)
|
||||
})
|
||||
|
||||
it('feature2 key does not exist (removed concurrent API jobs entry)', () => {
|
||||
expect(hasKey('pricing.plan.pro.feature2')).toBe(false)
|
||||
})
|
||||
|
||||
it('translationKeys array does not include removed pro feature2', () => {
|
||||
expect(translationKeys).not.toContain('pricing.plan.pro.feature2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('t() function defaults', () => {
|
||||
it('defaults locale to en when not provided', () => {
|
||||
expect(t('pricing.plan.creator.feature1')).toBe(
|
||||
t('pricing.plan.creator.feature1', 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to en when locale translation is missing', () => {
|
||||
// All keys in this file have both en and zh-CN, but the fallback
|
||||
// behaviour is part of the contract: t() should return en if the
|
||||
// locale value is nullish.
|
||||
const englishValue = t('pricing.plan.pro.feature1', 'en')
|
||||
expect(englishValue).toBe('Longer workflow runtime (up to 1 hour)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasKey()', () => {
|
||||
it('returns true for known keys', () => {
|
||||
expect(hasKey('hero.title')).toBe(true)
|
||||
expect(hasKey('pricing.plan.creator.feature1')).toBe(true)
|
||||
expect(hasKey('pricing.plan.pro.feature1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for completely unknown keys', () => {
|
||||
expect(hasKey('nonexistent.key')).toBe(false)
|
||||
expect(hasKey('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for the two removed concurrent-API-jobs keys', () => {
|
||||
// Regression guard: these keys carried "3 concurrent API jobs" and
|
||||
// "5 concurrent API jobs" copy that was intentionally removed.
|
||||
expect(hasKey('pricing.plan.creator.feature2')).toBe(false)
|
||||
expect(hasKey('pricing.plan.pro.feature2')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockInit: vi.fn(),
|
||||
mockCapture: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: hoisted.mockInit,
|
||||
capture: hoisted.mockCapture
|
||||
}
|
||||
}))
|
||||
|
||||
describe('initPostHog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
|
||||
const { initPostHog } = await import('./posthog')
|
||||
initPostHog()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledOnce()
|
||||
const initOptions = hoisted.mockInit.mock.calls[0][1]
|
||||
expect(initOptions.person_profiles).toBe('identified_only')
|
||||
expect(typeof initOptions.before_send).toBe('function')
|
||||
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: { email: 'd@example.com', name: 'keep me' },
|
||||
$set_once: { $email: 'e@example.com', plan: 'free' }
|
||||
}
|
||||
|
||||
const result = initOptions.before_send(event)
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
expect(result.$set_once).not.toHaveProperty('$email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -20,9 +18,7 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
person_profiles: 'identified_only'
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -190,6 +190,9 @@ export class ComfyPage {
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
|
||||
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
|
||||
public isVueNodes = false
|
||||
|
||||
/** Test user ID for the current context */
|
||||
get id() {
|
||||
return this.userIds[comfyPageFixture.info().parallelIndex]
|
||||
@@ -282,12 +285,10 @@ export class ComfyPage {
|
||||
|
||||
async setup({
|
||||
clearStorage = true,
|
||||
mockReleases = true,
|
||||
url
|
||||
mockReleases = true
|
||||
}: {
|
||||
clearStorage?: boolean
|
||||
mockReleases?: boolean
|
||||
url?: string
|
||||
} = {}) {
|
||||
// Mock release endpoint to prevent changelog popups (before navigation)
|
||||
if (mockReleases) {
|
||||
@@ -319,7 +320,7 @@ export class ComfyPage {
|
||||
}, this.id)
|
||||
}
|
||||
|
||||
await this.goto({ url })
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
await this.waitForAppReady()
|
||||
@@ -346,8 +347,8 @@ export class ComfyPage {
|
||||
return assetPath(fileName)
|
||||
}
|
||||
|
||||
async goto({ url }: { url?: string } = {}) {
|
||||
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
|
||||
async goto() {
|
||||
await this.page.goto(this.url)
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
@@ -502,6 +503,7 @@ export const comfyPageFixture = base.extend<{
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
const isVueNodes = testInfo.tags.includes('@vue-nodes')
|
||||
comfyPage.isVueNodes = isVueNodes
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ContextMenu {
|
||||
|
||||
async clickMenuItemExact(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,7 +95,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
@@ -104,7 +103,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
|
||||
@@ -6,71 +6,6 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
function readFilePayload(filePath: string) {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
return { bufferArray, fileName, fileType }
|
||||
}
|
||||
|
||||
async function dispatchFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const target = document.activeElement ?? document
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
async function interceptNextFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
document.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
type PasteFileOptions = {
|
||||
mode?: 'keyboard' | 'direct'
|
||||
}
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
@@ -85,20 +20,43 @@ export class ClipboardHelper {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(
|
||||
filePath: string,
|
||||
{ mode = 'keyboard' }: PasteFileOptions = {}
|
||||
): Promise<void> {
|
||||
const payload = readFilePayload(filePath)
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
if (mode === 'keyboard') {
|
||||
await interceptNextFilePaste(this.page, payload)
|
||||
await this.paste()
|
||||
return
|
||||
}
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
|
||||
// Dispatch the app-level paste event with file clipboardData directly.
|
||||
await dispatchFilePaste(this.page, payload)
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
@@ -13,6 +14,7 @@ export class DragDropHelper {
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
filePath?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
@@ -22,13 +24,14 @@ export class DragDropHelper {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
filePath,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
throw new Error('Must provide either fileName or url')
|
||||
if (!fileName && !filePath && !url)
|
||||
throw new Error('Must provide fileName, filePath, or url')
|
||||
|
||||
const evaluateParams: {
|
||||
dropPosition: Position
|
||||
@@ -39,12 +42,22 @@ export class DragDropHelper {
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
if (fileName || filePath) {
|
||||
const resolvedPath = filePath ?? assetPath(fileName!)
|
||||
const displayName = fileName ?? basename(resolvedPath)
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = readFileSync(resolvedPath)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.fileName = displayName
|
||||
evaluateParams.fileType = getMimeType(displayName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
@@ -148,6 +161,13 @@ export class DragDropHelper {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropFilePath(
|
||||
filePath: string,
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ filePath, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import type { Page, Route, WebSocketRoute } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page, Route } 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[]): 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({
|
||||
async mockRawLogs(messages: string[]) {
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
|
||||
})
|
||||
})
|
||||
return () => count
|
||||
)
|
||||
}
|
||||
|
||||
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
|
||||
@@ -28,8 +21,7 @@ export class LogsTerminalHelper {
|
||||
const pending = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
|
||||
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
|
||||
await pending
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -41,39 +33,15 @@ export class LogsTerminalHelper {
|
||||
}
|
||||
|
||||
async mockRawLogsError() {
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, (route: Route) =>
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
route.fulfill({ status: 500, body: 'Internal Server Error' })
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
async mockSubscribeLogs() {
|
||||
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
|
||||
route.fulfill({ status: 200, body: '' })
|
||||
)
|
||||
}
|
||||
|
||||
static buildWsLogFrame(messages: string[]): string {
|
||||
|
||||
@@ -362,6 +362,9 @@ export class SubgraphHelper {
|
||||
|
||||
await this.comfyPage.nextFrame()
|
||||
await expect.poll(async () => this.isInSubgraph()).toBe(false)
|
||||
if (this.comfyPage.isVueNodes) {
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async countGraphPseudoPreviewEntries(): Promise<number> {
|
||||
|
||||
@@ -8,7 +8,6 @@ export const TestIds = {
|
||||
toolbar: 'side-toolbar',
|
||||
nodeLibrary: 'node-library-tree',
|
||||
nodeLibrarySearch: 'node-library-search',
|
||||
nodePreviewCard: 'node-preview-card',
|
||||
workflows: 'workflows-sidebar',
|
||||
modeToggle: 'mode-toggle'
|
||||
},
|
||||
@@ -76,15 +75,7 @@ export const TestIds = {
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-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'
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
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) ?? []
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.mp3')) return 'audio/mpeg'
|
||||
if (name.endsWith('.flac')) return 'audio/flac'
|
||||
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
|
||||
export function metadataFixturePath(fileName: string): string {
|
||||
return `./src/scripts/metadata/__fixtures__/${fileName}`
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -39,6 +39,16 @@ export class VueNodeFixture {
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select this node and delete it via the Delete key, waiting for the node
|
||||
* element to leave the DOM before resolving.
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
await this.header.click()
|
||||
await this.header.press('Delete')
|
||||
await this.locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async getCollapseIconClass(): Promise<string> {
|
||||
return (await this.collapseIcon.getAttribute('class')) ?? ''
|
||||
}
|
||||
|
||||
@@ -133,27 +133,48 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
test('GET /assets does not filter by exclude_tags (parameter removed)', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
// Regression guard: exclude_tags filtering was removed from handleListAssets
|
||||
// and getFilteredAssets. Passing exclude_tags must NOT exclude any assets —
|
||||
// all assets matching include_tags (or all assets when include_tags is
|
||||
// absent) should be returned regardless of the exclude_tags param.
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
id: 'tagged-input',
|
||||
tags: ['input', 'extra-tag']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
`${comfyPage.url}/api/assets?include_tags=input&exclude_tags=extra-tag`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
// Both assets share the 'input' include_tag. With exclude_tags removed,
|
||||
// neither should be hidden — the response must contain both.
|
||||
expect(data.assets).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('GET /assets with empty include_tags returns all assets', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
// When include_tags is absent or empty the helper returns the full store.
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(`${comfyPage.url}/api/assets`)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
|
||||
@@ -147,68 +147,5 @@ 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
|
||||
//
|
||||
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
|
||||
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
|
||||
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
|
||||
// must render translated strings, not literal i18n keys like
|
||||
// 'sideToolbar.labels.assets'.
|
||||
test.describe('i18n locale fallback', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: 'de-DE',
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: ['de-DE', 'de'],
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
// Default sidebar size on small viewports hides labels; force normal so
|
||||
// .side-bar-button-label is rendered for the assertion.
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.waitForAppReady()
|
||||
})
|
||||
|
||||
test('sidebar labels render translated strings, not raw i18n keys', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
|
||||
const labelTexts = await page
|
||||
.getByTestId('side-toolbar')
|
||||
.locator('.side-bar-button-label')
|
||||
.allTextContents()
|
||||
|
||||
expect(labelTexts.length).toBeGreaterThan(0)
|
||||
for (const text of labelTexts) {
|
||||
expect(text).not.toContain('sideToolbar.labels')
|
||||
}
|
||||
})
|
||||
})
|
||||
62
browser_tests/tests/metadataWorkflowImport.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
type MetadataFixture = {
|
||||
fileName: string
|
||||
parser: string
|
||||
}
|
||||
|
||||
// Each fixture embeds the same single-KSampler workflow (see
|
||||
// scripts/generate-embedded-metadata-test-files.py), exercising a different
|
||||
// parser in src/scripts/metadata/. Dropping the file should import that
|
||||
// workflow.
|
||||
const FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_metadata.png', parser: 'png' },
|
||||
{ fileName: 'with_metadata.avif', parser: 'avif' },
|
||||
{ fileName: 'with_metadata.webp', parser: 'webp' },
|
||||
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
|
||||
{ fileName: 'with_metadata.flac', parser: 'flac' },
|
||||
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
|
||||
{ fileName: 'with_metadata.opus', parser: 'ogg' },
|
||||
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
|
||||
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
test.describe(
|
||||
'Metadata drop-to-load workflow import',
|
||||
{ tag: ['@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
})
|
||||
|
||||
for (const { fileName, parser } of FIXTURES) {
|
||||
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step(`drop ${fileName} on canvas`, async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFilePath(
|
||||
metadataFixturePath(fileName)
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('graph contains only the embedded KSampler', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const ksamplers =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
expect(
|
||||
ksamplers,
|
||||
'exactly one KSampler should have been loaded from the fixture'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
123
browser_tests/tests/mimeTypeUtil.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
// These tests verify the current state of getMimeType after audio MIME types
|
||||
// (mp3, flac, ogg, opus) were removed in favour of 'application/octet-stream'.
|
||||
|
||||
test.describe('getMimeType', { tag: '@unit' }, () => {
|
||||
test.describe('image formats (still supported)', () => {
|
||||
test('returns image/png for .png files', () => {
|
||||
expect(getMimeType('photo.png')).toBe('image/png')
|
||||
})
|
||||
|
||||
test('returns image/jpeg for .jpg files', () => {
|
||||
expect(getMimeType('photo.jpg')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
test('returns image/jpeg for .jpeg files', () => {
|
||||
expect(getMimeType('photo.jpeg')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
test('returns image/webp for .webp files', () => {
|
||||
expect(getMimeType('image.webp')).toBe('image/webp')
|
||||
})
|
||||
|
||||
test('returns image/svg+xml for .svg files', () => {
|
||||
expect(getMimeType('icon.svg')).toBe('image/svg+xml')
|
||||
})
|
||||
|
||||
test('returns image/avif for .avif files', () => {
|
||||
expect(getMimeType('image.avif')).toBe('image/avif')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('video formats (still supported)', () => {
|
||||
test('returns video/webm for .webm files', () => {
|
||||
expect(getMimeType('video.webm')).toBe('video/webm')
|
||||
})
|
||||
|
||||
test('returns video/mp4 for .mp4 files', () => {
|
||||
expect(getMimeType('video.mp4')).toBe('video/mp4')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('other formats (still supported)', () => {
|
||||
test('returns application/json for .json files', () => {
|
||||
expect(getMimeType('data.json')).toBe('application/json')
|
||||
})
|
||||
|
||||
test('returns model/gltf-binary for .glb files', () => {
|
||||
expect(getMimeType('model.glb')).toBe('model/gltf-binary')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('audio formats (no longer supported — regression guard)', () => {
|
||||
// These formats were explicitly removed from getMimeType. They must now
|
||||
// fall through to the generic octet-stream default, not return audio/*
|
||||
// MIME types.
|
||||
|
||||
test('returns application/octet-stream for .mp3 files', () => {
|
||||
expect(getMimeType('audio.mp3')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for .flac files', () => {
|
||||
expect(getMimeType('audio.flac')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for .ogg files', () => {
|
||||
expect(getMimeType('audio.ogg')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for .opus files', () => {
|
||||
expect(getMimeType('audio.opus')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('unknown / fallback extensions', () => {
|
||||
test('returns application/octet-stream for unknown extensions', () => {
|
||||
expect(getMimeType('file.xyz')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for files with no extension', () => {
|
||||
expect(getMimeType('README')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for empty string', () => {
|
||||
expect(getMimeType('')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('case insensitivity', () => {
|
||||
test('handles uppercase .PNG extension', () => {
|
||||
expect(getMimeType('IMAGE.PNG')).toBe('image/png')
|
||||
})
|
||||
|
||||
test('handles uppercase .JPG extension', () => {
|
||||
expect(getMimeType('PHOTO.JPG')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
test('handles uppercase .MP4 extension', () => {
|
||||
expect(getMimeType('VIDEO.MP4')).toBe('video/mp4')
|
||||
})
|
||||
|
||||
test('handles uppercase .WEBM extension', () => {
|
||||
expect(getMimeType('VIDEO.WEBM')).toBe('video/webm')
|
||||
})
|
||||
|
||||
// Boundary case: confirm audio formats are not supported regardless of case
|
||||
test('returns application/octet-stream for uppercase .MP3 extension', () => {
|
||||
expect(getMimeType('AUDIO.MP3')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('filenames with multiple dots', () => {
|
||||
test('resolves extension from last segment', () => {
|
||||
expect(getMimeType('my.file.name.png')).toBe('image/png')
|
||||
})
|
||||
|
||||
test('returns octet-stream when final segment is not a known extension', () => {
|
||||
expect(getMimeType('archive.tar.gz')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
test('Controls stack label above widget in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
const brushButton = painterWidget.getByText('Brush', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
'tool label should be visible in wide layout'
|
||||
).toBeVisible()
|
||||
|
||||
const wideLabelBox = await toolLabel.boundingBox()
|
||||
const wideBrushBox = await brushButton.boundingBox()
|
||||
expect(
|
||||
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
|
||||
'label should sit to the left of the brush button in wide layout'
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
'tool label should remain visible in compact layout'
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const labelBox = await toolLabel.boundingBox()
|
||||
const brushBox = await brushButton.boundingBox()
|
||||
if (!labelBox || !brushBox) return false
|
||||
return labelBox.y + labelBox.height <= brushBox.y
|
||||
},
|
||||
{
|
||||
message: 'label should stack above the brush button in compact layout'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -54,44 +54,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('info button opens the right-side info tab in new menu mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
|
||||
test('info button opens properties panel', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click()
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(panel).toContainText('KSampler')
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('info button is hidden when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
|
||||
@@ -2,7 +2,6 @@ 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')
|
||||
@@ -19,19 +18,70 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = (comfyPage: ComfyPage) =>
|
||||
openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
test('hides Node Info from More Options menu when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
// 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')
|
||||
}
|
||||
|
||||
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'Node Info'
|
||||
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||
exact: true
|
||||
})
|
||||
await expect(nodeInfoButton).toBeHidden()
|
||||
await expect(nodeInfoButton).toBeVisible()
|
||||
await nodeInfoButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||
@@ -40,14 +90,11 @@ test.describe(
|
||||
)[0]
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
// 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.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Box', { exact: true })
|
||||
).toBeVisible()
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
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' }, () => {
|
||||
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
|
||||
sharedWorkflowImportMocks.resetAndStartRecording()
|
||||
// Missing media only surfaces the overlay when the Errors tab is enabled
|
||||
// (src/stores/executionErrorStore.ts).
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -120,13 +120,4 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(options.first()).toBeVisible()
|
||||
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
|
||||
await tab.getNode('test blueprint').hover()
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -558,5 +558,52 @@ test.describe(
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.fail(
|
||||
'Promoted text widget is removed when source node is deleted inside the subgraph',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.first()
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const subgraphNodeId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('text')
|
||||
await expect(
|
||||
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await interiorClip.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const subgraphNodeAfter =
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
await expect(
|
||||
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -106,49 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.route(
|
||||
'**/workflows/published/test-share-id',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
share_id: 'test-share-id',
|
||||
workflow_id: 'wf-1',
|
||||
name: 'Shared Workflow',
|
||||
listed: true,
|
||||
publish_time: new Date().toISOString(),
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
},
|
||||
assets: []
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
|
||||
await comfyPage.setup({
|
||||
clearStorage: true,
|
||||
url: '/?share=test-share-id'
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
|
||||
|
||||
@@ -174,51 +131,48 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
test('Falls back to English templates when locale file not found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Pick a shipped LTR locale and simulate its template index returning 404.
|
||||
// (Previously this test used 'de', but unsupported locales are now
|
||||
// clamped to 'en' at boot so they never hit the template fallback path.
|
||||
// 'fa' would also work but flips document.dir to rtl, which can leak
|
||||
// into adjacent specs in the same worker.)
|
||||
const locale = 'tr'
|
||||
// Set locale to a language that doesn't have a template file
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
|
||||
|
||||
await comfyPage.page.route(
|
||||
`**/templates/index.${locale}.json`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
}
|
||||
// Wait for the German request (expected to 404)
|
||||
const germanRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.de.json'
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', locale)
|
||||
|
||||
const localeRequestPromise = comfyPage.page.waitForRequest(
|
||||
`**/templates/index.${locale}.json`
|
||||
)
|
||||
// Wait for the fallback English request
|
||||
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.json'
|
||||
)
|
||||
|
||||
// Intercept the German file to simulate a 404
|
||||
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
})
|
||||
|
||||
// Allow the English index to load normally
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
// Load the templates dialog
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
const localeRequest = await localeRequestPromise
|
||||
// Verify German was requested first, then English as fallback
|
||||
const germanRequest = await germanRequestPromise
|
||||
const englishRequest = await englishRequestPromise
|
||||
|
||||
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
|
||||
expect(germanRequest.url()).toContain('templates/index.de.json')
|
||||
expect(englishRequest.url()).toContain('templates/index.json')
|
||||
|
||||
// Assert on rendered content, not just the container — the container
|
||||
// testid is present even when the dialog body is empty, which would let
|
||||
// a regression where the fallback fetch succeeds but no cards render
|
||||
// pass silently.
|
||||
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
|
||||
// Verify English titles are shown as fallback
|
||||
await expect(
|
||||
comfyPage.page.getByRole('main').getByText('All Templates')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('template cards are dynamically sized and responsive', async ({
|
||||
|
||||
@@ -75,24 +75,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should open node info in the right side panel via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Node Info')
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -14,9 +13,7 @@ import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
@@ -25,61 +22,6 @@ const ERROR_CLASS = /ring-destructive-background/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
const LOAD_IMAGE_INPUT_NAME = 'image'
|
||||
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
|
||||
|
||||
function buildLoadImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'LoadImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
|
||||
details: '',
|
||||
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function surfaceLoadImageMissingInputError(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[loadImageId]: buildLoadImageRequiredInputError()
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
}
|
||||
|
||||
async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const loadImageId = String(loadImageNode.id)
|
||||
|
||||
return {
|
||||
loadImageId,
|
||||
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
|
||||
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -249,74 +191,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user drops an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('drop an image onto the Load Image node', async () => {
|
||||
const dropPosition =
|
||||
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
|
||||
if (!dropPosition) {
|
||||
throw new Error('Load Image node center must be available for drop')
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
|
||||
dropPosition,
|
||||
waitForUpload: true
|
||||
})
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user pastes an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('paste an image while Load Image is selected', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
|
||||
)
|
||||
.toBe('LoadImage')
|
||||
|
||||
const uploadResponse = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
// File clipboard contents cannot be seeded reliably in Playwright;
|
||||
// use the direct document paste mode to exercise usePaste.
|
||||
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
|
||||
mode: 'direct'
|
||||
})
|
||||
await uploadResponse
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
|
||||
|
||||
@@ -12,22 +12,19 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
|
||||
node.widgets!.push(node.widgets![0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
|
||||
node.widgets![2] = node.widgets![0]
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.splice(0, 0, {
|
||||
...node.widgets![0],
|
||||
name: 'added_widget_3'
|
||||
})
|
||||
node.widgets!.splice(0, 0, node.widgets![0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
|
||||
@@ -26,10 +26,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.20",
|
||||
"version": "1.45.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
13
packages/ingest-types/src/types.gen.ts
generated
@@ -524,18 +524,9 @@ export type ImportPublishedAssetsRequest = {
|
||||
*/
|
||||
published_asset_ids: Array<string>
|
||||
/**
|
||||
* Optional. Share ID of the published workflow these assets belong to.
|
||||
* When provided (non-null, non-empty): all published_asset_ids must
|
||||
* belong to this share's workflow version; returns
|
||||
* 400/CodeInvalidAssets if the share is not found or any asset does
|
||||
* not belong to it.
|
||||
* When omitted, null, or empty string: no share-scoped validation is
|
||||
* performed and the assets are validated only against global rules
|
||||
* (legacy behaviour, preserved for clients that have not yet adopted
|
||||
* share_id).
|
||||
*
|
||||
* The share ID of the published workflow these assets belong to. Required for authorization.
|
||||
*/
|
||||
share_id?: string | null
|
||||
share_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
packages/ingest-types/src/zod.gen.ts
generated
@@ -310,8 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
|
||||
* Request body for importing assets from a published workflow.
|
||||
*/
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string()),
|
||||
share_id: z.string().nullish()
|
||||
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
|
||||
share_id: z.string().min(1).max(64)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts",
|
||||
"./piiUtil": "./src/piiUtil.ts"
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -3,14 +3,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -301,42 +299,6 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinFilePath', () => {
|
||||
it('joins subfolder and filename with normalized slash separators', () => {
|
||||
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
||||
'nested/folder/child/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims boundary separators without changing the filename body', () => {
|
||||
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
||||
'nested/folder/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the normalized filename when no subfolder is provided', () => {
|
||||
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
||||
})
|
||||
|
||||
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
||||
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
||||
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilePathSeparatorVariants', () => {
|
||||
it('returns slash and backslash variants for nested paths', () => {
|
||||
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
||||
'nested/folder/file.png',
|
||||
'nested\\folder\\file.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single value when no separator is present', () => {
|
||||
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
|
||||
@@ -256,31 +256,6 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function joinFilePath(
|
||||
subfolder: string | null | undefined,
|
||||
filename: string | null | undefined
|
||||
): string {
|
||||
const normalizedSubfolder = normalizeFilePathSeparators(
|
||||
subfolder ?? ''
|
||||
).replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFilename = normalizeFilePathSeparators(
|
||||
filename ?? ''
|
||||
).replace(/^\/+/g, '')
|
||||
if (!normalizedSubfolder) return normalizedFilename
|
||||
if (!normalizedFilename) return normalizedSubfolder
|
||||
return `${normalizedSubfolder}/${normalizedFilename}`
|
||||
}
|
||||
|
||||
export function getFilePathSeparatorVariants(filepath: string): string[] {
|
||||
const slashPath = normalizeFilePathSeparators(filepath)
|
||||
const backslashPath = slashPath.replace(/\//g, '\\')
|
||||
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
|
||||
}
|
||||
|
||||
function normalizeFilePathSeparators(filepath: string): string {
|
||||
return filepath.replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
@@ -299,7 +274,8 @@ export function parseFilePath(filepath: string): {
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = normalizeFilePathSeparators(filepath)
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createPostHogBeforeSend } from './piiUtil'
|
||||
|
||||
describe('createPostHogBeforeSend', () => {
|
||||
const beforeSend = createPostHogBeforeSend()
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(beforeSend(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('strips all PII keys from properties, $set, and $set_once', () => {
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'd@example.com',
|
||||
user_email: 'e@example.com',
|
||||
$email: 'f@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'g@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = beforeSend(event)!
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('handles missing property bags gracefully', () => {
|
||||
const event = { properties: { email: 'a@example.com', safe: true } }
|
||||
const result = beforeSend(event)!
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).toHaveProperty('safe', true)
|
||||
expect(result.$set).toBeUndefined()
|
||||
expect(result.$set_once).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
|
||||
|
||||
function stripPiiKeys(obj?: Record<string, unknown>): void {
|
||||
if (!obj) return
|
||||
for (const key of PII_KEYS) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog before_send hook that strips PII from all three property bags
|
||||
* an event can carry: properties, $set, and $set_once.
|
||||
*
|
||||
* posthog.identify(id, { email }) lands in $set, not properties, so all
|
||||
* three bags must be sanitized.
|
||||
*
|
||||
* Ref: posthog.com/tutorials/web-redact-properties
|
||||
*/
|
||||
interface PostHogEventLike {
|
||||
properties?: Record<string, unknown>
|
||||
$set?: Record<string, unknown>
|
||||
$set_once?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createPostHogBeforeSend() {
|
||||
return function beforeSend<E extends PostHogEventLike>(
|
||||
event: E | null
|
||||
): E | null {
|
||||
if (!event) return null
|
||||
stripPiiKeys(event.properties)
|
||||
stripPiiKeys(event.$set)
|
||||
stripPiiKeys(event.$set_once)
|
||||
return event
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
|
||||
@@ -19,6 +19,7 @@ import subprocess
|
||||
|
||||
import av
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||
@@ -115,6 +116,15 @@ def generate_av_fixture(
|
||||
report(name)
|
||||
|
||||
|
||||
def generate_png():
|
||||
img = make_1x1_image()
|
||||
info = PngInfo()
|
||||
info.add_text('workflow', WORKFLOW_JSON)
|
||||
info.add_text('prompt', PROMPT_JSON)
|
||||
img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
|
||||
report('with_metadata.png')
|
||||
|
||||
|
||||
def generate_webp():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
@@ -167,6 +177,7 @@ def generate_webm():
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_png()
|
||||
generate_webp()
|
||||
generate_avif()
|
||||
generate_flac()
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -1,291 +0,0 @@
|
||||
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,36 +12,79 @@
|
||||
data-testid="terminal-loading-spinner"
|
||||
class="relative inset-0 z-10 flex h-full items-center justify-center"
|
||||
/>
|
||||
<BaseTerminal
|
||||
v-show="!loading && !errorMessage"
|
||||
@created="terminalCreated"
|
||||
/>
|
||||
<BaseTerminal v-show="!loading" @created="terminalCreated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import type { Ref } from 'vue'
|
||||
import { shallowRef } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { useLogsTerminal } from '@/composables/bottomPanelTabs/useLogsTerminal'
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
import BaseTerminal from './BaseTerminal.vue'
|
||||
|
||||
const terminal = shallowRef<Terminal>()
|
||||
const { errorMessage, loading } = useLogsTerminal(terminal)
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
const terminalCreated = (
|
||||
{ terminal: instance, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
{ terminal, 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 })
|
||||
terminal.value = instance
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<a
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2 px-3 py-1.5"
|
||||
@click="onItemClick($event, item)"
|
||||
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
|
||||
>
|
||||
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
@@ -21,27 +21,20 @@
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
<i
|
||||
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
|
||||
v-if="hasSubmenu || item.isColorSubmenu"
|
||||
class="icon-[lucide--chevron-right] size-4 opacity-60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<SubmenuPopover
|
||||
<!-- Color picker menu (custom with color circles) -->
|
||||
<ColorPickerMenu
|
||||
v-if="colorOption"
|
||||
ref="colorSubmenu"
|
||||
key="color-submenu"
|
||||
ref="colorPickerMenu"
|
||||
key="color-picker-menu"
|
||||
:option="colorOption"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
/>
|
||||
|
||||
<SubmenuPopover
|
||||
v-if="shapeOption"
|
||||
ref="shapeSubmenu"
|
||||
key="shape-submenu"
|
||||
:option="shapeOption"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
@submenu-click="handleColorSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -61,18 +54,16 @@ import type {
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
|
||||
interface ExtendedMenuItem extends MenuItem {
|
||||
isColorSubmenu?: boolean
|
||||
isShapeSubmenu?: boolean
|
||||
shortcut?: string
|
||||
originalOption?: MenuOption
|
||||
}
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
@@ -159,20 +150,21 @@ useEventListener(
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
// Find color picker option
|
||||
const colorOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isColorPicker)
|
||||
)
|
||||
|
||||
const shapeOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isShapePicker)
|
||||
)
|
||||
// Check if option is the color picker
|
||||
function isColorOption(option: MenuOption): boolean {
|
||||
return Boolean(option.isColorPicker)
|
||||
}
|
||||
|
||||
// Convert MenuOption to PrimeVue MenuItem
|
||||
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
if (option.type === 'divider') return { separator: true }
|
||||
|
||||
const isColor = Boolean(option.isColorPicker)
|
||||
const isShape = Boolean(option.isShapePicker)
|
||||
const usesPopover = isColor || isShape
|
||||
const isColor = isColorOption(option)
|
||||
|
||||
const item: ExtendedMenuItem = {
|
||||
label: option.label,
|
||||
@@ -180,14 +172,11 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
disabled: option.disabled,
|
||||
shortcut: option.shortcut,
|
||||
isColorSubmenu: isColor,
|
||||
isShapeSubmenu: isShape,
|
||||
originalOption: option
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Native submenus for non-color options
|
||||
if (option.hasSubmenu && option.submenu && !isColor) {
|
||||
item.items = option.submenu.map((sub) => ({
|
||||
label: sub.label,
|
||||
icon: sub.icon,
|
||||
@@ -199,6 +188,7 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
}))
|
||||
}
|
||||
|
||||
// Regular action items
|
||||
if (!option.hasSubmenu && option.action) {
|
||||
item.command = () => {
|
||||
option.action?.()
|
||||
@@ -255,30 +245,17 @@ function toggle(event: Event) {
|
||||
|
||||
defineExpose({ toggle, hide, isOpen, show })
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function openSubmenuPopover(
|
||||
event: MouseEvent,
|
||||
target: InstanceType<typeof SubmenuPopover> | undefined,
|
||||
other: InstanceType<typeof SubmenuPopover> | undefined
|
||||
) {
|
||||
if (!target) return
|
||||
function showColorPopover(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
other?.hide()
|
||||
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
const target = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
target.toggle(event, anchor)
|
||||
colorPickerMenu.value?.toggle(event, target)
|
||||
}
|
||||
|
||||
function handleSubmenuSelect(subOption: SubMenuOption) {
|
||||
// Handle color selection
|
||||
function handleColorSelect(subOption: SubMenuOption) {
|
||||
subOption.action()
|
||||
hide()
|
||||
}
|
||||
@@ -293,17 +270,11 @@ function constrainMenuHeight() {
|
||||
if (!rootList) return
|
||||
|
||||
const rect = rootList.getBoundingClientRect()
|
||||
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'
|
||||
const maxHeight = window.innerHeight - rect.top - 8
|
||||
if (maxHeight > 0) {
|
||||
rootList.style.maxHeight = `${maxHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuShow() {
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { i18n, te } from '@/i18n'
|
||||
import type * as LiteGraphModule from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import NodeTooltip from './NodeTooltip.vue'
|
||||
|
||||
type HitTest = (
|
||||
node: MockNode,
|
||||
x: number,
|
||||
y: number,
|
||||
offset: [number, number]
|
||||
) => number
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface MockNode {
|
||||
type: string
|
||||
flags: {
|
||||
collapsed?: boolean
|
||||
ghost?: boolean
|
||||
}
|
||||
pos: [number, number]
|
||||
inputs: Array<{ name: string }>
|
||||
constructor: {
|
||||
title_mode?: 0 | 1 | 2 | 3
|
||||
}
|
||||
}
|
||||
|
||||
interface MockCanvas {
|
||||
mouse: [number, number]
|
||||
graph_mouse: [number, number]
|
||||
node_over: MockNode | null
|
||||
getWidgetAtCursor: () => MockWidget | null
|
||||
}
|
||||
|
||||
const mockIsOverNodeInput = vi.hoisted(() => vi.fn<HitTest>())
|
||||
const mockIsOverNodeOutput = vi.hoisted(() => vi.fn<HitTest>())
|
||||
const mockIsDOMWidget = vi.hoisted(() =>
|
||||
vi.fn<(widget: MockWidget) => boolean>()
|
||||
)
|
||||
const mockCanvas = vi.hoisted(
|
||||
(): MockCanvas => ({
|
||||
mouse: [100, 80],
|
||||
graph_mouse: [10, 10],
|
||||
node_over: null,
|
||||
getWidgetAtCursor: vi.fn<() => MockWidget | null>()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LiteGraphModule>()
|
||||
return {
|
||||
...actual,
|
||||
isOverNodeInput: mockIsOverNodeInput,
|
||||
isOverNodeOutput: mockIsOverNodeOutput
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
isDOMWidget: mockIsDOMWidget
|
||||
}))
|
||||
|
||||
const jsonTooltip =
|
||||
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
|
||||
|
||||
const positiveCoordsTooltipKey =
|
||||
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
|
||||
|
||||
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
|
||||
|
||||
const sam3DetectNodeDef: ComfyNodeDef = {
|
||||
name: 'SAM3_Detect',
|
||||
display_name: 'SAM3 Detect',
|
||||
category: 'detection/',
|
||||
python_module: 'comfy_extras.nodes_sam3',
|
||||
description: '',
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
positive_coords: [
|
||||
'STRING',
|
||||
{
|
||||
tooltip: jsonTooltip,
|
||||
forceInput: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MASK'],
|
||||
output_name: ['masks'],
|
||||
output_tooltips: [jsonTooltip],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
|
||||
function createSam3Node(): MockNode {
|
||||
return {
|
||||
type: 'SAM3_Detect',
|
||||
flags: {},
|
||||
pos: [0, 0],
|
||||
inputs: [{ name: 'positive_coords' }],
|
||||
constructor: {}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOutputTooltipMessage(tooltip: string | null) {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
nodeDefs: {
|
||||
SAM3_Detect: {
|
||||
outputs: {
|
||||
0: {
|
||||
tooltip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function renderAndHoverCanvas() {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(NodeTooltip)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
document.body.appendChild(canvas)
|
||||
await user.hover(canvas)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('NodeTooltip', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
<K extends keyof Settings>(key: K): Settings[K] => {
|
||||
switch (key) {
|
||||
case 'LiteGraph.Node.TooltipDelay':
|
||||
return 0 as Settings[K]
|
||||
default:
|
||||
return undefined as Settings[K]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockCanvas.mouse = [100, 80]
|
||||
mockCanvas.graph_mouse = [10, 10]
|
||||
mockCanvas.node_over = createSam3Node()
|
||||
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue(null)
|
||||
vi.mocked(mockIsOverNodeInput).mockReturnValue(-1)
|
||||
vi.mocked(mockIsOverNodeOutput).mockReturnValue(-1)
|
||||
vi.mocked(mockIsDOMWidget).mockReturnValue(false)
|
||||
|
||||
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
|
||||
mergeOutputTooltipMessage(jsonTooltip)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mergeOutputTooltipMessage(null)
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows input slot JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockIsOverNodeInput).mockReturnValue(0)
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows output slot JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockIsOverNodeOutput).mockReturnValue(0)
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(outputTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows widget JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue({
|
||||
name: 'positive_coords'
|
||||
})
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { stRaw } from '@/i18n'
|
||||
import { st } from '@/i18n'
|
||||
import {
|
||||
LiteGraph,
|
||||
isOverNodeInput,
|
||||
@@ -84,7 +84,7 @@ function onIdle() {
|
||||
)
|
||||
if (inputSlot !== -1) {
|
||||
const inputName = node.inputs[inputSlot].name
|
||||
const translatedTooltip = stRaw(
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
|
||||
nodeDef?.inputs[inputName]?.tooltip ?? ''
|
||||
)
|
||||
@@ -98,7 +98,7 @@ function onIdle() {
|
||||
[0, 0]
|
||||
)
|
||||
if (outputSlot !== -1) {
|
||||
const translatedTooltip = stRaw(
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
|
||||
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
|
||||
)
|
||||
@@ -108,7 +108,7 @@ function onIdle() {
|
||||
const widget = comfyApp.canvas.getWidgetAtCursor()
|
||||
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
|
||||
if (widget && !isDOMWidget(widget)) {
|
||||
const translatedTooltip = stRaw(
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
|
||||
nodeDef?.inputs[widget.name]?.tooltip ?? ''
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fireEvent, render } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -30,26 +29,6 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
|
||||
>
|
||||
}
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
const defaultSettingValues: Record<string, unknown> = {
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': true,
|
||||
'Comfy.Load3D.3DViewerEnable': true
|
||||
}
|
||||
|
||||
function mockSettingValues(overrides: Record<string, unknown> = {}) {
|
||||
const settingValues = {
|
||||
...defaultSettingValues,
|
||||
...overrides
|
||||
}
|
||||
settingGetMock.mockImplementation(
|
||||
(key: string): unknown => settingValues[key] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
// Mock the composables and services
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: vi.fn(() => ({
|
||||
@@ -100,7 +79,10 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: settingGetMock
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Load3D.3DViewerEnable') return true
|
||||
return null
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -146,7 +128,7 @@ describe('SelectionToolbox', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
nodeDefMock = {
|
||||
type: 'TestNode',
|
||||
@@ -157,7 +139,6 @@ describe('SelectionToolbox', () => {
|
||||
canvasStore.canvas = createMockCanvas()
|
||||
|
||||
vi.resetAllMocks()
|
||||
mockSettingValues()
|
||||
})
|
||||
|
||||
function renderComponent(props = {}): { container: Element } {
|
||||
@@ -250,42 +231,6 @@ describe('SelectionToolbox', () => {
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the new node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': true
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should show info button when new menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should show color picker for all selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<DeleteButton v-if="showDelete" />
|
||||
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="showInfoButton" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
@@ -105,8 +105,9 @@ const {
|
||||
isSingleImageNode,
|
||||
hasAny3DNodeSelected,
|
||||
hasOutputNodesSelected,
|
||||
canOpenNodeInfo
|
||||
nodeDef
|
||||
} = useSelectionState()
|
||||
const showInfoButton = computed(() => !!nodeDef.value)
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'p-popover absolute z-60'
|
||||
class: 'absolute z-60'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
@@ -90,12 +90,8 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const toggle = (event: Event, target?: HTMLElement) => {
|
||||
popoverRef.value?.toggle(event, target)
|
||||
}
|
||||
const hide = () => {
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
defineExpose({
|
||||
toggle,
|
||||
hide
|
||||
toggle
|
||||
})
|
||||
|
||||
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -8,20 +9,19 @@ import { createI18n } from 'vue-i18n'
|
||||
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
|
||||
openNodeInfoMock: vi.fn(),
|
||||
trackUiButtonClickedMock: vi.fn()
|
||||
const { openPanelMock } = vi.hoisted(() => ({
|
||||
openPanelMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => ({
|
||||
openNodeInfo: openNodeInfoMock
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({
|
||||
openPanel: openPanelMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: trackUiButtonClickedMock
|
||||
trackUiButtonClicked: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
openNodeInfoMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
const renderComponent = () => {
|
||||
@@ -53,29 +53,12 @@ describe('InfoButton', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const clickNodeInfoButton = async () => {
|
||||
it('should open the info panel on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
}
|
||||
|
||||
it('should open the node info panel on click', async () => {
|
||||
renderComponent()
|
||||
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not track the click when the node info panel is unavailable', async () => {
|
||||
openNodeInfoMock.mockReturnValue(false)
|
||||
renderComponent()
|
||||
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,16 +15,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const { openNodeInfo } = useSelectionState()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
/**
|
||||
* Track node info button click and toggle node help.
|
||||
*/
|
||||
const onInfoClick = () => {
|
||||
if (!openNodeInfo()) return
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
rightSidePanelStore.openPanel('info')
|
||||
}
|
||||
</script>
|
||||
|
||||
164
src/components/helpcenter/HelpCenterMenuContent.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const commandStoreExecute = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
staticUrls: { discord: '', github: '' },
|
||||
buildDocsUrl: () => 'https://docs.comfy.org'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
trackHelpCenterOpened: vi.fn(),
|
||||
trackHelpCenterClosed: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => ({
|
||||
releases: [],
|
||||
recentReleases: [],
|
||||
isLoading: false,
|
||||
fetchReleases: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: commandStoreExecute })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => null
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => ({
|
||||
useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({ isNewManagerUI: { value: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons/PuzzleIcon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'PuzzleIconStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(HelpCenterMenuContent, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('HelpCenterMenuContent feedback item', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
commandStoreExecute.mockReset()
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Cloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to Comfy.ContactSupport on OSS builds', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,7 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
@@ -306,7 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
trackResourceClick('help_feedback', isCloud || isNightly)
|
||||
if (isCloud || isNightly) {
|
||||
window.open(
|
||||
'https://form.typeform.com/to/q7azbWPi',
|
||||
buildFeedbackTypeformUrl('help-center'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-if="rest"
|
||||
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"
|
||||
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
|
||||
>
|
||||
<span class="pr-2" v-text="rest" />
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
|
||||
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
|
||||
data-testid="node-preview-card"
|
||||
>
|
||||
<div ref="previewContainerRef" class="overflow-hidden p-3">
|
||||
<div
|
||||
|
||||
334
src/components/painter/WidgetPainter.test.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const sizeHolder = vi.hoisted(() => ({ width: 0, height: 0 }))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useElementSize: () => ({
|
||||
width: ref(sizeHolder.width),
|
||||
height: ref(sizeHolder.height)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const painterHolder = vi.hoisted(() => ({
|
||||
state: null as Record<string, unknown> | null
|
||||
}))
|
||||
|
||||
function createDefaultPainterState() {
|
||||
return {
|
||||
tool: ref('brush'),
|
||||
brushSize: ref(20),
|
||||
brushColor: ref('#000000'),
|
||||
brushOpacity: ref(1),
|
||||
brushHardness: ref(1),
|
||||
backgroundColor: ref('#ffffff'),
|
||||
canvasWidth: ref(512),
|
||||
canvasHeight: ref(512),
|
||||
cursorVisible: ref(true),
|
||||
displayBrushSize: ref(20),
|
||||
inputImageUrl: ref<string | null>(null),
|
||||
isImageInputConnected: ref(false),
|
||||
handlePointerDown: vi.fn(),
|
||||
handlePointerMove: vi.fn(),
|
||||
handlePointerUp: vi.fn(),
|
||||
handlePointerEnter: vi.fn(),
|
||||
handlePointerLeave: vi.fn(),
|
||||
handleInputImageLoad: vi.fn(),
|
||||
handleClear: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/painter/usePainter', () => ({
|
||||
PAINTER_TOOLS: { BRUSH: 'brush', ERASER: 'eraser' } as const,
|
||||
usePainter: () => {
|
||||
if (!painterHolder.state) painterHolder.state = createDefaultPainterState()
|
||||
return painterHolder.state
|
||||
}
|
||||
}))
|
||||
|
||||
import WidgetPainter from './WidgetPainter.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
painter: {
|
||||
tool: 'Tool',
|
||||
brush: 'Brush',
|
||||
eraser: 'Eraser',
|
||||
size: 'Size',
|
||||
color: 'Color',
|
||||
hardness: 'Hardness',
|
||||
width: 'Width',
|
||||
height: 'Height',
|
||||
background: 'Background',
|
||||
clear: 'Clear'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'Button',
|
||||
inheritAttrs: false,
|
||||
template: '<button v-bind="$attrs" type="button"><slot /></button>'
|
||||
})
|
||||
|
||||
const SliderStub = defineComponent({
|
||||
name: 'Slider',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
min: Number,
|
||||
max: Number,
|
||||
step: Number
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<div data-testid="slider-stub" :data-min="min" @click="$emit(\'update:modelValue\', [Number(min) + Number(step ?? 1)])" />'
|
||||
})
|
||||
|
||||
function primePainterState(overrides: Record<string, unknown> = {}) {
|
||||
painterHolder.state = { ...createDefaultPainterState(), ...overrides }
|
||||
}
|
||||
|
||||
function renderWidget(initialModel = '') {
|
||||
const value = ref(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetPainter },
|
||||
setup: () => ({ value }),
|
||||
template: '<WidgetPainter v-model="value" node-id="42" />'
|
||||
})
|
||||
return render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Button: ButtonStub, Slider: SliderStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WidgetPainter', () => {
|
||||
beforeEach(() => {
|
||||
sizeHolder.width = 0
|
||||
sizeHolder.height = 0
|
||||
painterHolder.state = null
|
||||
})
|
||||
|
||||
describe('Label visibility', () => {
|
||||
const allLabels = [
|
||||
'Tool',
|
||||
'Size',
|
||||
'Color',
|
||||
'Hardness',
|
||||
'Width',
|
||||
'Height',
|
||||
'Background'
|
||||
]
|
||||
|
||||
it('renders every label in wide layout (width >= 350)', () => {
|
||||
sizeHolder.width = 600
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('still renders every label in compact layout (width < 350)', () => {
|
||||
sizeHolder.width = 200
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps labels at the responsive boundary (width = 350)', () => {
|
||||
sizeHolder.width = 350
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image-input branch', () => {
|
||||
it('hides canvas-size and background controls when an image is connected', () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
expect(screen.queryByText('Width')).toBeNull()
|
||||
expect(screen.queryByText('Height')).toBeNull()
|
||||
expect(screen.queryByText('Background')).toBeNull()
|
||||
expect(screen.getByTestId('painter-dimension-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the input image inside the canvas container', () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
const container = screen.getByTestId('painter-canvas-container')
|
||||
expect(within(container).getByRole('img')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tool selection', () => {
|
||||
it('hides brush-only controls when the eraser tool is active', () => {
|
||||
primePainterState({ tool: ref('eraser') })
|
||||
renderWidget()
|
||||
|
||||
expect(screen.queryByText('Color')).toBeNull()
|
||||
expect(screen.queryByText('Hardness')).toBeNull()
|
||||
})
|
||||
|
||||
it('updates the active tool when clicking brush/eraser buttons', async () => {
|
||||
const tool = ref<'brush' | 'eraser'>('brush')
|
||||
primePainterState({ tool })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByText('Eraser'))
|
||||
expect(tool.value).toBe('eraser')
|
||||
|
||||
await user.click(screen.getByText('Brush'))
|
||||
expect(tool.value).toBe('brush')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas events', () => {
|
||||
it('forwards pointerdown/up to the composable on click', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByTestId('painter-canvas'))
|
||||
|
||||
const s = painterHolder.state!
|
||||
expect(s.handlePointerDown).toHaveBeenCalled()
|
||||
expect(s.handlePointerUp).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards pointerenter/leave to the composable on hover', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
const canvas = screen.getByTestId('painter-canvas')
|
||||
|
||||
await user.hover(canvas)
|
||||
await user.unhover(canvas)
|
||||
|
||||
const s = painterHolder.state!
|
||||
expect(s.handlePointerEnter).toHaveBeenCalled()
|
||||
expect(s.handlePointerLeave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes handleInputImageLoad when the input image fires load', async () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
const img = within(
|
||||
screen.getByTestId('painter-canvas-container')
|
||||
).getByRole('img')
|
||||
await fireEvent.load(img)
|
||||
expect(painterHolder.state!.handleInputImageLoad).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Control bindings', () => {
|
||||
it('invokes handleClear when the clear button is clicked', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByTestId('painter-clear-button'))
|
||||
expect(painterHolder.state!.handleClear).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates brushSize via the size slider', async () => {
|
||||
const brushSize = ref(20)
|
||||
primePainterState({ brushSize })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const slider = within(screen.getByTestId('painter-size-row')).getByTestId(
|
||||
'slider-stub'
|
||||
)
|
||||
await user.click(slider)
|
||||
expect(brushSize.value).toBe(2) // min=1, step=1 -> emits 2
|
||||
})
|
||||
|
||||
it('updates brushColor via the color picker', async () => {
|
||||
const brushColor = ref('#000000')
|
||||
primePainterState({ brushColor })
|
||||
renderWidget()
|
||||
|
||||
const colorInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('#000000')
|
||||
// <input type="color"> has no userEvent equivalent — fire input directly
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.input(colorInput, { target: { value: '#ff0000' } })
|
||||
expect(brushColor.value.toLowerCase()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('updates brushOpacity via the percent input', async () => {
|
||||
const brushOpacity = ref(1)
|
||||
primePainterState({ brushOpacity })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const percentInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('100')
|
||||
await user.clear(percentInput)
|
||||
await user.type(percentInput, '50')
|
||||
await user.tab() // blur to trigger @change
|
||||
expect(brushOpacity.value).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
it('clamps opacity input to the 0-100 range', async () => {
|
||||
const brushOpacity = ref(1)
|
||||
primePainterState({ brushOpacity })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const percentInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('100')
|
||||
await user.clear(percentInput)
|
||||
await user.type(percentInput, '999')
|
||||
await user.tab()
|
||||
expect(brushOpacity.value).toBe(1) // clamped to 100% -> 1.0
|
||||
})
|
||||
|
||||
it('updates background color via the bg color input', async () => {
|
||||
const backgroundColor = ref('#ffffff')
|
||||
primePainterState({ backgroundColor })
|
||||
renderWidget()
|
||||
|
||||
const bgInput = within(
|
||||
screen.getByTestId('painter-bg-color-row')
|
||||
).getByDisplayValue('#ffffff')
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.input(bgInput, { target: { value: '#00ff00' } })
|
||||
expect(backgroundColor.value.toLowerCase()).toBe('#00ff00')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
/>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
data-testid="painter-canvas"
|
||||
class="absolute inset-0 size-full cursor-none touch-none"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@@ -58,7 +59,6 @@
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.tool') }}
|
||||
@@ -99,7 +99,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.size') }}
|
||||
@@ -126,7 +125,6 @@
|
||||
|
||||
<template v-if="tool === PAINTER_TOOLS.BRUSH">
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.color') }}
|
||||
@@ -170,7 +168,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.hardness') }}
|
||||
@@ -199,7 +196,6 @@
|
||||
|
||||
<template v-if="!isImageInputConnected">
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.width') }}
|
||||
@@ -222,7 +218,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.height') }}
|
||||
@@ -245,7 +240,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.background') }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
|
||||
@@ -21,13 +21,19 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMouseInElement: () => ({
|
||||
elementX: ref(50),
|
||||
elementWidth: ref(100),
|
||||
isOutside: ref(false)
|
||||
})
|
||||
}))
|
||||
const mockRect = (el: HTMLElement, width: number) => {
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: 100,
|
||||
width,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
describe('CompareSliderThumbnail', () => {
|
||||
const renderThumbnail = (props = {}) => {
|
||||
@@ -74,4 +80,44 @@ describe('CompareSliderThumbnail', () => {
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('50%')
|
||||
})
|
||||
|
||||
it('updates slider position on mousemove', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 200)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.pointer({ target: container, coords: { clientX: 50 } })
|
||||
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('25%')
|
||||
})
|
||||
|
||||
it('clamps slider position to [0, 100] when pointer overshoots', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 200)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.pointer({ target: container, coords: { clientX: -10 } })
|
||||
let divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('0%')
|
||||
|
||||
await user.pointer({ target: container, coords: { clientX: 250 } })
|
||||
divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('100%')
|
||||
})
|
||||
|
||||
it('ignores mousemove when container has zero width', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 0)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.pointer({ target: container, coords: { clientX: 50 } })
|
||||
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('50%')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<div ref="containerRef" class="absolute inset-0">
|
||||
<div
|
||||
data-testid="compare-slider-container"
|
||||
class="absolute inset-0"
|
||||
@mousemove="updateSliderPosition"
|
||||
>
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
@@ -34,8 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
@@ -57,18 +60,20 @@ const isVideoType =
|
||||
false
|
||||
|
||||
const sliderPosition = ref(SLIDER_START_POSITION)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
|
||||
|
||||
// Update slider position based on mouse position when hovered
|
||||
watch(
|
||||
[() => isHovered, elementX, elementWidth, isOutside],
|
||||
([isHovered, x, width, outside]) => {
|
||||
if (!isHovered) return
|
||||
if (!outside) {
|
||||
sliderPosition.value = (x / width) * 100
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* Update slider position from a local mousemove. Scoped to currentTarget so
|
||||
* only the hovered card reads its rect — unlike useMouseInElement which
|
||||
* attaches a global mousemove listener and fires for every mounted instance.
|
||||
*/
|
||||
function updateSliderPosition(event: MouseEvent) {
|
||||
const el = event.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width === 0) return
|
||||
// Clamp to [0, 100] — subpixel rounding or stale rects on hover-in can
|
||||
// push the raw percentage slightly out of range, which would offset the
|
||||
// divider past the container or invert the overlay's clipPath.
|
||||
const raw = ((event.clientX - rect.left) / rect.width) * 100
|
||||
sliderPosition.value = Math.max(0, Math.min(100, raw))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -252,20 +252,6 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('Log Out')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('credits help icon (FE-617)', () => {
|
||||
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
|
||||
renderComponent()
|
||||
|
||||
const helpButton = screen.getByTestId('credits-info-button')
|
||||
expect(helpButton).toBeInTheDocument()
|
||||
expect(helpButton.tagName).toBe('BUTTON')
|
||||
expect(helpButton).toHaveAttribute(
|
||||
'aria-label',
|
||||
enMessages.credits.unified.tooltip
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
|
||||
|
||||
@@ -41,16 +41,10 @@
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<Button
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="mr-auto"
|
||||
:aria-label="$t('credits.unified.tooltip')"
|
||||
data-testid="credits-info-button"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help]" />
|
||||
</Button>
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
|
||||
186
src/components/topbar/WorkflowTabs.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import WorkflowTabs from './WorkflowTabs.vue'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: { value: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
isOverflowing: { value: false },
|
||||
disposed: { value: false },
|
||||
checkOverflow: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
openWorkflow: vi.fn(),
|
||||
closeWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () =>
|
||||
reactive({
|
||||
openWorkflows: [],
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mouseDownUtil', () => ({
|
||||
whileMouseDown: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowOverflowMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'WorkflowOverflowMenuStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowTab.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'WorkflowTabStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./CurrentUserButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CurrentUserButtonStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./LoginButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'LoginButtonStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(WorkflowTabs, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
tooltip: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('WorkflowTabs feedback button', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
tabBarLayout.value = 'Default'
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the feedback button when the legacy tab bar is active', () => {
|
||||
distribution.isCloud = true
|
||||
tabBarLayout.value = 'Legacy'
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -119,7 +119,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -152,9 +152,12 @@ const isIntegratedTabBar = computed(
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
function openFeedback() {
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('topbar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -21,12 +21,6 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Connection error clearing via onConnectionsChange', () => {
|
||||
beforeEach(() => {
|
||||
@@ -211,47 +205,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing media when an upload emits onWidgetChanged', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
node.type = 'LoadImage'
|
||||
const widget = node.addWidget(
|
||||
'combo',
|
||||
'image',
|
||||
'missing.png',
|
||||
() => undefined,
|
||||
{ values: [] }
|
||||
)
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const mediaStore = useMissingMediaStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
|
||||
mediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'missing.png',
|
||||
isMissing: true
|
||||
} satisfies MissingMediaCandidate
|
||||
])
|
||||
|
||||
node.onWidgetChanged!.call(
|
||||
node,
|
||||
'image',
|
||||
'uploaded.png',
|
||||
'missing.png',
|
||||
widget
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(mediaStore.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('uses interior node execution ID for promoted widget error clearing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
@@ -394,90 +347,6 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
||||
})
|
||||
|
||||
it('scans added-node missing models after widget values are restored', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.type = 'CheckpointLoaderSimple'
|
||||
const widget = node.addWidget('combo', 'ckpt_name', '', () => undefined, {
|
||||
values: []
|
||||
})
|
||||
|
||||
graph.add(node)
|
||||
widget.value = 'fake_model.safetensors'
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
||||
expect.objectContaining({ name: 'fake_model.safetensors' })
|
||||
])
|
||||
})
|
||||
|
||||
it('scans added-node missing models before the deferred media scan', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi
|
||||
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
.mockImplementation((_rootGraph, node) => [
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: node.type,
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake_model.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
const mediaScan = vi
|
||||
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
.mockReturnValue([])
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.type = 'CheckpointLoaderSimple'
|
||||
graph.add(node)
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).toHaveBeenCalledOnce()
|
||||
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
||||
expect.objectContaining({ name: 'fake_model.safetensors' })
|
||||
])
|
||||
expect(mediaScan).not.toHaveBeenCalled()
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mediaScan).toHaveBeenCalledTimes(1)
|
||||
expect(modelScan.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mediaScan.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not surface added-node missing media when upload state is marked between deferred scans', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('LoadVideo')
|
||||
node.type = 'LoadVideo'
|
||||
node.addWidget('combo', 'file', 'uploading.mp4', () => undefined, {
|
||||
values: []
|
||||
})
|
||||
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
node.isUploading = true
|
||||
await Promise.resolve()
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
expect(mediaScan).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
@@ -674,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
@@ -742,6 +611,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
|
||||
describe('realtime verification staleness guards', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
@@ -816,7 +686,7 @@ describe('realtime verification staleness guards', () => {
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
@@ -901,6 +771,7 @@ describe('realtime verification staleness guards', () => {
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import {
|
||||
scanNodeMediaCandidates,
|
||||
verifyMediaCandidates
|
||||
verifyCloudMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
@@ -155,26 +155,25 @@ function isNodeInactive(mode: number): boolean {
|
||||
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
|
||||
}
|
||||
|
||||
function scanNodeErrorTargets(
|
||||
node: LGraphNode,
|
||||
scanNode: (node: LGraphNode) => void
|
||||
): void {
|
||||
/** Scan a single node and add confirmed missing model/media to stores.
|
||||
* For subgraph containers, also scans all active interior nodes. */
|
||||
function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
for (const innerNode of collectAllNodes(node.subgraph)) {
|
||||
if (innerNode.isSubgraphNode?.()) continue
|
||||
if (isNodeInactive(innerNode.mode)) continue
|
||||
scanNode(innerNode)
|
||||
scanSingleNodeErrors(innerNode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scanNode(node)
|
||||
scanSingleNodeErrors(node)
|
||||
}
|
||||
|
||||
function getActiveExecutionId(node: LGraphNode): string | null {
|
||||
if (!app.rootGraph) return null
|
||||
function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
// Skip when any enclosing subgraph is muted/bypassed. Callers only
|
||||
// verify each node's own mode; entering a bypassed subgraph (via
|
||||
// useGraphNodeManager replaying onNodeAdded for existing interior
|
||||
@@ -182,25 +181,7 @@ function getActiveExecutionId(node: LGraphNode): string | null {
|
||||
// execId means the node has no current graph (e.g. detached mid
|
||||
// lifecycle) — also skip, since we cannot verify its scope.
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return null
|
||||
return execId
|
||||
}
|
||||
|
||||
/** Scan a single node and add confirmed missing model/media to stores.
|
||||
* For subgraph containers, also scans all active interior nodes. */
|
||||
function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
scanNodeErrorTargets(node, scanSingleNodeErrors)
|
||||
}
|
||||
|
||||
function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
scanSingleNodeModelsAndTypes(node)
|
||||
scanSingleNodeMedia(node)
|
||||
}
|
||||
|
||||
function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
const execId = getActiveExecutionId(node)
|
||||
if (!execId) return
|
||||
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
|
||||
|
||||
const modelCandidates = scanNodeModelCandidates(
|
||||
app.rootGraph,
|
||||
@@ -223,40 +204,39 @@ function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
|
||||
void verifyAndAddPendingModels(pendingModels)
|
||||
}
|
||||
|
||||
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
|
||||
if (!(originalType in LiteGraph.registered_node_types)) {
|
||||
const nodeReplacementStore = useNodeReplacementStore()
|
||||
const replacement = nodeReplacementStore.getReplacementFor(originalType)
|
||||
const store = useMissingNodesErrorStore()
|
||||
const existing = store.missingNodesError?.nodeTypes ?? []
|
||||
store.surfaceMissingNodes([
|
||||
...existing,
|
||||
{
|
||||
type: originalType,
|
||||
nodeId: execId,
|
||||
cnrId: getCnrIdFromNode(node),
|
||||
isReplaceable: replacement !== null,
|
||||
replacement: replacement ?? undefined
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function scanSingleNodeMedia(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
if (!getActiveExecutionId(node)) return
|
||||
|
||||
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
|
||||
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
|
||||
if (confirmedMedia.length) {
|
||||
useMissingMediaStore().addMissingMedia(confirmedMedia)
|
||||
}
|
||||
// Cloud media scans return pending for asset verification. OSS scans only
|
||||
// return pending for generated output media.
|
||||
// Cloud media scans always return isMissing: undefined pending
|
||||
// verification against the input-assets list.
|
||||
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
|
||||
if (pendingMedia.length) {
|
||||
void verifyAndAddPendingMedia(pendingMedia)
|
||||
}
|
||||
|
||||
// Check for missing node type
|
||||
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
|
||||
if (!(originalType in LiteGraph.registered_node_types)) {
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (execId) {
|
||||
const nodeReplacementStore = useNodeReplacementStore()
|
||||
const replacement = nodeReplacementStore.getReplacementFor(originalType)
|
||||
const store = useMissingNodesErrorStore()
|
||||
const existing = store.missingNodesError?.nodeTypes ?? []
|
||||
store.surfaceMissingNodes([
|
||||
...existing,
|
||||
{
|
||||
type: originalType,
|
||||
nodeId: execId,
|
||||
cnrId: getCnrIdFromNode(node),
|
||||
isReplaceable: replacement !== null,
|
||||
replacement: replacement ?? undefined
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,7 +282,7 @@ async function verifyAndAddPendingMedia(
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyMediaCandidates(pending, { isCloud })
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
@@ -313,23 +293,10 @@ async function verifyAndAddPendingMedia(
|
||||
}
|
||||
}
|
||||
|
||||
function scanAddedNode(
|
||||
node: LGraphNode,
|
||||
scanNode: (node: LGraphNode) => void
|
||||
): void {
|
||||
function scanAddedNode(node: LGraphNode): void {
|
||||
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
|
||||
if (isNodeInactive(node.mode)) return
|
||||
scanNodeErrorTargets(node, scanNode)
|
||||
}
|
||||
|
||||
function scheduleAddedNodeScan(node: LGraphNode): void {
|
||||
queueMicrotask(() => {
|
||||
scanAddedNode(node, scanSingleNodeModelsAndTypes)
|
||||
// Paste/drop upload handlers run immediately after graph.add and must set
|
||||
// node.isUploading synchronously before their first await. This second
|
||||
// microtask lets that upload state settle before media widgets are scanned.
|
||||
queueMicrotask(() => scanAddedNode(node, scanSingleNodeMedia))
|
||||
})
|
||||
scanAndAddNodeErrors(node)
|
||||
}
|
||||
|
||||
function handleNodeModeChange(
|
||||
@@ -401,12 +368,10 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
// Scan pasted/duplicated nodes for missing models/media.
|
||||
// Skip during loadGraphData (undo/redo/tab switch) — those are
|
||||
// handled by the full pipeline or cache restore.
|
||||
// Model and node scans use the original one-microtask deferral so pasted
|
||||
// missing-model errors appear before selection-scoped tabs recalculate.
|
||||
// Media gets one extra microtask so drag/drop upload handlers can mark
|
||||
// transient upload state before media detection reads the widget value.
|
||||
// Deferred to microtask because onNodeAdded fires before
|
||||
// node.configure() restores widget values.
|
||||
if (!ChangeTracker.isLoadingGraph) {
|
||||
scheduleAddedNodeScan(node)
|
||||
queueMicrotask(() => scanAddedNode(node))
|
||||
}
|
||||
|
||||
originalOnNodeAdded?.call(this, node)
|
||||
|
||||
@@ -48,8 +48,6 @@ 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.
|
||||
@@ -109,7 +107,7 @@ export interface VueNodeData {
|
||||
title: string
|
||||
type: string
|
||||
apiNode?: boolean
|
||||
badges?: Badges
|
||||
badges?: (LGraphBadge | (() => LGraphBadge))[]
|
||||
bgcolor?: string
|
||||
color?: string
|
||||
flags?: {
|
||||
@@ -788,12 +786,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
showAdvanced: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'badges':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
badges: propertyEvent.newValue as Badges
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface MenuOption {
|
||||
disabled?: boolean
|
||||
source?: 'litegraph' | 'vue'
|
||||
isColorPicker?: boolean
|
||||
isShapePicker?: boolean
|
||||
}
|
||||
|
||||
export interface SubMenuOption {
|
||||
@@ -125,8 +124,8 @@ export function useMoreOptionsMenu() {
|
||||
const {
|
||||
selectedItems,
|
||||
selectedNodes,
|
||||
canOpenNodeInfo,
|
||||
openNodeInfo,
|
||||
nodeDef,
|
||||
showNodeHelp,
|
||||
hasSubgraphs: hasSubgraphsComputed,
|
||||
hasImageNode,
|
||||
hasOutputNodesSelected,
|
||||
@@ -244,8 +243,8 @@ export function useMoreOptionsMenu() {
|
||||
options.push({ type: 'divider' })
|
||||
|
||||
// Section 4: Node properties (Node Info, Shape, Color)
|
||||
if (canOpenNodeInfo.value) {
|
||||
options.push(getNodeInfoOption(openNodeInfo))
|
||||
if (nodeDef.value) {
|
||||
options.push(getNodeInfoOption(showNodeHelp))
|
||||
}
|
||||
if (groupContext) {
|
||||
options.push(getGroupColorOptions(groupContext, bump))
|
||||
|
||||
@@ -66,7 +66,6 @@ export function useNodeMenuOptions() {
|
||||
icon: 'icon-[lucide--box]',
|
||||
hasSubmenu: true,
|
||||
submenu: shapeSubmenu.value,
|
||||
isShapePicker: true,
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
@@ -112,10 +111,10 @@ export function useNodeMenuOptions() {
|
||||
action: runBranch
|
||||
})
|
||||
|
||||
const getNodeInfoOption = (openNodeInfo: () => boolean): MenuOption => ({
|
||||
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
|
||||
label: t('contextMenu.Node Info'),
|
||||
icon: 'icon-[lucide--info]',
|
||||
action: openNodeInfo
|
||||
action: showNodeHelp
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,11 +3,9 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
@@ -15,6 +13,11 @@ import {
|
||||
createMockPositionable
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Mock composables
|
||||
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
|
||||
useNodeLibrarySidebarTab: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn()
|
||||
@@ -36,45 +39,6 @@ const mockConnection = {
|
||||
isNode: false
|
||||
}
|
||||
|
||||
function createMockNodeDef() {
|
||||
return new ComfyNodeDefImpl({
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
category: 'test',
|
||||
input: {},
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
description: ''
|
||||
})
|
||||
}
|
||||
|
||||
function selectSingleNodeWithNodeDef(id: number) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
canvasStore.$state.selectedItems = [
|
||||
createMockLGraphNode({ id, type: 'TestNode' })
|
||||
]
|
||||
vi.mocked(nodeDefStore.fromLGraphNode).mockReturnValue(createMockNodeDef())
|
||||
}
|
||||
|
||||
function mockSettingValues(overrides: Record<string, unknown> = {}) {
|
||||
const settingStore = useSettingStore()
|
||||
const settingValues: Record<string, unknown> = {
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': true,
|
||||
'Comfy.Load3D.3DViewerEnable': false,
|
||||
...overrides
|
||||
}
|
||||
|
||||
vi.mocked(settingStore.get).mockImplementation(
|
||||
(key: string): unknown => settingValues[key]
|
||||
)
|
||||
}
|
||||
|
||||
describe('useSelectionState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -85,7 +49,14 @@ describe('useSelectionState', () => {
|
||||
createSpy: vi.fn
|
||||
})
|
||||
)
|
||||
mockSettingValues()
|
||||
|
||||
// Setup mock composables
|
||||
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
|
||||
id: 'node-library-tab',
|
||||
title: 'Node Library',
|
||||
type: 'custom',
|
||||
render: () => null
|
||||
} as ReturnType<typeof useNodeLibrarySidebarTab>)
|
||||
|
||||
// Setup mock utility functions
|
||||
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
|
||||
@@ -216,83 +187,4 @@ describe('useSelectionState', () => {
|
||||
expect(newIsPinned).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Info', () => {
|
||||
test('should open the right side info panel for a selected node', () => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
selectSingleNodeWithNodeDef(8)
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(true)
|
||||
openNodeInfo()
|
||||
|
||||
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
|
||||
})
|
||||
|
||||
test('should not open the right side panel for multiple selected nodes', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.$state.selectedItems = [
|
||||
createMockLGraphNode({ id: 9, type: 'TestNode' }),
|
||||
createMockLGraphNode({ id: 10, type: 'TestNode' })
|
||||
]
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(false)
|
||||
openNodeInfo()
|
||||
|
||||
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should open the right side info panel when new menu uses the legacy node library', () => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
selectSingleNodeWithNodeDef(11)
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(true)
|
||||
|
||||
const didOpen = openNodeInfo()
|
||||
|
||||
expect(didOpen).toBe(true)
|
||||
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
|
||||
})
|
||||
|
||||
test('should not open node info when legacy menu uses the new node library', () => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': true
|
||||
})
|
||||
selectSingleNodeWithNodeDef(12)
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(false)
|
||||
|
||||
const didOpen = openNodeInfo()
|
||||
|
||||
expect(didOpen).toBe(false)
|
||||
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should not open node info when legacy menu uses the legacy node library', () => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
selectSingleNodeWithNodeDef(13)
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(false)
|
||||
|
||||
const didOpen = openNodeInfo()
|
||||
|
||||
expect(didOpen).toBe(false)
|
||||
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
@@ -23,8 +25,9 @@ export interface NodeSelectionState {
|
||||
export function useSelectionState() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
|
||||
@@ -61,7 +64,7 @@ export function useSelectionState() {
|
||||
)
|
||||
|
||||
const hasAny3DNodeSelected = computed(() => {
|
||||
const enable3DViewer = settingStore.get('Comfy.Load3D.3DViewerEnable')
|
||||
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
||||
return (
|
||||
selectedNodes.value.length === 1 &&
|
||||
selectedNodes.value.some(isLoad3dNode) &&
|
||||
@@ -95,24 +98,34 @@ export function useSelectionState() {
|
||||
const computeSelectionFlags = (): NodeSelectionState =>
|
||||
computeSelectionStatesFromNodes(selectedNodes.value)
|
||||
|
||||
const canOpenNodeInfo = computed(
|
||||
() =>
|
||||
Boolean(nodeDef.value) &&
|
||||
settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
/** Toggle node help sidebar/panel for the single selected node (if any). */
|
||||
const showNodeHelp = () => {
|
||||
const def = nodeDef.value
|
||||
if (!def) return
|
||||
|
||||
const openNodeInfo = () => {
|
||||
if (!canOpenNodeInfo.value) return false
|
||||
rightSidePanelStore.openPanel('info')
|
||||
return true
|
||||
const isSidebarActive =
|
||||
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
|
||||
const currentHelpNode = nodeHelpStore.currentHelpNode
|
||||
const isSameNodeHelpOpen =
|
||||
isSidebarActive &&
|
||||
nodeHelpStore.isHelpOpen &&
|
||||
currentHelpNode?.nodePath === def.nodePath
|
||||
|
||||
if (isSameNodeHelpOpen) {
|
||||
nodeHelpStore.closeHelp()
|
||||
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
nodeHelpStore.openHelp(def)
|
||||
}
|
||||
|
||||
return {
|
||||
selectedItems,
|
||||
selectedNodes,
|
||||
nodeDef,
|
||||
canOpenNodeInfo,
|
||||
openNodeInfo,
|
||||
showNodeHelp,
|
||||
hasAny3DNodeSelected,
|
||||
hasAnySelection,
|
||||
hasSingleSelection,
|
||||
|
||||
@@ -54,8 +54,8 @@ function createMockNode(): LGraphNode {
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(name = 'test.png', type = 'image/png'): File {
|
||||
return new File(['data'], name, { type })
|
||||
function createFile(name = 'test.png'): File {
|
||||
return new File(['data'], name, { type: 'image/png' })
|
||||
}
|
||||
|
||||
function successResponse(name: string, subfolder?: string) {
|
||||
@@ -95,21 +95,15 @@ describe('useNodeImageUpload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ mediaType: 'image', filename: 'test.png', mimeType: 'image/png' },
|
||||
{ mediaType: 'video', filename: 'clip.mp4', mimeType: 'video/mp4' }
|
||||
])(
|
||||
'sets isUploading true during $mediaType upload and false after',
|
||||
async ({ filename, mimeType }) => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse(filename))
|
||||
it('sets isUploading true during upload and false after', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
|
||||
const promise = capturedDragOnDrop([createFile(filename, mimeType)])
|
||||
expect(node.isUploading).toBe(true)
|
||||
const promise = capturedDragOnDrop([createFile()])
|
||||
expect(node.isUploading).toBe(true)
|
||||
|
||||
await promise
|
||||
expect(node.isUploading).toBe(false)
|
||||
}
|
||||
)
|
||||
await promise
|
||||
expect(node.isUploading).toBe(false)
|
||||
})
|
||||
|
||||
it('clears node.imgs on upload start', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
|
||||
@@ -625,9 +625,9 @@ describe('useNodePricing', () => {
|
||||
getNodeDisplayPrice(node)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
// VueNodes path bumps per-node ref and the global tick.
|
||||
// VueNodes path bumps per-node ref instead of the global tick.
|
||||
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
|
||||
expect(pricingRevision.value).toBeGreaterThan(tickBefore)
|
||||
expect(pricingRevision.value).toBe(tickBefore)
|
||||
} finally {
|
||||
LiteGraph.vueNodesMode = false
|
||||
}
|
||||
|
||||
@@ -509,8 +509,10 @@ 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,15 +18,6 @@ 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,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -108,8 +107,7 @@ export function useFeatureFlags() {
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
|
||||
83
src/extensions/core/cloudFeedbackTopbarButton.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
const registerExtension = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
describe('cloudFeedbackTopbarButton', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
registerExtension.mockReset()
|
||||
distribution.isCloud = false
|
||||
distribution.isNightly = false
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
function getRegisteredButtons(): ActionBarButton[] {
|
||||
expect(registerExtension).toHaveBeenCalledTimes(1)
|
||||
const extension = registerExtension.mock.calls[0]?.[0] as {
|
||||
actionBarButtons: ActionBarButton[]
|
||||
}
|
||||
return extension.actionBarButtons
|
||||
}
|
||||
|
||||
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
|
||||
tabBarLayout.value = 'Legacy'
|
||||
distribution.isCloud = true
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
const buttons = getRegisteredButtons()
|
||||
expect(buttons).toHaveLength(1)
|
||||
buttons[0].onClick?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1)
|
||||
const [url, target, features] = openSpy.mock.calls[0]
|
||||
expect(url).toBe(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
|
||||
)
|
||||
expect(target).toBe('_blank')
|
||||
expect(features).toBe('noopener,noreferrer')
|
||||
})
|
||||
|
||||
it('only registers the action bar button when the tab bar is Legacy', async () => {
|
||||
tabBarLayout.value = 'Default'
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
expect(getRegisteredButtons()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,20 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const TYPEFORM_SURVEY_URL = 'https://form.typeform.com/to/q7azbWPi'
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
window.open(TYPEFORM_SURVEY_URL, '_blank', 'noopener,noreferrer')
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('action-bar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { clearOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
||||
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
@@ -20,7 +19,6 @@ useExtensionService().registerExtension({
|
||||
},
|
||||
|
||||
onAuthUserLogout: async () => {
|
||||
clearOAuthRequestId()
|
||||
const { deleteSession } = useSessionCookie()
|
||||
await deleteSession()
|
||||
}
|
||||
|
||||
487
src/extensions/core/load3d.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const {
|
||||
registerExtensionMock,
|
||||
waitForLoad3dMock,
|
||||
configureMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension: registerExtensionMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({
|
||||
getLoad3d: getLoad3dMock,
|
||||
handleViewerClose: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock }),
|
||||
nodeToLoad3dMap: new Map()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configure = configureMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
|
||||
createExportMenuItems: vi.fn(() => [{ content: 'Export' }])
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn((p: string) => ['', p]),
|
||||
getResourceURL: vi.fn(() => '/view'),
|
||||
uploadFile: vi.fn(),
|
||||
uploadMultipleFiles: vi.fn(),
|
||||
uploadTempImage: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/constants', () => ({
|
||||
SUPPORTED_EXTENSIONS_ACCEPT: '.glb,.gltf'
|
||||
}))
|
||||
|
||||
vi.mock('@/components/load3d/Load3D.vue', () => ({ default: {} }))
|
||||
vi.mock('@/components/load3d/Load3dViewerContent.vue', () => ({ default: {} }))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn(),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { apiURL: (p: string) => p }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: {} } },
|
||||
ComfyApp: { copyToClipspace: vi.fn(), clipspace_return_node: null }
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: toastAddAlertMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLoad3dNode: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: { ContextMenu: vi.fn() }
|
||||
}))
|
||||
|
||||
type ExtCreated = ComfyExtension & {
|
||||
nodeCreated: (node: LGraphNode) => Promise<void>
|
||||
beforeRegisterNodeDef: (
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) => Promise<void>
|
||||
getNodeMenuItems: (node: LGraphNode) => unknown[]
|
||||
}
|
||||
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
serializeValue?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
function makePreview3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3D' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'model_file', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLoad3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
|
||||
size: [300, 600],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
setCameraFromMatrices: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
function makeLoad3dMock(): FakeLoad3d {
|
||||
return {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
setCameraFromMatrices: vi.fn(),
|
||||
setBackgroundImage: vi.fn(),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
}
|
||||
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3D.beforeRegisterNodeDef', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('rewrites the image input spec for Preview3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const nodeData = {
|
||||
name: 'Preview3D',
|
||||
input: { required: { image: ['STRING', {}] } }
|
||||
} as unknown as ComfyNodeDef
|
||||
|
||||
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(nodeData.input!.required!.image).toEqual(['PREVIEW_3D'])
|
||||
})
|
||||
|
||||
it('leaves non-Preview3D node defs unchanged', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const nodeData = {
|
||||
name: 'Load3D',
|
||||
input: { required: { image: ['STRING', {}] } }
|
||||
} as unknown as ComfyNodeDef
|
||||
|
||||
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(nodeData.input!.required!.image).toEqual(['STRING', {}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3D.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Preview3D', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not configure on creation when no Last Time Model File is persisted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores via configure with persisted cameraState when Last Time Model File is set', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const cameraState = { position: [1, 2, 3] }
|
||||
const node = makePreview3DNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'prev/model.glb',
|
||||
'Camera Config': { cameraType: 'perspective', state: cameraState }
|
||||
}
|
||||
})
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'output',
|
||||
modelWidget: expect.objectContaining({ value: 'prev/model.glb' }),
|
||||
cameraState,
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
it('persists Last Time Model File and normalizes backslashes after onExecuted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
|
||||
expect(configureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
loadFolder: 'output',
|
||||
silentOnNotFound: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards bgImagePath to load3d.setBackgroundImage on execute', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', undefined, 'bg.png'] })
|
||||
|
||||
expect(load3d.setBackgroundImage).toHaveBeenCalledWith('bg.png')
|
||||
})
|
||||
|
||||
it('applies camera matrices when load3d generation is unchanged', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const extrinsics = [
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
[0, 0, 0, 1]
|
||||
]
|
||||
const intrinsics = [
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, 1]
|
||||
]
|
||||
|
||||
const node = makePreview3DNode()
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, undefined, extrinsics, intrinsics]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraFromMatrices).toHaveBeenCalledWith(
|
||||
extrinsics,
|
||||
intrinsics
|
||||
)
|
||||
})
|
||||
|
||||
it('skips camera matrix application when load3d generation changes before whenLoadIdle resolves', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
let resolveIdle: () => void = () => {}
|
||||
load3d.whenLoadIdle = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveIdle = resolve
|
||||
})
|
||||
)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const node = makePreview3DNode()
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, undefined, [[1]], [[1]]]
|
||||
})
|
||||
|
||||
load3d.currentLoadGeneration = 6
|
||||
resolveIdle()
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraFromMatrices).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error toast when onExecuted has no file path', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Load3D', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('configures with the input folder and width/height widgets', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: 'model.glb' },
|
||||
{ name: 'width', value: 1024 },
|
||||
{ name: 'height', value: 768 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'input',
|
||||
modelWidget: widgets[0],
|
||||
cameraState: undefined,
|
||||
width: widgets[1],
|
||||
height: widgets[2]
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches a serializeValue function to the scene widget', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(typeof widgets[3].serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('skips configure when required widgets are missing', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({
|
||||
widgets: [{ name: 'model_file', value: '' }]
|
||||
})
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('Comfy.Load3D returns [] for non-Load3D nodes', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(load3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('Comfy.Preview3D returns [] for non-Preview3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when no load3d instance exists for the node', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue(null)
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns export menu items for non-splat 3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([{ content: 'Export' }])
|
||||
})
|
||||
})
|
||||