Compare commits

..

4 Commits

Author SHA1 Message Date
github-actions
2601ee307d [automated] Update test expectations 2026-05-07 08:58:39 +00:00
Kelly Yang
acc68e478a fix: use processSelect to trigger onSelectionChange after node placement
canvas.select() only sets selectionChanged flag but never calls
onSelectionChange or setDirty, so Vue reactive state and the canvas
render were never updated. processSelect() completes all three steps
in the correct order.
2026-05-07 01:45:19 -07:00
Kelly Yang
a11a2841ed fix: defer node selection to nextTick to outlast processMouseUp deselectAll
The click-to-place flow fires pointerup on the canvas after endDrag
places the node. processMouseDown had already recorded the position as
empty canvas and set pointer.onClick to processSelect(null), which calls
deselectAll() in processMouseUp - overwriting any selection we set
synchronously in addNodeOnGraph.

Deferring the select/deselectAll to the next microtask (nextTick) ensures
it runs after processMouseUp completes, so the newly placed node ends up
selected regardless of how the canvas event cycle resolved.
2026-05-07 01:20:40 -07:00
Kelly Yang
f71fb2e9dd fix: select node after placing it on graph from node library
After placement via click or drag from the node library sidebar, the
node was not entered into canvas.selected_nodes. The ghost path already
handled this via startGhostPlacement, but the direct-add path had no
equivalent selection call.

Call canvas.deselectAll() + canvas.select(node) after graph.add() for
all non-ghost placements so the behavior is consistent regardless of
how a node is added.
2026-05-07 01:04:01 -07:00
64 changed files with 347 additions and 3427 deletions

View File

@@ -20,8 +20,6 @@ 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
@@ -39,33 +37,31 @@ 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)
@@ -86,7 +82,7 @@ jobs:
done
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -95,7 +91,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -104,7 +100,6 @@ 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."
@@ -119,7 +114,6 @@ 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
@@ -128,9 +122,7 @@ jobs:
deploy:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 100 }
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {

View File

@@ -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.webm'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
},
{
label: t('useCase.advertising', locale),

View File

@@ -217,20 +217,13 @@ export class VueNodeHelpers {
}
}
/**
* Locator for the Enter Subgraph footer button.
*/
getSubgraphEnterButton(nodeId?: string): Locator {
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
}
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const editButton = this.getSubgraphEnterButton(nodeId)
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.

View File

@@ -215,12 +215,11 @@ export class AssetHelper {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags, excludeTags)
let filtered = this.getFilteredAssets(includeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
@@ -297,29 +296,15 @@ export class AssetHelper {
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(
includeTags: string[],
excludeTags: string[]
): Asset[] {
private getFilteredAssets(tags: string[]): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
)
}
}
function parseAssetTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]

View File

@@ -1,5 +1,4 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -14,7 +13,6 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -24,14 +22,13 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !filePath && !url)
throw new Error('Must provide fileName, filePath, or url')
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
const evaluateParams: {
dropPosition: Position
@@ -42,22 +39,12 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
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 }
)
}
if (fileName) {
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
evaluateParams.fileName = displayName
evaluateParams.fileType = getMimeType(displayName)
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -161,13 +148,6 @@ 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: {

View File

@@ -7,9 +7,6 @@ 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'

View File

@@ -1,7 +1,3 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}
export function metadataFixturePath(fileName: string): string {
return `./src/scripts/metadata/__fixtures__/${fileName}`
}

View File

@@ -133,29 +133,6 @@ test.describe('AssetHelper', () => {
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
})
test('GET /assets filters by exclude_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_INPUT_IMAGE),
withAsset({
...STABLE_INPUT_IMAGE,
id: 'missing-input',
tags: ['input', 'missing']
})
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets.map((asset) => asset.id)).toEqual([
STABLE_INPUT_IMAGE.id
])
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -1,62 +0,0 @@
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)
})
})
}
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -692,27 +692,19 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Controls stack label above widget in compact mode', async ({
test('Controls collapse to single column 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 wide layout'
'tool label should be visible in two-column 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']
@@ -724,22 +716,8 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(
toolLabel,
'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)
'tool label should hide in compact single-column layout'
).toBeHidden()
})
test('Multiple sequential strokes at different positions all accumulate', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -189,79 +188,4 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test.describe('Closing a modified workflow tab (FE-419)', () => {
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
await page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
await expect(
activeTab.getByTestId('workflow-dirty-indicator')
).toHaveCount(1)
}
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Close anyway' })
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
0
)
})
test('clicking "Close anyway" closes the tab without saving', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await comfyPage.page
.getByRole('dialog')
.getByRole('button', { name: 'Close anyway' })
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
})
test('dismissing the dialog keeps the modified tab open', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})
})

View File

@@ -1,5 +1,3 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -41,19 +39,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
const box = await button.boundingBox()
if (!box) throw new Error('Tab button has no bounding box')
const start = {
x: box.x + box.width / 2,
y: box.y + box.height * 0.75
}
await comfyPage.canvasOps.dragAndDrop(start, {
x: start.x + 120,
y: start.y + 80
})
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -105,63 +90,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(headerPos, afterPos)
})
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = node.getByText('Show advanced inputs')
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
await expect(widgets).toHaveCount(2)
const beforePos = await node.boundingBox()
if (!beforePos) throw new Error('Node has no bounding box')
await dragFromTabButton(comfyPage, showButton)
await expect(showButton).toBeVisible()
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
await expect(widgets).toHaveCount(2)
const afterPos = await node.boundingBox()
if (!afterPos) throw new Error('Node missing after drag')
await expectPosChanged(beforePos, afterPos)
})
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const beforePos = await subgraphNode.getPosition()
await dragFromTabButton(
comfyPage,
comfyPage.vueNodes.getSubgraphEnterButton('2')
)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
const afterPos = await subgraphNode.getPosition()
await expectPosChanged(beforePos, afterPos)
})
test('should move all selected nodes together when dragging one with Meta held', async ({
comfyPage
}) => {

View File

@@ -3,7 +3,6 @@
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,

View File

@@ -19,7 +19,6 @@ 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__')
@@ -116,15 +115,6 @@ 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()
@@ -177,7 +167,6 @@ def generate_webm():
if __name__ == '__main__':
print('Generating fixtures...')
generate_png()
generate_webp()
generate_avif()
generate_flac()

View File

@@ -1,5 +1,4 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -43,43 +42,4 @@ describe('ConfirmationDialogContent', () => {
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
})
})

View File

@@ -55,7 +55,7 @@
</div>
<Button
v-if="type !== 'info' && type !== 'dirtyClose'"
v-if="type !== 'info'"
variant="secondary"
autofocus
@click="onCancel"
@@ -86,9 +86,9 @@
<template v-else-if="type === 'dirtyClose'">
<Button variant="secondary" @click="onDeny">
<i class="pi pi-times" />
{{ denyLabel ?? $t('g.no') }}
{{ $t('g.no') }}
</Button>
<Button autofocus @click="onConfirm">
<Button @click="onConfirm">
<i class="pi pi-save" />
{{ $t('g.save') }}
</Button>
@@ -131,7 +131,6 @@ const props = defineProps<{
onConfirm: (value?: boolean) => void
itemList?: string[]
hint?: string
denyLabel?: string
}>()
const { t } = useI18n()

View File

@@ -1,164 +0,0 @@
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')
})
})

View File

@@ -163,7 +163,6 @@ 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'
@@ -307,7 +306,7 @@ const menuItems = computed<MenuItem[]>(() => {
trackResourceClick('help_feedback', isCloud || isNightly)
if (isCloud || isNightly) {
window.open(
buildFeedbackTypeformUrl('help-center'),
'https://form.typeform.com/to/q7azbWPi',
'_blank',
'noopener,noreferrer'
)

View File

@@ -1,334 +0,0 @@
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')
})
})
})

View File

@@ -23,7 +23,6 @@
/>
<canvas
ref="canvasEl"
data-testid="painter-canvas"
class="absolute inset-0 size-full cursor-none touch-none"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@@ -59,6 +58,7 @@
"
>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.tool') }}
@@ -99,6 +99,7 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.size') }}
@@ -125,6 +126,7 @@
<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') }}
@@ -168,6 +170,7 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.hardness') }}
@@ -196,6 +199,7 @@
<template v-if="!isImageInputConnected">
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.width') }}
@@ -218,6 +222,7 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.height') }}
@@ -240,6 +245,7 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.background') }}

