Compare commits
4 Commits
main
...
fix/node-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2601ee307d | ||
|
|
acc68e478a | ||
|
|
a11a2841ed | ||
|
|
f71fb2e9dd |
36
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
@@ -28,7 +28,7 @@ export default defineConfig({
|
||||
? [['html'], ['json', { outputFile: 'results.json' }]]
|
||||
: 'html',
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 100 }
|
||||
toHaveScreenshot: { maxDiffPixels: 50 }
|
||||
},
|
||||
...maybeLocalOptions,
|
||||
webServer: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 70 KiB |
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -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 ({
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 =
|
||||
|
||||
|
Before Width: | Height: | Size: 223 B |
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||