View File

@@ -23,7 +23,6 @@
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
data-testid="workflow-dirty-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
>

View File

@@ -1,186 +0,0 @@
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()
})
})

View File

@@ -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 { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { buildFeedbackUrl } 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,12 +152,9 @@ const isIntegratedTabBar = computed(
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
const feedbackUrl = buildFeedbackUrl()
function openFeedback() {
window.open(
buildFeedbackTypeformUrl('topbar'),
'_blank',
'noopener,noreferrer'
)
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
}
const containerRef = ref<HTMLElement | null>(null)

View File

@@ -1,195 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
logout: vi.fn().mockResolvedValue(undefined)
}))
const mockToastStore = vi.hoisted(() => ({
add: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
modifiedWorkflows: [] as ModifiedWorkflow[]
}))
const mockWorkflowService = vi.hoisted(() => ({
saveWorkflow: vi.fn().mockResolvedValue(true)
}))
const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => undefined)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => mockToastStore)
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => mockWorkflowService)
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: vi.fn()
})
}))
function makeWorkflow(path: string): ModifiedWorkflow {
return { path, isModified: true } satisfies ModifiedWorkflow
}
describe('useAuthActions.logout', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
})
it('logs out without prompting when no workflows are modified', async () => {
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).not.toHaveBeenCalled()
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
})
it('cancels sign-out when the dialog is dismissed (null)', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(null)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('signs out without saving when the user picks "Sign out anyway" (false)', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(false)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
})
it('cancels sign-out when saving a workflow is cancelled', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(true)
mockWorkflowService.saveWorkflow.mockResolvedValueOnce(false)
const { logout } = useAuthActions()
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('does not log out if a workflow save fails', async () => {
mockWorkflowStore.modifiedWorkflows = [
makeWorkflow('a.json'),
makeWorkflow('b.json')
]
mockDialogService.confirm.mockResolvedValueOnce(true)
mockWorkflowService.saveWorkflow.mockRejectedValueOnce(
new Error('disk full')
)
const { logout } = useAuthActions()
await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json')
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('saves every modified workflow before signing out when user picks Save (true)', async () => {
const workflows = [makeWorkflow('a.json'), makeWorkflow('b.json')]
mockWorkflowStore.modifiedWorkflows = workflows
mockDialogService.confirm.mockResolvedValueOnce(true)
const { logout } = useAuthActions()
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(2)
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
1,
workflows[0]
)
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
2,
workflows[1]
)
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
expect(
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1]
).toBeLessThan(mockAuthStore.logout.mock.invocationCallOrder[0])
expect(
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[0]
).toBeLessThan(mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1])
})
it('passes denyLabel "Sign out anyway" to the dialog', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(null)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dirtyClose',
title: 'auth.signOut.unsavedChangesTitle',
message: 'auth.signOut.unsavedChangesMessage',
denyLabel: 'auth.signOut.signOutAnyway'
})
)
})
})

View File

@@ -9,7 +9,6 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
@@ -54,30 +53,14 @@ export const useAuthActions = () => {
const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
const modifiedWorkflows = workflowStore.modifiedWorkflows
if (modifiedWorkflows.length > 0) {
if (workflowStore.modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose',
denyLabel: t('auth.signOut.signOutAnyway')
type: 'dirtyClose'
})
if (confirmed === null) return
if (confirmed === true) {
const workflowService = useWorkflowService()
for (const workflow of modifiedWorkflows) {
try {
const saved = await workflowService.saveWorkflow(workflow)
if (!saved) return
} catch {
throw new Error(
t('auth.signOut.saveFailed', { workflow: workflow.path })
)
}
}
}
if (!confirmed) return
}
await authStore.logout()

View File

@@ -1,83 +0,0 @@
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([])
})
})

View File

@@ -1,20 +1,17 @@
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(
buildFeedbackTypeformUrl('action-bar'),
'_blank',
'noopener,noreferrer'
)
window.open(TYPEFORM_SURVEY_URL, '_blank', 'noopener,noreferrer')
}
}
]

View File

@@ -1,487 +0,0 @@
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' }])
})
})

View File

@@ -6,22 +6,17 @@ import Load3DConfiguration, {
} from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraConfig,
GizmoConfig,
LightConfig,
ModelConfig,
SceneConfig
ModelConfig
} from '@/extensions/core/load3d/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const { settingsGetMock } = vi.hoisted(() => ({
settingsGetMock: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: settingsGetMock })
useSettingStore: () => ({
get: vi.fn()
})
}))
vi.mock('@/scripts/api', () => ({
@@ -48,22 +43,13 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
}
}))
type WithPrivate = {
loadModelConfig(): ModelConfig
loadSceneConfig(): SceneConfig
loadCameraConfig(): CameraConfig
loadLightConfig(): LightConfig
}
type WithPrivate = { loadModelConfig(): ModelConfig }
function createConfig(properties?: Dictionary<NodeProperty | undefined>) {
const load3d = {} as Load3d
return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate
}
function stubSettings(values: Record<string, unknown>) {
settingsGetMock.mockImplementation((key: string) => values[key])
}
const defaultGizmo: GizmoConfig = {
enabled: false,
mode: 'translate',
@@ -72,13 +58,6 @@ const defaultGizmo: GizmoConfig = {
scale: { x: 1, y: 1, z: 1 }
}
const hdriDefaults = {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
} as const
describe('Load3DConfiguration.loadModelConfig', () => {
afterEach(() => {
vi.restoreAllMocks()
@@ -363,234 +342,3 @@ describe('parseAnnotatedFilename', () => {
})
})
})
describe('Load3DConfiguration.loadSceneConfig', () => {
beforeEach(() => {
settingsGetMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the persisted Scene Config when present, ignoring settings', () => {
const stored: SceneConfig = {
showGrid: false,
backgroundColor: '#123456',
backgroundImage: 'bg.png'
}
const properties = { 'Scene Config': stored } as Dictionary<
NodeProperty | undefined
>
stubSettings({
'Comfy.Load3D.ShowGrid': true,
'Comfy.Load3D.BackgroundColor': 'aaaaaa'
})
expect(createConfig(properties).loadSceneConfig()).toEqual(stored)
expect(settingsGetMock).not.toHaveBeenCalled()
})
it('falls back to settings and prepends # to the background color', () => {
stubSettings({
'Comfy.Load3D.ShowGrid': false,
'Comfy.Load3D.BackgroundColor': 'abcdef'
})
expect(createConfig().loadSceneConfig()).toEqual({
showGrid: false,
backgroundColor: '#abcdef',
backgroundImage: ''
})
})
})
describe('Load3DConfiguration.loadCameraConfig', () => {
beforeEach(() => {
settingsGetMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the persisted Camera Config when present', () => {
const stored: CameraConfig = {
cameraType: 'orthographic',
fov: 50
}
const properties = { 'Camera Config': stored } as Dictionary<
NodeProperty | undefined
>
stubSettings({ 'Comfy.Load3D.CameraType': 'perspective' })
expect(createConfig(properties).loadCameraConfig()).toEqual(stored)
expect(settingsGetMock).not.toHaveBeenCalled()
})
it('falls back to settings and a default fov of 35', () => {
stubSettings({ 'Comfy.Load3D.CameraType': 'perspective' })
expect(createConfig().loadCameraConfig()).toEqual({
cameraType: 'perspective',
fov: 35
})
})
})
describe('Load3DConfiguration.loadLightConfig', () => {
beforeEach(() => {
settingsGetMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('falls back to settings with default hdri when nothing is persisted', () => {
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
expect(createConfig().loadLightConfig()).toEqual({
intensity: 4,
hdri: hdriDefaults
})
})
it('uses the persisted intensity over the setting when present', () => {
const stored: Partial<LightConfig> = { intensity: 7 }
const properties = { 'Light Config': stored } as Dictionary<
NodeProperty | undefined
>
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
expect(createConfig(properties).loadLightConfig()).toEqual({
intensity: 7,
hdri: hdriDefaults
})
})
it('falls back to the setting intensity when persisted intensity is missing', () => {
const properties = { 'Light Config': {} } as Dictionary<
NodeProperty | undefined
>
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
expect(createConfig(properties).loadLightConfig()).toEqual({
intensity: 4,
hdri: hdriDefaults
})
})
it('merges persisted hdri partial over hdri defaults', () => {
const stored: Partial<LightConfig> = {
intensity: 2,
hdri: { hdriPath: 'env.hdr', enabled: true } as LightConfig['hdri']
}
const properties = { 'Light Config': stored } as Dictionary<
NodeProperty | undefined
>
expect(createConfig(properties).loadLightConfig()).toEqual({
intensity: 2,
hdri: {
enabled: true,
hdriPath: 'env.hdr',
showAsBackground: false,
intensity: 1
}
})
})
})
describe('Load3DConfiguration.configure forwards persisted + settings to load3d', () => {
let load3d: Load3d
function makeLoad3dMock(): Load3d {
return {
loadModel: vi.fn().mockResolvedValue(undefined),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setTargetSize: vi.fn(),
setCameraState: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn(),
emitModelReady: vi.fn()
} as unknown as Load3d
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
settingsGetMock.mockReset()
load3d = makeLoad3dMock()
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('uses settings defaults when no Scene/Camera/Light Config is persisted', async () => {
stubSettings({
'Comfy.Load3D.ShowGrid': true,
'Comfy.Load3D.BackgroundColor': '282828',
'Comfy.Load3D.CameraType': 'orthographic',
'Comfy.Load3D.LightIntensity': 6
})
const config = new Load3DConfiguration(load3d)
config.configure({
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
loadFolder: 'output'
})
await flush()
expect(load3d.toggleGrid).toHaveBeenCalledWith(true)
expect(load3d.setBackgroundColor).toHaveBeenCalledWith('#282828')
expect(load3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(load3d.setFOV).toHaveBeenCalledWith(35)
expect(load3d.setLightIntensity).toHaveBeenCalledWith(6)
})
it('prefers persisted Scene/Camera/Light Config over settings', async () => {
const properties = {
'Scene Config': {
showGrid: false,
backgroundColor: '#101010',
backgroundImage: ''
},
'Camera Config': { cameraType: 'perspective', fov: 60 },
'Light Config': { intensity: 9 }
} as unknown as Dictionary<NodeProperty | undefined>
stubSettings({
'Comfy.Load3D.ShowGrid': true,
'Comfy.Load3D.BackgroundColor': '282828',
'Comfy.Load3D.CameraType': 'orthographic',
'Comfy.Load3D.LightIntensity': 1
})
const config = new Load3DConfiguration(load3d, properties)
config.configure({
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
loadFolder: 'output'
})
await flush()
expect(load3d.toggleGrid).toHaveBeenCalledWith(false)
expect(load3d.setBackgroundColor).toHaveBeenCalledWith('#101010')
expect(load3d.toggleCamera).toHaveBeenCalledWith('perspective')
expect(load3d.setFOV).toHaveBeenCalledWith(60)
expect(load3d.setLightIntensity).toHaveBeenCalledWith(9)
})
})

View File

@@ -1,197 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyExtension } from '@/types/comfy'
const { registerExtensionMock, waitForLoad3dMock, configureForSaveMeshMock } =
vi.hoisted(() => ({
registerExtensionMock: vi.fn(),
waitForLoad3dMock: vi.fn(),
configureForSaveMeshMock: vi.fn()
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension: registerExtensionMock })
}))
vi.mock('@/services/load3dService', () => ({
useLoad3dService: () => ({ getLoad3d: vi.fn() })
}))
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock })
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
configureForSaveMesh = configureForSaveMeshMock
}
}))
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
createExportMenuItems: vi.fn(() => [])
}))
vi.mock('@/components/load3d/Load3D.vue', () => ({ default: {} }))
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: vi.fn(),
addWidget: vi.fn()
}))
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
isAssetPreviewSupported: vi.fn(() => false),
persistThumbnail: vi.fn()
}))
type SaveMeshExtension = ComfyExtension & {
nodeCreated: (node: LGraphNode) => Promise<void>
}
async function loadSaveMeshExtensionFresh(): Promise<SaveMeshExtension> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/saveMesh')
return registerExtensionMock.mock.calls[0][0] as SaveMeshExtension
}
function makeNode(
overrides: Partial<{
comfyClass: string
properties: Record<string, unknown>
}> = {}
): LGraphNode {
const { comfyClass = 'SaveGLB', properties = {} } = overrides
return {
constructor: { comfyClass },
size: [400, 550],
setSize: vi.fn(),
widgets: [{ name: 'image', value: '' }],
properties
} as unknown as LGraphNode
}
describe('saveMesh', () => {
beforeEach(() => {
vi.clearAllMocks()
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
cb({
whenLoadIdle: () => Promise.resolve(),
captureThumbnail: vi.fn()
})
})
})
it('registers a single Comfy.SaveGLB extension on import', async () => {
const ext = await loadSaveMeshExtensionFresh()
expect(registerExtensionMock).toHaveBeenCalledOnce()
expect(ext.name).toBe('Comfy.SaveGLB')
expect(typeof ext.nodeCreated).toBe('function')
})
it('skips nodes whose comfyClass is not SaveGLB', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode({ comfyClass: 'OtherNode' })
await ext.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('does not load a model on creation when no Last Time Model File is persisted', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode()
await ext.nodeCreated(node)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('restores the persisted model on creation using the persisted folder', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode({
properties: {
'Last Time Model File': 'sub/model.glb',
'Last Time Model Folder': 'output'
}
})
await ext.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'sub/model.glb',
{ silentOnNotFound: true }
)
expect(node.widgets?.find((w) => w.name === 'image')?.value).toBe(
'sub/model.glb'
)
})
it('defaults the load folder to output when only the file path is persisted', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode({
properties: { 'Last Time Model File': 'model.glb' }
})
await ext.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'model.glb',
{ silentOnNotFound: true }
)
})
it('persists Last Time Model File and Folder after onExecuted', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode()
await ext.nodeCreated(node)
node.onExecuted!({
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }]
})
expect(node.properties['Last Time Model File']).toBe('sub/mesh.glb')
expect(node.properties['Last Time Model Folder']).toBe('output')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'sub/mesh.glb',
{ silentOnNotFound: true }
)
})
it('does not persist anything when onExecuted has no 3d output', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode()
await ext.nodeCreated(node)
node.onExecuted!({})
expect(node.properties['Last Time Model File']).toBeUndefined()
expect(node.properties['Last Time Model Folder']).toBeUndefined()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('uses the persisted state from a prior run when the node is recreated', async () => {
const ext = await loadSaveMeshExtensionFresh()
const firstNode = makeNode()
await ext.nodeCreated(firstNode)
firstNode.onExecuted!({
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }]
})
configureForSaveMeshMock.mockClear()
const recreated = makeNode({ properties: { ...firstNode.properties } })
await ext.nodeCreated(recreated)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'sub/mesh.glb',
{ silentOnNotFound: true }
)
})
})

View File

@@ -81,32 +81,6 @@ useExtensionService().registerExtension({
await nextTick()
useLoad3d(node).waitForLoad3d((load3d) => {
if (!load3d) return
const modelWidget = node.widgets?.find((w) => w.name === 'image')
if (!modelWidget) return
const lastTimeModelFile = node.properties['Last Time Model File'] as
| string
| undefined
const lastTimeModelFolder =
(node.properties['Last Time Model Folder'] as
| 'input'
| 'output'
| undefined) ?? 'output'
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(lastTimeModelFolder, lastTimeModelFile, {
silentOnNotFound: true
})
}
})
const onExecuted = node.onExecuted
node.onExecuted = function (output: SaveMeshOutput) {
@@ -129,9 +103,6 @@ useExtensionService().registerExtension({
const loadFolder = fileInfo.type as 'input' | 'output'
node.properties['Last Time Model File'] = filePath
node.properties['Last Time Model Folder'] = loadFolder
config.configureForSaveMesh(loadFolder, filePath, {
silentOnNotFound: true
})

View File

@@ -979,7 +979,6 @@
"dirtyCloseTitle": "Save Changes?",
"dirtyClose": "The files below have been changed. Would you like to save them before closing?",
"dirtyCloseHint": "Hold Shift to close without prompt",
"dirtyCloseAnyway": "Close anyway",
"confirmOverwriteTitle": "Overwrite existing file?",
"confirmOverwrite": "The file below already exists. Would you like to overwrite it?",
"workflowTreeType": {
@@ -2212,9 +2211,7 @@
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?",
"signOutAnyway": "Sign out anyway",
"saveFailed": "Sign-out cancelled because saving \"{workflow}\" failed."
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
},
"passwordUpdate": {
"success": "Password Updated",

View File

@@ -1,220 +0,0 @@
import { render, screen } from '@testing-library/vue'
import type * as VueUseCore from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import Media3DTop from './Media3DTop.vue'
const {
mockUseIntersectionObserver,
mockFindServerPreviewUrl,
mockIsAssetPreviewSupported
} = vi.hoisted(() => ({
mockUseIntersectionObserver: vi.fn(),
mockFindServerPreviewUrl: vi.fn(),
mockIsAssetPreviewSupported: vi.fn(() => true)
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUseCore>()
return {
...actual,
useIntersectionObserver: mockUseIntersectionObserver
}
})
vi.mock('../utils/assetPreviewUtil', () => ({
findServerPreviewUrl: mockFindServerPreviewUrl,
isAssetPreviewSupported: mockIsAssetPreviewSupported
}))
function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
id: 'asset-1',
name: 'mesh.glb',
asset_hash: null,
mime_type: 'model/gltf-binary',
tags: [],
kind: '3D',
src: 'http://example.com/mesh.glb',
...overrides
} as AssetMeta
}
function fireObserverIntersecting() {
mockUseIntersectionObserver.mockImplementation(
(
_target: unknown,
callback: (entries: { isIntersecting: boolean }[]) => void
) => {
callback([{ isIntersecting: true }])
return { stop: vi.fn() }
}
)
}
function noopObserver() {
mockUseIntersectionObserver.mockImplementation(() => ({ stop: vi.fn() }))
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
const globalConfig = { mocks: { $t: (key: string) => key } }
describe('Media3DTop', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsAssetPreviewSupported.mockReturnValue(true)
})
it('renders the placeholder when no thumbnail has loaded', () => {
noopObserver()
const { container } = render(Media3DTop, {
props: { asset: makeAsset() },
global: globalConfig
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- <img> has no role until src is set
expect(container.querySelector('img')).not.toBeInTheDocument()
expect(
screen.getByText('assetBrowser.media.threeDModelPlaceholder')
).toBeInTheDocument()
})
it('uses asset.preview_url directly when preview_id is already on the prop', async () => {
fireObserverIntersecting()
const { container } = render(Media3DTop, {
props: {
asset: makeAsset({
preview_id: 'p1',
preview_url: 'http://server/preview.png'
})
},
global: globalConfig
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'http://server/preview.png')
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
it('queries the server for a preview when preview_id is missing on the prop', async () => {
fireObserverIntersecting()
mockFindServerPreviewUrl.mockResolvedValue('http://server/from-name.png')
const { container } = render(Media3DTop, {
props: { asset: makeAsset() },
global: globalConfig
})
await flush()
expect(mockFindServerPreviewUrl).toHaveBeenCalledWith('mesh.glb')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'http://server/from-name.png')
})
it('skips the server query when isAssetPreviewSupported is false', async () => {
fireObserverIntersecting()
mockIsAssetPreviewSupported.mockReturnValue(false)
render(Media3DTop, {
props: { asset: makeAsset() },
global: globalConfig
})
await flush()
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
it('picks up a patched preview_url after the IntersectionObserver gate has closed', async () => {
// Initial render: observer fires, server has no preview yet — hasAttempted=true
fireObserverIntersecting()
mockFindServerPreviewUrl.mockResolvedValue(null)
const { container, rerender } = render(Media3DTop, {
props: {
asset: makeAsset({
preview_id: null,
preview_url: undefined
})
},
global: globalConfig
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('img')).not.toBeInTheDocument()
// Simulate persistThumbnail patching the store: the prop arrives with the
// new preview. The watch on [preview_id, preview_url] should apply it
// directly even though the observer won't refire.
await rerender({
asset: makeAsset({
preview_id: 'p-new',
preview_url: 'http://server/patched.png'
})
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'http://server/patched.png')
})
it('does not overwrite a thumbnail that is already showing', async () => {
fireObserverIntersecting()
const { container, rerender } = render(Media3DTop, {
props: {
asset: makeAsset({
preview_id: 'p1',
preview_url: 'http://server/first.png'
})
},
global: globalConfig
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('img')).toHaveAttribute(
'src',
'http://server/first.png'
)
await rerender({
asset: makeAsset({
preview_id: 'p2',
preview_url: 'http://server/second.png'
})
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('img')).toHaveAttribute(
'src',
'http://server/first.png'
)
})
it('does not apply a patch with no preview_id', async () => {
fireObserverIntersecting()
mockFindServerPreviewUrl.mockResolvedValue(null)
const { container, rerender } = render(Media3DTop, {
props: { asset: makeAsset({ preview_id: null, preview_url: undefined }) },
global: globalConfig
})
await flush()
await rerender({
asset: makeAsset({
preview_id: null,
preview_url: 'http://server/no-id.png'
})
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('img')).not.toBeInTheDocument()
})
})

View File

@@ -74,20 +74,5 @@ watch(
}
)
watch(
() => [asset?.preview_id, asset?.preview_url] as const,
([newPreviewId, newPreviewUrl], [, oldPreviewUrl]) => {
if (
newPreviewId &&
newPreviewUrl &&
newPreviewUrl !== oldPreviewUrl &&
!thumbnailSrc.value
) {
thumbnailSrc.value = newPreviewUrl
hasAttempted.value = true
}
}
)
onBeforeUnmount(revokeThumbnail)
</script>

View File

@@ -1,4 +1,3 @@
import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
@@ -21,11 +20,11 @@ const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
const zAssetResponse = zListAssetsResponse
.pick({ total: true, has_more: true })
.extend({
assets: z.array(zAsset)
})
const zAssetResponse = z.object({
assets: z.array(zAsset).optional(),
total: z.number().optional(),
has_more: z.boolean().optional()
})
const zModelFolder = z.object({
name: z.string(),

View File

@@ -1,9 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AssetItem,
AssetResponse
} from '@/platform/assets/schemas/assetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
assetService,
@@ -56,11 +53,6 @@ const validBlake3Hash =
'1111111111111111111111111111111111111111111111111111111111111111'
const validBlake3AssetHash = `blake3:${validBlake3Hash}`
type AssetListResponseOptions = {
hasMore?: AssetResponse['has_more']
total?: AssetResponse['total']
}
function buildResponse(
body: unknown,
init: { ok?: boolean; status?: number } = {}
@@ -72,13 +64,6 @@ function buildResponse(
} as unknown as Response
}
function buildAssetListResponse(
assets: AssetItem[],
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
): Response {
return buildResponse({ assets, total, has_more: hasMore })
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
@@ -233,7 +218,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -255,7 +240,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
)
@@ -316,7 +301,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -342,7 +327,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse({
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
@@ -436,14 +421,17 @@ describe(assetService.getAssetModelFolders, () => {
vi.clearAllMocks()
})
it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
])
buildResponse({
assets: [
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
]
})
)
const folders = await assetService.getAssetModelFolders()
@@ -456,7 +444,6 @@ describe(assetService.getAssetModelFolders, () => {
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.has('include_public')).toBe(false)
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
})
@@ -503,9 +490,14 @@ describe(assetService.getAssetsByTag, () => {
vi.clearAllMocks()
})
it('forwards include_public=true by default and requests missing-tag exclusion', async () => {
it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
]
})
)
const assets = await assetService.getAssetsByTag('input')
@@ -515,20 +507,6 @@ describe(assetService.getAssetsByTag, () => {
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('true')
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
it('normalizes tag query parameters', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
)
await assetService.getAssetsByTag(' input ')
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('input')
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
})
@@ -540,16 +518,17 @@ describe(assetService.getAllAssetsByTag, () => {
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
[
buildResponse({
assets: [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
)
]
})
)
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
buildResponse({
assets: [validAsset({ id: 'c', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -561,33 +540,63 @@ describe(assetService.getAllAssetsByTag, () => {
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
})
it('paginates from raw response size before filtering missing-tagged assets', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
]
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
if (typeof secondUrl !== 'string') {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
})
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
[
buildResponse({
assets: [
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
{ hasMore: true }
)
has_more: true
})
)
.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'later-public', tags: ['input'] })
])
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
has_more: false
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -605,41 +614,12 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('offset')).toBe('2')
})
it.each([
{
name: 'missing has_more',
body: {
assets: [validAsset({ id: 'a', tags: ['input'] })],
total: 1
}
},
{
name: 'missing total',
body: {
assets: [validAsset({ id: 'a', tags: ['input'] })],
has_more: false
}
},
{
name: 'non-boolean has_more',
body: {
assets: [validAsset({ id: 'a', tags: ['input'] })],
total: 1,
has_more: 'false'
}
}
])('rejects asset responses with $name', async ({ body }) => {
fetchApiMock.mockResolvedValueOnce(buildResponse(body))
await expect(
assetService.getAllAssetsByTag('input', true, { limit: 2 })
).rejects.toThrow(/Invalid asset response/)
})
it('passes abort signals through paginated requests', async () => {
const controller = new AbortController()
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
buildResponse({
assets: [validAsset({ id: 'a', tags: ['input'] })]
})
)
await assetService.getAllAssetsByTag('input', true, {
@@ -656,13 +636,12 @@ describe(assetService.getAllAssetsByTag, () => {
const controller = new AbortController()
fetchApiMock.mockImplementationOnce(async () => {
controller.abort()
return buildAssetListResponse(
[
return buildResponse({
assets: [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
)
]
})
})
await expect(
@@ -687,7 +666,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
validAsset({ id: 'user-input', tags: ['input'] }),
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
]
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
const first = await assetService.getInputAssetsIncludingPublic()
const second = await assetService.getInputAssetsIncludingPublic()
@@ -706,8 +685,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
@@ -741,7 +720,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(serviceSignal).toBeUndefined()
resolveResponse(buildAssetListResponse(assets))
resolveResponse(buildResponse({ assets }))
await expect(second).resolves.toEqual(assets)
expect(fetchApiMock).toHaveBeenCalledOnce()
@@ -771,7 +750,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
resolveResponse(buildAssetListResponse(assets))
resolveResponse(buildResponse({ assets }))
await Promise.resolve()
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -791,12 +770,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
resolveResponse = resolve
})
)
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
const inFlight = assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
resolveResponse(buildAssetListResponse(assets))
resolveResponse(buildResponse({ assets }))
await expect(inFlight).resolves.toEqual(assets)
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -809,9 +788,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(null))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
await assetService.deleteAsset('stale-input')
@@ -830,9 +809,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
const freshAssets = [uploadedAsset]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(uploadedAsset))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
@@ -848,7 +827,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for pending async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'running' },
@@ -870,7 +849,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for non-input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
await assetService.getInputAssetsIncludingPublic()

View File

@@ -36,7 +36,6 @@ interface AssetPaginationOptions extends PaginationOptions {
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
excludeTags?: string[]
includePublic?: boolean
signal?: AbortSignal
}
@@ -182,7 +181,6 @@ const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
export const MODELS_TAG = 'models'
/** Asset tag used by the backend for placeholder records that are not installed. */
export const MISSING_TAG = 'missing'
const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG]
/** Result of a HEAD lookup against an exact asset hash. */
export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
@@ -212,10 +210,6 @@ function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createAbortError()
}
function normalizeAssetTags(tags: string[]): string[] {
return tags.map((tag) => tag.trim()).filter(Boolean)
}
async function withCallerAbort<T>(
promise: Promise<T>,
signal?: AbortSignal
@@ -296,22 +290,15 @@ function createAssetService() {
): Promise<AssetResponse> {
const {
includeTags,
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
includePublic,
signal
} = options
const normalizedIncludeTags = normalizeAssetTags(includeTags)
const normalizedExcludeTags = normalizeAssetTags(excludeTags)
const queryParams = new URLSearchParams({
include_tags: normalizedIncludeTags.join(','),
include_tags: includeTags.join(','),
limit: limit.toString()
})
if (normalizedExcludeTags.length > 0) {
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
}
if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
@@ -350,10 +337,15 @@ function createAssetService() {
// Blacklist directories we don't want to show
const blacklistedDirectories = new Set(['configs'])
const folderTags = data.assets
.flatMap((asset) => asset.tags)
.filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
const discoveredFolders = new Set<string>(folderTags)
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>(
data?.assets
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
?.flatMap((asset) => asset.tags)
?.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
) ?? []
)
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).toSorted()
@@ -371,10 +363,17 @@ function createAssetService() {
`models for ${folder}`
)
return data.assets.map((asset) => ({
name: asset.name,
pathIndex: 0
}))
return (
data?.assets
?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
)
?.map((asset) => ({
name: asset.name,
pathIndex: 0
})) ?? []
)
}
/**
@@ -450,7 +449,12 @@ function createAssetService() {
)
// Return full AssetItem[] objects (don't strip like getAssetModels does)
return data.assets
return (
data?.assets?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
) ?? []
)
}
/**
@@ -469,8 +473,11 @@ function createAssetService() {
}
const data = await res.json()
const result = assetItemSchema.safeParse(data)
if (result.success) return result.data
// Validate the single asset response against our schema
const result = assetResponseSchema.safeParse({ assets: [data] })
if (result.success && result.data.assets?.[0]) {
return result.data.assets[0]
}
const error = result.error
? fromZodError(result.error)
@@ -501,12 +508,13 @@ function createAssetService() {
`assets for tag ${tag}`
)
return data.assets
return (
data?.assets?.filter((asset) => !asset.tags.includes(MISSING_TAG)) ?? []
)
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
* Pagination follows the required server-provided `has_more` flag.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -537,14 +545,13 @@ function createAssetService() {
},
`assets for tag ${tag}`
)
const batch = data.assets
if (batch.length === 0) {
return assets
}
const batch = data.assets ?? []
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
assets.push(...batch)
if (!data.has_more) {
const noMoreFromServer = data.has_more === false
const inferredLastPage =
data.has_more === undefined && batch.length < pageSize
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
return assets
}

View File

@@ -15,7 +15,6 @@ const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
const mockUpdateAsset = vi.hoisted(() => vi.fn())
const mockSetAssetPreview = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
@@ -34,10 +33,6 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({ setAssetPreview: mockSetAssetPreview })
}))
function mockFetchResponse(assets: Record<string, unknown>[]) {
mockFetchApi.mockResolvedValueOnce({
ok: true,
@@ -269,66 +264,4 @@ describe('persistThumbnail', () => {
preview_id: 'new-preview-id'
})
})
it('patches the assets store by name with the new preview after upload', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
mockUpdateAsset.mockResolvedValue({})
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockSetAssetPreview).toHaveBeenCalledWith(
localAsset.name,
'new-preview-id',
'http://localhost:8188/assets/new-preview-id/content'
)
})
it('uses the cloud asset name (not the hash) when patching the store', async () => {
mockFetchResponse([cloudAsset])
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
mockUpdateAsset.mockResolvedValue({})
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('c6cadcee57dd.glb', blob)
expect(mockSetAssetPreview).toHaveBeenCalledWith(
cloudAsset.name,
'new-preview-id',
'http://localhost:8188/assets/new-preview-id/content'
)
})
it('does not patch the store when the asset already has a preview', async () => {
mockFetchEmpty()
mockFetchResponse([localAssetWithPreview])
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockSetAssetPreview).not.toHaveBeenCalled()
})
it('does not patch the store when no asset is found', async () => {
mockFetchEmpty()
mockFetchEmpty()
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('nonexistent.glb', blob)
expect(mockSetAssetPreview).not.toHaveBeenCalled()
})
it('does not patch the store when upload fails', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockSetAssetPreview).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,5 @@
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
interface AssetRecord {
id: string
@@ -81,9 +80,6 @@ export async function persistThumbnail(
await assetService.updateAsset(asset.id, {
preview_id: uploaded.id
})
const previewUrl = api.apiURL(`/assets/${uploaded.id}/content`)
useAssetsStore().setAssetPreview(asset.name, uploaded.id, previewUrl)
} catch {
// Non-critical — client still shows the rendered thumbnail
}

View File

@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isNightly() {
return distribution.isNightly
}
}))
describe('buildFeedbackTypeformUrl', () => {
beforeEach(() => {
distribution.isCloud = false
distribution.isNightly = false
})
async function build(source: 'topbar' | 'action-bar' | 'help-center') {
vi.resetModules()
const { buildFeedbackTypeformUrl } = await import('./config')
return buildFeedbackTypeformUrl(source)
}
it('tags Cloud builds with distribution=ccloud', async () => {
distribution.isCloud = true
expect(await build('topbar')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar'
)
})
it('tags Nightly builds with distribution=oss-nightly', async () => {
distribution.isNightly = true
expect(await build('action-bar')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar'
)
})
it('tags OSS builds with distribution=oss', async () => {
expect(await build('help-center')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=oss&source=help-center'
)
})
it('uses a URL fragment so distribution and source are not sent to the server', async () => {
distribution.isCloud = true
const url = new URL(await build('topbar'))
expect(url.search).toBe('')
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
})
})

View File

@@ -15,7 +15,7 @@ const ZENDESK_FIELDS = {
} as const
/**
* Gets the distribution identifier for tracking.
* Gets the distribution identifier for Zendesk tracking.
* Helps distinguish feedback from different build types.
*/
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
@@ -25,22 +25,17 @@ function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
/**
* Builds the feedback Typeform URL tagged with the current build distribution
* and the UI source that opened it. Tags are passed via the URL fragment
* (Typeform's hidden-field convention) so survey responses can be segmented
* by distribution (cloud / oss-nightly / oss) and entry point.
* Builds the feedback form URL with the appropriate distribution tag.
*/
export function buildFeedbackTypeformUrl(
source: 'topbar' | 'action-bar' | 'help-center'
): string {
export function buildFeedbackUrl(): string {
const params = new URLSearchParams({
distribution: getDistribution(),
source
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
})
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
return `${SUPPORT_BASE_URL}?${params.toString()}`
}
/**

View File

@@ -418,51 +418,24 @@ describe('useWorkflowService', () => {
})
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
const result = await useWorkflowService().saveWorkflow(workflow)
await useWorkflowService().saveWorkflow(workflow)
expect(result).toBe(true)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
it('should return false when temporary workflow save is cancelled', async () => {
it('should call saveWorkflowAs for temporary workflows', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
const result = await useWorkflowService().saveWorkflow(workflow)
await useWorkflowService().saveWorkflow(workflow)
expect(result).toBe(false)
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
})
})
describe('closeWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
beforeEach(() => {
workflowStore = useWorkflowStore()
service = useWorkflowService()
})
it('keeps a temporary workflow open when Save As is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
workflow.isModified = true
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
mockConfirm.mockResolvedValue(true)
const closed = await service.closeWorkflow(workflow)
expect(closed).toBe(false)
expect(workflowStore.closeWorkflow).not.toHaveBeenCalled()
})
})
describe('afterLoadNewGraph', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let existingWorkflow: LoadedComfyWorkflow

View File

@@ -174,39 +174,40 @@ export const useWorkflowService = () => {
* Save a workflow
* @param workflow The workflow to save
*/
const saveWorkflow = async (workflow: ComfyWorkflow): Promise<boolean> => {
const saveWorkflow = async (workflow: ComfyWorkflow) => {
if (workflow.isTemporary) {
return await saveWorkflowAs(workflow)
}
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory + '/' + appendWorkflowJsonExt(workflow.filename, isApp)
if (workflow.path !== expectedPath) {
const existing = workflowStore.getWorkflowByPath(expectedPath)
if (existing && !existing.isTemporary) {
if ((await confirmOverwrite(expectedPath)) !== true) {
await workflowStore.saveWorkflow(workflow)
return true
await saveWorkflowAs(workflow)
} else {
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory +
'/' +
appendWorkflowJsonExt(workflow.filename, isApp)
if (workflow.path !== expectedPath) {
const existing = workflowStore.getWorkflowByPath(expectedPath)
if (existing && !existing.isTemporary) {
if ((await confirmOverwrite(expectedPath)) !== true) {
await workflowStore.saveWorkflow(workflow)
return
}
await deleteWorkflow(existing, true)
}
await deleteWorkflow(existing, true)
await renameWorkflow(workflow, expectedPath)
toastStore.add({
severity: 'info',
summary: t(
isApp
? 'workflowService.savedAsApp'
: 'workflowService.savedAsWorkflow'
),
life: 3000
})
}
await renameWorkflow(workflow, expectedPath)
toastStore.add({
severity: 'info',
summary: t(
isApp
? 'workflowService.savedAsApp'
: 'workflowService.savedAsWorkflow'
),
life: 3000
})
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
return true
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
}
}
/**
@@ -283,15 +284,13 @@ export const useWorkflowService = () => {
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
hint: options.hint,
denyLabel: t('sideToolbar.workflowTab.dirtyCloseAnyway')
hint: options.hint
})
// Cancel
if (confirmed === null) return false
if (confirmed === true) {
const saved = await saveWorkflow(workflow)
if (!saved) return false
await saveWorkflow(workflow)
}
}

View File

@@ -1,19 +1,11 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import NodeFooter from '@/renderer/extensions/vueNodes/components/NodeFooter.vue'
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
const isDraggingVueNodes = ref(false)
return { layoutStore: { isDraggingVueNodes } }
})
const { layoutStore } = await import('@/renderer/core/layout/store/layoutStore')
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -151,33 +143,6 @@ describe('NodeFooter', () => {
await user.click(screen.getByText('Show Advanced Inputs'))
expect(emitted()).toHaveProperty('toggleAdvanced')
})
describe('drag-then-click suppression', () => {
beforeEach(() => {
layoutStore.isDraggingVueNodes.value = false
})
it('does not emit enterSubgraph when a node drag is in progress at pointerup', async () => {
const { emitted } = renderFooter({ isSubgraph: true })
layoutStore.isDraggingVueNodes.value = true
await user.click(screen.getByTestId('subgraph-enter-button'))
expect(emitted().enterSubgraph).toBeUndefined()
})
it('only suppresses the immediately following click, not later ones', async () => {
const { emitted } = renderFooter({ isSubgraph: true })
const button = screen.getByTestId('subgraph-enter-button')
layoutStore.isDraggingVueNodes.value = true
await user.click(button)
expect(emitted().enterSubgraph).toBeUndefined()
layoutStore.isDraggingVueNodes.value = false
await user.click(button)
expect(emitted()).toHaveProperty('enterSubgraph')
})
})
})
describe('shape-based radius classes (getBottomRadius)', () => {

View File

@@ -13,8 +13,7 @@
errorRadiusClass
)
"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('openErrors')"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
@@ -33,8 +32,7 @@
)
"
:style="headerColorStyle"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('enterSubgraph')"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
@@ -62,8 +60,7 @@
errorRadiusClass
)
"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('openErrors')"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
@@ -81,8 +78,7 @@
)
"
:style="headerColorStyle"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('toggleAdvanced')"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{
@@ -115,8 +111,7 @@
footerRadiusClass
)
"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('openErrors')"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
@@ -147,8 +142,7 @@
)
"
:style="headerColorStyle"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('enterSubgraph')"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enterSubgraph') }}</span>
@@ -178,8 +172,7 @@
)
"
:style="headerColorStyle"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('toggleAdvanced')"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
@@ -204,7 +197,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
@@ -229,29 +221,12 @@ const {
shape
} = defineProps<Props>()
const emit = defineEmits<{
defineEmits<{
enterSubgraph: []
openErrors: []
toggleAdvanced: []
}>()
let suppressNextClick = false
function snapshotDragOnPointerUp() {
suppressNextClick = layoutStore.isDraggingVueNodes.value
}
function emitIfNotDragged(
name: 'enterSubgraph' | 'openErrors' | 'toggleAdvanced'
) {
const wasDrag = suppressNextClick
suppressNextClick = false
if (wasDrag) return
if (name === 'enterSubgraph') emit('enterSubgraph')
else if (name === 'openErrors') emit('openErrors')
else emit('toggleAdvanced')
}
const RADIUS_CLASS = {
'rounded-b-17': 'rounded-b-[17px]',
'rounded-b-20': 'rounded-b-[20px]',

View File

@@ -34,21 +34,11 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
})
}))
const snapState = vi.hoisted(() => ({
shouldSnap: false,
applySnapToPosition: (pos: { x: number; y: number }) => pos,
applySnapToSize: (size: { width: number; height: number }) => size
}))
vi.mock('@/renderer/extensions/vueNodes/composables/useNodeSnap', () => ({
useNodeSnap: () => ({
shouldSnap: vi.fn(() => snapState.shouldSnap),
applySnapToPosition: vi.fn((pos: { x: number; y: number }) =>
snapState.applySnapToPosition(pos)
),
applySnapToSize: vi.fn((size: { width: number; height: number }) =>
snapState.applySnapToSize(size)
)
shouldSnap: vi.fn(() => false),
applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos),
applySnapToSize: vi.fn((size: { width: number; height: number }) => size)
})
}))
@@ -159,9 +149,6 @@ describe('useNodeResize', () => {
vi.clearAllMocks()
eventHandlers.pointermove = null
eventHandlers.pointerup = null
snapState.shouldSnap = false
snapState.applySnapToPosition = (pos) => pos
snapState.applySnapToSize = (size) => size
callback = vi.fn<ResizeCallback>()
nodeElement = createMockNodeElement()
@@ -286,230 +273,4 @@ describe('useNodeResize', () => {
expect(payload.position!.y).toBe(450)
})
})
describe('dynamic content height (re-measured per move)', () => {
function makeReflowingElement(
width: number,
height: number,
getMinContentHeight: () => number
): HTMLElement {
const element = document.createElement('div')
element.setAttribute('data-node-id', 'test-node')
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
element.getBoundingClientRect = () => {
const nodeHeight = element.style.getPropertyValue('--node-height')
const h = nodeHeight === '0px' ? getMinContentHeight() : height
return {
width,
height: h,
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: h,
toJSON: () => {}
} as DOMRect
}
return element
}
async function setupDynamic(getMinContentHeight: () => number) {
vi.clearAllMocks()
eventHandlers.pointermove = null
eventHandlers.pointerup = null
const cb = vi.fn<ResizeCallback>()
const el = makeReflowingElement(300, 400, getMinContentHeight)
const h = createMockHandle(el)
const { useNodeResize } = await import('./useNodeResize')
const { startResize } = useNodeResize(cb)
return { cb, el, handle: h, startResize }
}
it('uses the latest measured content height when content reflows taller', async () => {
let currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'SE')
// First move: clamp uses initial minContentHeight = 150
simulateMove(0, -300)
const firstPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(firstPayload.size.height).toBe(150)
// Content reflows taller (e.g. painter switches to compact layout)
currentMinHeight = 280
// Second move at the same position must reflect the new minimum,
// not the value captured at drag start.
simulateMove(0, -300)
const secondPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(secondPayload.size.height).toBe(280)
})
it('also re-measures for N corners and updates the y-position clamp', async () => {
let currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'NW')
simulateMove(0, 500)
const firstPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(firstPayload.size.height).toBe(150)
expect(firstPayload.position!.y).toBe(450) // 200 + 400 - 150
currentMinHeight = 220
simulateMove(0, 500)
const secondPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(secondPayload.size.height).toBe(220)
expect(secondPayload.position!.y).toBe(380) // 200 + 400 - 220
})
it('stops responding to pointermove after pointerup', async () => {
const currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'SE')
simulateMove(20, 20)
const callsBeforeUp = cb.mock.calls.length
const upEvent = createPointerEvent('pointerup', { pointerId: 1 })
eventHandlers.pointerup?.(upEvent)
// Subsequent moves should be ignored after cleanup
simulateMove(40, 40)
expect(cb.mock.calls.length).toBe(callsBeforeUp)
})
it('handles releasePointerCapture throwing without breaking cleanup', async () => {
const { cb, el, handle: h, startResize } = await setupDynamic(() => 150)
h.releasePointerCapture = vi.fn(() => {
throw new Error('already released')
})
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
const upEvent = createPointerEvent('pointerup', { pointerId: 1 })
expect(() => eventHandlers.pointerup?.(upEvent)).not.toThrow()
// Further moves are ignored — cleanup still ran.
const callsAfterUp = cb.mock.calls.length
simulateMove(50, 50)
expect(cb.mock.calls.length).toBe(callsAfterUp)
expect(el).toBeDefined()
})
it('applies snap-to-grid on SE (size only)', async () => {
snapState.shouldSnap = true
snapState.applySnapToSize = ({ width, height }) => ({
width: Math.round(width / 10) * 10,
height: Math.round(height / 10) * 10
})
const { cb, handle: h, startResize } = await setupDynamic(() => 50)
startResizeAt(startResize, h, 'SE')
simulateMove(53, 27)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.size.width).toBe(350) // 353 -> 350
expect(payload.size.height).toBe(430) // 427 -> 430
expect(payload.position).toBeUndefined()
})
it('applies snap-to-grid on NW (position + size compensation)', async () => {
snapState.shouldSnap = true
// Snap position down to nearest 10
snapState.applySnapToPosition = ({ x, y }) => ({
x: Math.floor(x / 10) * 10,
y: Math.floor(y / 10) * 10
})
snapState.applySnapToSize = ({ width, height }) => ({
width: Math.round(width / 10) * 10,
height: Math.round(height / 10) * 10
})
const { cb, handle: h, startResize } = await setupDynamic(() => 50)
startResizeAt(startResize, h, 'NW')
// delta: x=-53, y=-27 -> raw newX=47, newY=173
// applySnapToPosition floors -> {40, 170}
// size compensated: width += 47-40=7 (-> 360), height += 173-170=3 (-> 430)
// applySnapToSize rounds -> 360, 430
simulateMove(-53, -27)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.position).toEqual({ x: 40, y: 170 })
expect(payload.size).toEqual({ width: 360, height: 430 })
})
it('restores --node-height after measuring (does not clobber state)', async () => {
const { el, handle: h, startResize } = await setupDynamic(() => 150)
el.style.setProperty('--node-height', '400px')
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
// Probe value should be reverted, not left at '0px'
expect(el.style.getPropertyValue('--node-height')).toBe('400px')
})
it('measures with the candidate width applied (responsive breakpoint frame)', async () => {
// Simulate a responsive widget: when width < 350, content reflows to
// 280; when width >= 350, content fits in 150.
const breakpointAwareElement = (() => {
const element = document.createElement('div')
element.setAttribute('data-node-id', 'test-node')
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
element.getBoundingClientRect = () => {
const nodeHeight = element.style.getPropertyValue('--node-height')
if (nodeHeight === '0px') {
const widthVar = element.style.getPropertyValue('--node-width')
const probedWidth = parseFloat(widthVar) || 300
const minH = probedWidth < 350 ? 280 : 150
return { width: probedWidth, height: minH } as DOMRect
}
return { width: 300, height: 400 } as DOMRect
}
return element
})()
const cb = vi.fn<ResizeCallback>()
const h = createMockHandle(breakpointAwareElement)
const { useNodeResize } = await import('./useNodeResize')
const { startResize } = useNodeResize(cb)
// Start at width=300 (still narrow side, but the breakpoint logic
// matters when the user attempts to shrink toward narrow on this frame).
breakpointAwareElement.style.setProperty('--node-width', '400px')
startResizeAt(startResize, h, 'SE')
// First move drives newWidth to 340 (below breakpoint). Probe must use
// 340, not the DOM's currently-applied 400, to return 280.
simulateMove(-60, -300)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.size.height).toBe(280)
})
it('restores --node-width after probing (does not clobber state)', async () => {
const { el, handle: h, startResize } = await setupDynamic(() => 150)
el.style.setProperty('--node-width', '350px')
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
expect(el.style.getPropertyValue('--node-width')).toBe('350px')
})
})
})

View File

@@ -59,17 +59,10 @@ export function useNodeResize(
height: rect.height / scale
}
const measureMinContentHeight = (candidateWidth: number) => {
const savedWidth = nodeElement.style.getPropertyValue('--node-width')
const savedHeight = nodeElement.style.getPropertyValue('--node-height')
nodeElement.style.setProperty('--node-width', `${candidateWidth}px`)
nodeElement.style.setProperty('--node-height', '0px')
const measured = nodeElement.getBoundingClientRect().height
nodeElement.style.setProperty('--node-height', savedHeight || '')
nodeElement.style.setProperty('--node-width', savedWidth || '')
const currentScale = transformState.camera.z || 1
return measured / currentScale
}
const savedNodeHeight = nodeElement.style.getPropertyValue('--node-height')
nodeElement.style.setProperty('--node-height', '0px')
const minContentHeight = nodeElement.getBoundingClientRect().height / scale
nodeElement.style.setProperty('--node-height', savedNodeHeight || '')
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
const startPosition: Point = nodeLayout
@@ -172,12 +165,6 @@ export function useNodeResize(
}
newWidth = minWidth
}
// Re-measure on each move with the candidate width applied: widget
// content (e.g. painter controls) can re-flow taller as width shrinks,
// raising the true minimum. Probing with newWidth — not the DOM's
// current width — keeps the clamp accurate on the frame that crosses
// a responsive breakpoint.
const minContentHeight = measureMinContentHeight(newWidth)
if (newHeight < minContentHeight) {
if (activeCorner.includes('N')) {
newY =

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 B

View File

@@ -42,31 +42,6 @@ export type ConfirmationDialogType =
| 'reinstall'
| 'info'
interface BaseConfirmOptions {
/** Dialog heading */
title: string
/** The main message body */
message: string
/** Displayed as an unordered list immediately below the message body */
itemList?: string[]
hint?: string
}
type ConfirmOptions = BaseConfirmOptions &
(
| {
/** Pre-configured dialog type */
type: 'dirtyClose'
/** Override the deny button label. Defaults to `g.no`. */
denyLabel?: string
}
| {
/** Pre-configured dialog type */
type?: Exclude<ConfirmationDialogType, 'dirtyClose'>
denyLabel?: never
}
)
/**
* Minimal interface for execution error dialogs.
* Satisfied by both ExecutionErrorWsMessage (WebSocket) and ExecutionError (Jobs API).
@@ -269,9 +244,18 @@ export const useDialogService = () => {
message,
type = 'default',
itemList = [],
hint,
denyLabel
}: ConfirmOptions): Promise<boolean | null> {
hint
}: {
/** Dialog heading */
title: string
/** The main message body */
message: string
/** Pre-configured dialog type */
type?: ConfirmationDialogType
/** Displayed as an unordered list immediately below the message body */
itemList?: string[]
hint?: string
}): Promise<boolean | null> {
return new Promise((resolve) => {
const options: ShowDialogOptions = {
key: 'global-prompt',
@@ -282,8 +266,7 @@ export const useDialogService = () => {
type,
itemList,
onConfirm: resolve,
hint,
denyLabel
hint
},
dialogComponentProps: {
onClose: () => resolve(null)

View File

@@ -2,13 +2,34 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockProcessSelect = vi.hoisted(() => vi.fn())
const mockGraphAdd = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({
app: { canvas: undefined },
app: { canvas: undefined, graph: null },
ComfyApp: class {}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
canvas: { processSelect: mockProcessSelect }
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({ activeSubgraph: null }))
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: vi.fn(() => ({ typePrefix: 'SubgraphBlueprint.' }))
}))
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { nextTick } from 'vue'
describe('useLitegraphService().getCanvasCenter', () => {
beforeEach(() => {
@@ -41,3 +62,50 @@ describe('useLitegraphService().getCanvasCenter', () => {
expect(center).toEqual([110, 70])
})
})
describe('useLitegraphService().addNodeOnGraph', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockProcessSelect.mockReset()
mockGraphAdd.mockReset()
Reflect.set(app, 'canvas', undefined)
Reflect.set(app, 'graph', { add: mockGraphAdd })
})
it('selects the node after placing it on the graph', async () => {
const fakeNode = { id: 1, flags: {} }
vi.spyOn(LiteGraph, 'createNode').mockReturnValue(
fakeNode as unknown as LGraphNode
)
const nodeDef = {
name: 'TestNode',
display_name: 'Test Node'
} as unknown as ComfyNodeDefV1
useLitegraphService().addNodeOnGraph(nodeDef, { pos: [0, 0] })
await nextTick()
expect(mockProcessSelect).toHaveBeenCalledOnce()
expect(mockProcessSelect).toHaveBeenCalledWith(fakeNode, undefined)
})
it('does not select the node when placing in ghost mode', async () => {
const fakeNode = { id: 1, flags: {} }
vi.spyOn(LiteGraph, 'createNode').mockReturnValue(
fakeNode as unknown as LGraphNode
)
const nodeDef = {
name: 'TestNode',
display_name: 'Test Node'
} as unknown as ComfyNodeDefV1
useLitegraphService().addNodeOnGraph(
nodeDef,
{ pos: [0, 0] },
{ ghost: true }
)
await nextTick()
expect(mockProcessSelect).not.toHaveBeenCalled()
})
})

View File

@@ -62,6 +62,8 @@ import { useSubgraphStore } from '@/stores/subgraphStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { nextTick } from 'vue'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
isAnimatedOutput,
@@ -944,6 +946,14 @@ export const useLitegraphService = () => {
if (!graph || !node) return null
graph.add(node, addOptions)
if (!addOptions?.ghost) {
const canvas = canvasStore.canvas
if (canvas) {
void nextTick(() => {
canvas.processSelect(node, undefined)
})
}
}
return node
}

View File

@@ -542,88 +542,6 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('setAssetPreview', () => {
it('patches preview_id and preview_url on the matching history asset by name', async () => {
const mockHistory = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
const target = store.historyAssets[1]
const targetName = target.name
store.setAssetPreview(
targetName,
'preview-xyz',
'/assets/preview-xyz/content'
)
const updated = store.historyAssets.find((a) => a.name === targetName)
expect(updated?.preview_id).toBe('preview-xyz')
expect(updated?.preview_url).toBe('/assets/preview-xyz/content')
})
it('matches by name even when ids differ between APIs', async () => {
const mockHistory = [createMockJobItem(0)]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
const historyAssetId = store.historyAssets[0].id
const targetName = store.historyAssets[0].name
// Simulate the cloud-api side using a different id space
store.setAssetPreview(targetName, 'p1', '/assets/p1/content')
expect(store.historyAssets[0].id).toBe(historyAssetId)
expect(store.historyAssets[0].preview_id).toBe('p1')
expect(store.historyAssets[0].preview_url).toBe('/assets/p1/content')
})
it('does nothing when no asset with that name is loaded', async () => {
const mockHistory = Array.from({ length: 2 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
const before = store.historyAssets.map((a) => ({ ...a }))
store.setAssetPreview('does-not-exist.glb', 'p', '/p')
expect(store.historyAssets).toEqual(before)
})
it('only patches the asset whose name matches exactly', async () => {
const mockHistory = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
// Patch using a non-matching prefix; the other assets must stay untouched
store.setAssetPreview('output_1', 'p', '/p')
for (const asset of store.historyAssets) {
expect(asset.preview_id).toBeUndefined()
}
})
it('replaces the asset object so reactivity fires for v-for keyed by id', async () => {
const mockHistory = [createMockJobItem(0)]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
const before = store.historyAssets[0]
const targetName = before.name
store.setAssetPreview(targetName, 'p', '/p')
// setAssetPreview replaces the item with a new object via list[idx] = {...}
// (rather than mutating in place) so Vue triggers dependent watchers.
expect(store.historyAssets[0]).not.toBe(before)
})
})
describe('jobDetailView Support', () => {
it('should include outputCount and allOutputs in user_metadata', async () => {
const mockHistory = Array.from({ length: 5 }, (_, i) =>

View File

@@ -255,32 +255,6 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
/**
* Patch preview_id/preview_url for a single asset already in memory,
* matched by name. Used after persistThumbnail succeeds so an open Asset
* panel reflects the new thumbnail without refetching the whole history.
* Match by name because the cloud assets API and the history API use
* different id spaces; name is the stable cross-API identifier.
*/
const setAssetPreview = (
name: string,
previewId: string,
previewUrl: string
) => {
const patch = (list: AssetItem[]) => {
const idx = list.findIndex((a) => a.name === name)
if (idx < 0) return
list[idx] = {
...list[idx],
preview_id: previewId,
preview_url: previewUrl
}
}
patch(historyAssets.value)
patch(allHistoryItems.value)
patch(inputAssets.value)
}
/**
* Map of asset hash filename to asset item for O(1) lookup
* Cloud assets use asset_hash for the hash-based filename
@@ -808,7 +782,6 @@ export const useAssetsStore = defineStore('assets', () => {
updateInputs,
updateHistory,
loadMoreHistory,
setAssetPreview,
// Input mapping helpers
inputAssetsByFilename,