Compare commits
30 Commits
glary/move
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ee262f78 | ||
|
|
6a8c453659 | ||
|
|
ea277dec4d | ||
|
|
a7aa124c10 | ||
|
|
9c62bbc74a | ||
|
|
f0e16cdf46 | ||
|
|
0658c1ac9c | ||
|
|
997501d8fb | ||
|
|
ab6e5ba094 | ||
|
|
2322a5a497 | ||
|
|
0bc951fd12 | ||
|
|
0446ca7a18 | ||
|
|
653ee48444 | ||
|
|
81d9df61f2 | ||
|
|
f4358cb161 | ||
|
|
5948002dee | ||
|
|
1ab9752af8 | ||
|
|
e469611f6d | ||
|
|
ad6cbf7cbe | ||
|
|
5ebf5e03ae | ||
|
|
d3ab2be695 | ||
|
|
37f0fbcbef | ||
|
|
6ef051f200 | ||
|
|
0788e71394 | ||
|
|
d3f802de10 | ||
|
|
d78c630d36 | ||
|
|
aa4343a98b | ||
|
|
270c7e34f4 | ||
|
|
666684e6e6 | ||
|
|
4484b62854 |
6
.github/workflows/ci-perf-report.yaml
vendored
@@ -54,10 +54,14 @@ jobs:
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
|
||||
# PRs run each test once to keep wall time bounded; main runs 3× so the
|
||||
# baseline saved to perf-data has enough samples to median over noise.
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
|
||||
env:
|
||||
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
|
||||
36
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -20,6 +20,8 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -37,31 +39,33 @@ jobs:
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Detect shard coverage data
|
||||
id: coverage-shards
|
||||
run: |
|
||||
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
|
||||
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Install lcov
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: sudo apt-get install -y -qq lcov
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
|
||||
if [ -z "$LCOV_FILES" ]; then
|
||||
echo "No coverage.lcov files found"
|
||||
touch coverage/playwright/coverage.lcov
|
||||
exit 0
|
||||
fi
|
||||
ADD_ARGS=""
|
||||
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
@@ -91,7 +95,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
@@ -100,6 +104,7 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
@@ -114,6 +119,7 @@ jobs:
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
@@ -122,7 +128,9 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
|
||||
}
|
||||
|
||||
async function navigateAndSettle(page: Page, url: string) {
|
||||
await page.goto(url)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('load')
|
||||
}
|
||||
|
||||
test.describe('Home', { tag: '@visual' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 99 KiB |
@@ -28,7 +28,7 @@ export default defineConfig({
|
||||
? [['html'], ['json', { outputFile: 'results.json' }]]
|
||||
: 'html',
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 50 }
|
||||
toHaveScreenshot: { maxDiffPixels: 100 }
|
||||
},
|
||||
...maybeLocalOptions,
|
||||
webServer: {
|
||||
|
||||
@@ -13,7 +13,7 @@ const { stars } = defineProps<{
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="`ComfyUI on GitHub — ${stars} stars`"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
class="hidden shrink-0 items-center gap-1 lg:flex"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="[{ text: stars }]"
|
||||
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
|
||||
size-class="h-5 sm:h-5"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7"
|
||||
class="bg-primary-comfy-yellow block size-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
|
||||
/>
|
||||
|
||||
@@ -25,7 +25,7 @@ const categories: Category[] = [
|
||||
{
|
||||
label: t('useCase.vfx', locale),
|
||||
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
|
||||
},
|
||||
{
|
||||
label: t('useCase.advertising', locale),
|
||||
|
||||
@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [{ text: 'pricing.plan.creator.feature1' }],
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [{ text: 'pricing.plan.pro.feature1' }]
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
|
||||
@@ -1119,6 +1119,10 @@ const translations = {
|
||||
en: 'Import your own LoRAs',
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
'pricing.plan.pro.summary': {
|
||||
@@ -1143,6 +1147,10 @@ const translations = {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'pricing.enterprise.heading': {
|
||||
|
||||
1449
browser_tests/assets/subgraphs/large-subgraph-80-nodes.json
Normal file
@@ -352,6 +352,12 @@ export class ComfyPage {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async idleFrames(count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await this.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return sleep(ms)
|
||||
}
|
||||
|
||||
@@ -217,13 +217,20 @@ 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 locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const editButton = this.getSubgraphEnterButton(nodeId)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
|
||||
@@ -39,10 +39,32 @@ class ComfyQueueButton {
|
||||
await this.dropdownButton.click()
|
||||
return new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
}
|
||||
|
||||
public async openOptions() {
|
||||
const options = new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
if (!(await options.menu.isVisible())) {
|
||||
await this.dropdownButton.click()
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyQueueButtonOptions {
|
||||
constructor(public readonly page: Page) {}
|
||||
public readonly menu: Locator
|
||||
public readonly modeItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menu = page.getByRole('menu')
|
||||
this.modeItems = this.menu.getByRole('menuitem')
|
||||
}
|
||||
|
||||
public modeItem(name: string) {
|
||||
return this.menu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
public async selectMode(name: string) {
|
||||
await this.modeItem(name).click()
|
||||
}
|
||||
|
||||
public async setMode(mode: AutoQueueMode) {
|
||||
await this.page.evaluate((mode) => {
|
||||
|
||||
@@ -215,11 +215,12 @@ export class AssetHelper {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
let filtered = this.getFilteredAssets(includeTags, excludeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
@@ -296,15 +297,29 @@ export class AssetHelper {
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
private getFilteredAssets(
|
||||
includeTags: string[],
|
||||
excludeTags: string[]
|
||||
): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.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,4 +1,5 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
@@ -13,6 +14,7 @@ export class DragDropHelper {
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
filePath?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
@@ -22,13 +24,14 @@ export class DragDropHelper {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
filePath,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
throw new Error('Must provide either fileName or url')
|
||||
if (!fileName && !filePath && !url)
|
||||
throw new Error('Must provide fileName, filePath, or url')
|
||||
|
||||
const evaluateParams: {
|
||||
dropPosition: Position
|
||||
@@ -39,12 +42,22 @@ export class DragDropHelper {
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
if (fileName || filePath) {
|
||||
const resolvedPath = filePath ?? assetPath(fileName!)
|
||||
const displayName = fileName ?? basename(resolvedPath)
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = readFileSync(resolvedPath)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.fileName = displayName
|
||||
evaluateParams.fileType = getMimeType(displayName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
@@ -148,6 +161,13 @@ export class DragDropHelper {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropFilePath(
|
||||
filePath: string,
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ filePath, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
|
||||
@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.mp3')) return 'audio/mpeg'
|
||||
if (name.endsWith('.flac')) return 'audio/flac'
|
||||
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
|
||||
export function metadataFixturePath(fileName: string): string {
|
||||
return `./src/scripts/metadata/__fixtures__/${fileName}`
|
||||
}
|
||||
|
||||
@@ -133,6 +133,29 @@ 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
|
||||
|
||||
62
browser_tests/tests/metadataWorkflowImport.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
type MetadataFixture = {
|
||||
fileName: string
|
||||
parser: string
|
||||
}
|
||||
|
||||
// Each fixture embeds the same single-KSampler workflow (see
|
||||
// scripts/generate-embedded-metadata-test-files.py), exercising a different
|
||||
// parser in src/scripts/metadata/. Dropping the file should import that
|
||||
// workflow.
|
||||
const FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_metadata.png', parser: 'png' },
|
||||
{ fileName: 'with_metadata.avif', parser: 'avif' },
|
||||
{ fileName: 'with_metadata.webp', parser: 'webp' },
|
||||
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
|
||||
{ fileName: 'with_metadata.flac', parser: 'flac' },
|
||||
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
|
||||
{ fileName: 'with_metadata.opus', parser: 'ogg' },
|
||||
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
|
||||
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
test.describe(
|
||||
'Metadata drop-to-load workflow import',
|
||||
{ tag: ['@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
})
|
||||
|
||||
for (const { fileName, parser } of FIXTURES) {
|
||||
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step(`drop ${fileName} on canvas`, async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFilePath(
|
||||
metadataFixturePath(fileName)
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('graph contains only the embedded KSampler', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const ksamplers =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
expect(
|
||||
ksamplers,
|
||||
'exactly one KSampler should have been loaded from the fixture'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
test('Controls stack label above widget in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
const brushButton = painterWidget.getByText('Brush', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
'tool label should be visible in wide layout'
|
||||
).toBeVisible()
|
||||
|
||||
const wideLabelBox = await toolLabel.boundingBox()
|
||||
const wideBrushBox = await brushButton.boundingBox()
|
||||
expect(
|
||||
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
|
||||
'label should sit to the left of the brush button in wide layout'
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
'tool label should remain visible in compact layout'
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const labelBox = await toolLabel.boundingBox()
|
||||
const brushBox = await brushButton.boundingBox()
|
||||
if (!labelBox || !brushBox) return false
|
||||
return labelBox.y + labelBox.height <= brushBox.y
|
||||
},
|
||||
{
|
||||
message: 'label should stack above the brush button in compact layout'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
|
||||
@@ -351,6 +351,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph transition (enter and exit)',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }, testInfo) => {
|
||||
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
|
||||
// The signal is dominated by N=80 mount cost, so a single sample per
|
||||
// CI invocation is sufficient — early-return on subsequent repeats.
|
||||
if (testInfo.repeatEachIndex > 0) return
|
||||
|
||||
// Load workflow with a subgraph containing 80 interior nodes.
|
||||
// Entering the subgraph unmounts root nodes and mounts all 80 interior
|
||||
// nodes synchronously — this is the bottleneck we're measuring.
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
|
||||
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
// Exit back to root graph before measuring a fresh enter/exit cycle
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.idleFrames(10)
|
||||
|
||||
// Start measuring the enter transition
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('workflow execution', async ({ comfyPage }) => {
|
||||
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
42
browser_tests/tests/previewAsText.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')!
|
||||
node.pos = [500, 200]
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
// Simulate a previous execution: backend returned text and the frontend
|
||||
// populated the preview widget values. The next prompt submission must
|
||||
// NOT echo those values back as inputs (which would change the cache
|
||||
// signature and trigger a redundant re-execution).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.name?.startsWith('preview_')) {
|
||||
widget.value = 'rendered preview content from previous execution'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const previewEntry = Object.values(apiWorkflow).find(
|
||||
(n) => n.class_type === 'PreviewAny'
|
||||
)
|
||||
expect(previewEntry).toBeDefined()
|
||||
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
})
|
||||
63
browser_tests/tests/queueButtonModes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
|
||||
const runOnChangeLabel = queueModeLabels[1]
|
||||
|
||||
test.describe('Queue button modes', { tag: '@ui' }, () => {
|
||||
test('Run button is visible in topbar', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await expect(options.modeItems).toHaveText(queueModeLabels)
|
||||
})
|
||||
|
||||
test('Selecting a non-default mode updates the Run button label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const queueButton = comfyPage.actionbar.queueButton
|
||||
const options = await queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await options.selectMode(runOnChangeLabel)
|
||||
|
||||
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
|
||||
})
|
||||
|
||||
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route('**/api/prompt', async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.actionbar.queueButton.primaryButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
@@ -188,4 +189,79 @@ 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,3 +1,5 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -39,6 +41,19 @@ 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, {
|
||||
@@ -90,6 +105,63 @@ 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
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.18",
|
||||
"version": "1.45.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
|
||||
@@ -19,6 +19,7 @@ import subprocess
|
||||
|
||||
import av
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||
@@ -115,6 +116,15 @@ def generate_av_fixture(
|
||||
report(name)
|
||||
|
||||
|
||||
def generate_png():
|
||||
img = make_1x1_image()
|
||||
info = PngInfo()
|
||||
info.add_text('workflow', WORKFLOW_JSON)
|
||||
info.add_text('prompt', PROMPT_JSON)
|
||||
img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
|
||||
report('with_metadata.png')
|
||||
|
||||
|
||||
def generate_webp():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
@@ -167,6 +177,7 @@ def generate_webm():
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_png()
|
||||
generate_webp()
|
||||
generate_avif()
|
||||
generate_flac()
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<!-- Model Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModelObjects"
|
||||
@@ -48,6 +51,7 @@
|
||||
class="w-[250px]"
|
||||
:label="modelFilterLabel"
|
||||
:options="modelOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -62,6 +66,7 @@
|
||||
v-model="selectedUseCaseObjects"
|
||||
:label="useCaseFilterLabel"
|
||||
:options="useCaseOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -76,6 +81,7 @@
|
||||
v-model="selectedRunsOnObjects"
|
||||
:label="runsOnFilterLabel"
|
||||
:options="runsOnOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -92,6 +98,7 @@
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
:content-style="selectContentStyle"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
|
||||
const loadingTemplate = ref<string | null>(null)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const selectContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
192
src/components/dialog/GlobalDialog.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { close: 'Close' } } },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const Body = defineComponent({
|
||||
name: 'Body',
|
||||
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
|
||||
})
|
||||
|
||||
function mountDialog() {
|
||||
return render(GlobalDialog, {
|
||||
global: { plugins: [PrimeVue, i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('GlobalDialog renderer branching', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the Reka branch when renderer is reka', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-opt-in',
|
||||
title: 'Reka dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the renderer flag on the dialog stack item', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-flag-check',
|
||||
title: 'Reka',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
|
||||
expect(item?.dialogComponentProps.renderer).toBe('reka')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('omits the close button when closable is false', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-not-closable',
|
||||
title: 'No close',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', closable: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the close button by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-closable',
|
||||
title: 'Closable',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the title when headless is true', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-headless',
|
||||
title: 'Hidden title',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', headless: true }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByText('Hidden title')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the title when headless is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-titled',
|
||||
title: 'Visible title',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.getByText('Visible title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the dialog on Escape by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-esc-default',
|
||||
title: 'Esc closes',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-default')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not close on Escape when closable is false', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-esc-blocked',
|
||||
title: 'Esc blocked',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', closable: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +1,106 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<template>
|
||||
<Dialog
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
<div v-if="!item.dialogComponentProps?.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="item in dialogStore.dialogStack" :key="item.key">
|
||||
<Dialog
|
||||
v-if="isRekaItem(item)"
|
||||
:open="item.visible"
|
||||
:modal="item.dialogComponentProps.modal ?? true"
|
||||
@update:open="(open) => onRekaOpenChange(item.key, open)"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
item.dialogComponentProps.closeOnEscape === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
item.dialogComponentProps.dismissableMask === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<DialogHeader v-if="!item.dialogComponentProps.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
<PrimeDialog
|
||||
v-else
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
<div v-if="!item.dialogComponentProps?.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</Dialog>
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</PrimeDialog>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -53,6 +110,14 @@ const teamWorkspacesEnabled = computed(
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function isRekaItem(item: DialogInstance) {
|
||||
return item.dialogComponentProps.renderer === 'reka'
|
||||
}
|
||||
|
||||
function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -42,4 +43,43 @@ 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'"
|
||||
v-if="type !== 'info' && type !== 'dirtyClose'"
|
||||
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" />
|
||||
{{ $t('g.no') }}
|
||||
{{ denyLabel ?? $t('g.no') }}
|
||||
</Button>
|
||||
<Button @click="onConfirm">
|
||||
<Button autofocus @click="onConfirm">
|
||||
<i class="pi pi-save" />
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
@@ -131,6 +131,7 @@ const props = defineProps<{
|
||||
onConfirm: (value?: boolean) => void
|
||||
itemList?: string[]
|
||||
hint?: string
|
||||
denyLabel?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
@@ -15,10 +18,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<KeybindingPresetToolbar
|
||||
:preset-names="presetNames"
|
||||
:content-style="keybindingOverlayContentStyle"
|
||||
@presets-changed="refreshPresetList"
|
||||
/>
|
||||
<DropdownMenu
|
||||
:entries="menuEntries"
|
||||
:style="keybindingOverlayContentStyle"
|
||||
icon="icon-[lucide--ellipsis]"
|
||||
item-class="text-sm gap-2"
|
||||
button-size="unset"
|
||||
@@ -238,6 +243,7 @@
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:style="keybindingOverlayContentStyle"
|
||||
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
<ContextMenuItem
|
||||
@@ -314,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
@@ -337,6 +344,8 @@ const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
const presetNames = ref<string[]>([])
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
{{ displayLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
|
||||
<SelectContent
|
||||
:style="contentStyle"
|
||||
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
|
||||
>
|
||||
<div class="max-w-60">
|
||||
<SelectItem
|
||||
value="default"
|
||||
@@ -46,6 +49,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
|
||||
const { presetNames } = defineProps<{
|
||||
const { presetNames, contentStyle } = defineProps<{
|
||||
presetNames: string[]
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
164
src/components/helpcenter/HelpCenterMenuContent.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const commandStoreExecute = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
staticUrls: { discord: '', github: '' },
|
||||
buildDocsUrl: () => 'https://docs.comfy.org'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
trackHelpCenterOpened: vi.fn(),
|
||||
trackHelpCenterClosed: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => ({
|
||||
releases: [],
|
||||
recentReleases: [],
|
||||
isLoading: false,
|
||||
fetchReleases: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: commandStoreExecute })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => null
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => ({
|
||||
useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({ isNewManagerUI: { value: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons/PuzzleIcon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'PuzzleIconStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(HelpCenterMenuContent, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('HelpCenterMenuContent feedback item', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
commandStoreExecute.mockReset()
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Cloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to Comfy.ContactSupport on OSS builds', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,7 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
@@ -306,7 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
trackResourceClick('help_feedback', isCloud || isNightly)
|
||||
if (isCloud || isNightly) {
|
||||
window.open(
|
||||
'https://form.typeform.com/to/q7azbWPi',
|
||||
buildFeedbackTypeformUrl('help-center'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
@@ -21,20 +21,42 @@
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
/>
|
||||
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
|
||||
@update:model-value="(val) => (selectedSpeed = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in speedOptions"
|
||||
:key="opt.value"
|
||||
:value="String(opt.value)"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
:model-value="
|
||||
selectedAnimation != null ? String(selectedAnimation) : undefined
|
||||
"
|
||||
@update:model-value="(val) => (selectedAnimation = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="anim in animations"
|
||||
:key="anim.index"
|
||||
:value="String(anim.index)"
|
||||
>
|
||||
{{ anim.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full max-w-xs items-center gap-2 px-4">
|
||||
@@ -54,10 +76,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
@@ -5,20 +5,20 @@ import { ref } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="modelValue"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -15,21 +15,22 @@
|
||||
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
|
||||
>
|
||||
<Slider
|
||||
v-model="value"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
const {
|
||||
icon = 'pi-expand',
|
||||
@@ -47,6 +48,12 @@ const {
|
||||
const value = defineModel<number>()
|
||||
const showSlider = ref(false)
|
||||
|
||||
const sliderValue = computed(() => [value.value ?? min])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) value.value = val[0]
|
||||
}
|
||||
|
||||
const toggleSlider = () => {
|
||||
showSlider.value = !showSlider.value
|
||||
}
|
||||
|
||||
@@ -7,38 +7,81 @@ import { createI18n } from 'vue-i18n'
|
||||
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:aria-label="ariaLabel"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -2,34 +2,46 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.viewer.cameraType') }}</label>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
>
|
||||
<Select v-model="cameraType">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="cam in cameras"
|
||||
:key="cam.value"
|
||||
:value="cam.value"
|
||||
>
|
||||
{{ cam.title }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
:model-value="fovSliderValue"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
:aria-label="t('load3d.fov')"
|
||||
@update:model-value="onFovUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -41,4 +53,10 @@ const cameras = [
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const fovSliderValue = computed(() => [fov.value ?? 10])
|
||||
|
||||
function onFovUpdate(val: number[] | undefined) {
|
||||
if (val?.length) fov.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<Select
|
||||
v-model="exportFormat"
|
||||
:options="exportFormats"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
>
|
||||
<Select v-model="exportFormat">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="fmt in exportFormats"
|
||||
:key="fmt.value"
|
||||
:value="fmt.value"
|
||||
>
|
||||
{{ fmt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
@@ -19,10 +26,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
|
||||
@@ -17,19 +17,20 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -3,18 +3,20 @@
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
@@ -28,4 +30,12 @@ const lightIntensityMinimum = useSettingStore().get(
|
||||
const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const sliderValue = computed(() => [
|
||||
lightIntensity.value ?? lightIntensityMinimum
|
||||
])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) lightIntensity.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,20 +9,65 @@ import type {
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
`
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||
@@ -2,31 +2,51 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.upDirection') }}</label>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
:options="upDirectionOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
<Select v-model="upDirection">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in upDirectionOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
:options="materialModeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
<Select v-model="materialMode">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in materialModeOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
|
||||
<label for="showGrid" class="pl-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="showGrid"
|
||||
v-model="showGrid"
|
||||
type="checkbox"
|
||||
name="showGrid"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="showGrid" class="cursor-pointer">
|
||||
{{ $t('load3d.showGrid') }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -58,7 +64,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
334
src/components/painter/WidgetPainter.test.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const sizeHolder = vi.hoisted(() => ({ width: 0, height: 0 }))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useElementSize: () => ({
|
||||
width: ref(sizeHolder.width),
|
||||
height: ref(sizeHolder.height)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const painterHolder = vi.hoisted(() => ({
|
||||
state: null as Record<string, unknown> | null
|
||||
}))
|
||||
|
||||
function createDefaultPainterState() {
|
||||
return {
|
||||
tool: ref('brush'),
|
||||
brushSize: ref(20),
|
||||
brushColor: ref('#000000'),
|
||||
brushOpacity: ref(1),
|
||||
brushHardness: ref(1),
|
||||
backgroundColor: ref('#ffffff'),
|
||||
canvasWidth: ref(512),
|
||||
canvasHeight: ref(512),
|
||||
cursorVisible: ref(true),
|
||||
displayBrushSize: ref(20),
|
||||
inputImageUrl: ref<string | null>(null),
|
||||
isImageInputConnected: ref(false),
|
||||
handlePointerDown: vi.fn(),
|
||||
handlePointerMove: vi.fn(),
|
||||
handlePointerUp: vi.fn(),
|
||||
handlePointerEnter: vi.fn(),
|
||||
handlePointerLeave: vi.fn(),
|
||||
handleInputImageLoad: vi.fn(),
|
||||
handleClear: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/painter/usePainter', () => ({
|
||||
PAINTER_TOOLS: { BRUSH: 'brush', ERASER: 'eraser' } as const,
|
||||
usePainter: () => {
|
||||
if (!painterHolder.state) painterHolder.state = createDefaultPainterState()
|
||||
return painterHolder.state
|
||||
}
|
||||
}))
|
||||
|
||||
import WidgetPainter from './WidgetPainter.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
painter: {
|
||||
tool: 'Tool',
|
||||
brush: 'Brush',
|
||||
eraser: 'Eraser',
|
||||
size: 'Size',
|
||||
color: 'Color',
|
||||
hardness: 'Hardness',
|
||||
width: 'Width',
|
||||
height: 'Height',
|
||||
background: 'Background',
|
||||
clear: 'Clear'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'Button',
|
||||
inheritAttrs: false,
|
||||
template: '<button v-bind="$attrs" type="button"><slot /></button>'
|
||||
})
|
||||
|
||||
const SliderStub = defineComponent({
|
||||
name: 'Slider',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
min: Number,
|
||||
max: Number,
|
||||
step: Number
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<div data-testid="slider-stub" :data-min="min" @click="$emit(\'update:modelValue\', [Number(min) + Number(step ?? 1)])" />'
|
||||
})
|
||||
|
||||
function primePainterState(overrides: Record<string, unknown> = {}) {
|
||||
painterHolder.state = { ...createDefaultPainterState(), ...overrides }
|
||||
}
|
||||
|
||||
function renderWidget(initialModel = '') {
|
||||
const value = ref(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetPainter },
|
||||
setup: () => ({ value }),
|
||||
template: '<WidgetPainter v-model="value" node-id="42" />'
|
||||
})
|
||||
return render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Button: ButtonStub, Slider: SliderStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WidgetPainter', () => {
|
||||
beforeEach(() => {
|
||||
sizeHolder.width = 0
|
||||
sizeHolder.height = 0
|
||||
painterHolder.state = null
|
||||
})
|
||||
|
||||
describe('Label visibility', () => {
|
||||
const allLabels = [
|
||||
'Tool',
|
||||
'Size',
|
||||
'Color',
|
||||
'Hardness',
|
||||
'Width',
|
||||
'Height',
|
||||
'Background'
|
||||
]
|
||||
|
||||
it('renders every label in wide layout (width >= 350)', () => {
|
||||
sizeHolder.width = 600
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('still renders every label in compact layout (width < 350)', () => {
|
||||
sizeHolder.width = 200
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps labels at the responsive boundary (width = 350)', () => {
|
||||
sizeHolder.width = 350
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image-input branch', () => {
|
||||
it('hides canvas-size and background controls when an image is connected', () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
expect(screen.queryByText('Width')).toBeNull()
|
||||
expect(screen.queryByText('Height')).toBeNull()
|
||||
expect(screen.queryByText('Background')).toBeNull()
|
||||
expect(screen.getByTestId('painter-dimension-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the input image inside the canvas container', () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
const container = screen.getByTestId('painter-canvas-container')
|
||||
expect(within(container).getByRole('img')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tool selection', () => {
|
||||
it('hides brush-only controls when the eraser tool is active', () => {
|
||||
primePainterState({ tool: ref('eraser') })
|
||||
renderWidget()
|
||||
|
||||
expect(screen.queryByText('Color')).toBeNull()
|
||||
expect(screen.queryByText('Hardness')).toBeNull()
|
||||
})
|
||||
|
||||
it('updates the active tool when clicking brush/eraser buttons', async () => {
|
||||
const tool = ref<'brush' | 'eraser'>('brush')
|
||||
primePainterState({ tool })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByText('Eraser'))
|
||||
expect(tool.value).toBe('eraser')
|
||||
|
||||
await user.click(screen.getByText('Brush'))
|
||||
expect(tool.value).toBe('brush')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas events', () => {
|
||||
it('forwards pointerdown/up to the composable on click', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByTestId('painter-canvas'))
|
||||
|
||||
const s = painterHolder.state!
|
||||
expect(s.handlePointerDown).toHaveBeenCalled()
|
||||
expect(s.handlePointerUp).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards pointerenter/leave to the composable on hover', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
const canvas = screen.getByTestId('painter-canvas')
|
||||
|
||||
await user.hover(canvas)
|
||||
await user.unhover(canvas)
|
||||
|
||||
const s = painterHolder.state!
|
||||
expect(s.handlePointerEnter).toHaveBeenCalled()
|
||||
expect(s.handlePointerLeave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes handleInputImageLoad when the input image fires load', async () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
const img = within(
|
||||
screen.getByTestId('painter-canvas-container')
|
||||
).getByRole('img')
|
||||
await fireEvent.load(img)
|
||||
expect(painterHolder.state!.handleInputImageLoad).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Control bindings', () => {
|
||||
it('invokes handleClear when the clear button is clicked', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByTestId('painter-clear-button'))
|
||||
expect(painterHolder.state!.handleClear).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates brushSize via the size slider', async () => {
|
||||
const brushSize = ref(20)
|
||||
primePainterState({ brushSize })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const slider = within(screen.getByTestId('painter-size-row')).getByTestId(
|
||||
'slider-stub'
|
||||
)
|
||||
await user.click(slider)
|
||||
expect(brushSize.value).toBe(2) // min=1, step=1 -> emits 2
|
||||
})
|
||||
|
||||
it('updates brushColor via the color picker', async () => {
|
||||
const brushColor = ref('#000000')
|
||||
primePainterState({ brushColor })
|
||||
renderWidget()
|
||||
|
||||
const colorInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('#000000')
|
||||
// <input type="color"> has no userEvent equivalent — fire input directly
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.input(colorInput, { target: { value: '#ff0000' } })
|
||||
expect(brushColor.value.toLowerCase()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('updates brushOpacity via the percent input', async () => {
|
||||
const brushOpacity = ref(1)
|
||||
primePainterState({ brushOpacity })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const percentInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('100')
|
||||
await user.clear(percentInput)
|
||||
await user.type(percentInput, '50')
|
||||
await user.tab() // blur to trigger @change
|
||||
expect(brushOpacity.value).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
it('clamps opacity input to the 0-100 range', async () => {
|
||||
const brushOpacity = ref(1)
|
||||
primePainterState({ brushOpacity })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const percentInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('100')
|
||||
await user.clear(percentInput)
|
||||
await user.type(percentInput, '999')
|
||||
await user.tab()
|
||||
expect(brushOpacity.value).toBe(1) // clamped to 100% -> 1.0
|
||||
})
|
||||
|
||||
it('updates background color via the bg color input', async () => {
|
||||
const backgroundColor = ref('#ffffff')
|
||||
primePainterState({ backgroundColor })
|
||||
renderWidget()
|
||||
|
||||
const bgInput = within(
|
||||
screen.getByTestId('painter-bg-color-row')
|
||||
).getByDisplayValue('#ffffff')
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.input(bgInput, { target: { value: '#00ff00' } })
|
||||
expect(backgroundColor.value.toLowerCase()).toBe('#00ff00')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
/>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
data-testid="painter-canvas"
|
||||
class="absolute inset-0 size-full cursor-none touch-none"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@@ -58,7 +59,6 @@
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.tool') }}
|
||||
@@ -99,7 +99,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.size') }}
|
||||
@@ -126,7 +125,6 @@
|
||||
|
||||
<template v-if="tool === PAINTER_TOOLS.BRUSH">
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.color') }}
|
||||
@@ -170,7 +168,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.hardness') }}
|
||||
@@ -199,7 +196,6 @@
|
||||
|
||||
<template v-if="!isImageInputConnected">
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.width') }}
|
||||
@@ -222,7 +218,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.height') }}
|
||||
@@ -245,7 +240,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.background') }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
|
||||
@@ -21,13 +21,19 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMouseInElement: () => ({
|
||||
elementX: ref(50),
|
||||
elementWidth: ref(100),
|
||||
isOutside: ref(false)
|
||||
})
|
||||
}))
|
||||
const mockRect = (el: HTMLElement, width: number) => {
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: 100,
|
||||
width,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
describe('CompareSliderThumbnail', () => {
|
||||
const renderThumbnail = (props = {}) => {
|
||||
@@ -74,4 +80,44 @@ describe('CompareSliderThumbnail', () => {
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('50%')
|
||||
})
|
||||
|
||||
it('updates slider position on mousemove', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 200)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.pointer({ target: container, coords: { clientX: 50 } })
|
||||
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('25%')
|
||||
})
|
||||
|
||||
it('clamps slider position to [0, 100] when pointer overshoots', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 200)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.pointer({ target: container, coords: { clientX: -10 } })
|
||||
let divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('0%')
|
||||
|
||||
await user.pointer({ target: container, coords: { clientX: 250 } })
|
||||
divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('100%')
|
||||
})
|
||||
|
||||
it('ignores mousemove when container has zero width', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 0)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.pointer({ target: container, coords: { clientX: 50 } })
|
||||
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('50%')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<div ref="containerRef" class="absolute inset-0">
|
||||
<div
|
||||
data-testid="compare-slider-container"
|
||||
class="absolute inset-0"
|
||||
@mousemove="updateSliderPosition"
|
||||
>
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
@@ -34,8 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
@@ -57,18 +60,20 @@ const isVideoType =
|
||||
false
|
||||
|
||||
const sliderPosition = ref(SLIDER_START_POSITION)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
|
||||
|
||||
// Update slider position based on mouse position when hovered
|
||||
watch(
|
||||
[() => isHovered, elementX, elementWidth, isOutside],
|
||||
([isHovered, x, width, outside]) => {
|
||||
if (!isHovered) return
|
||||
if (!outside) {
|
||||
sliderPosition.value = (x / width) * 100
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* Update slider position from a local mousemove. Scoped to currentTarget so
|
||||
* only the hovered card reads its rect — unlike useMouseInElement which
|
||||
* attaches a global mousemove listener and fires for every mounted instance.
|
||||
*/
|
||||
function updateSliderPosition(event: MouseEvent) {
|
||||
const el = event.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width === 0) return
|
||||
// Clamp to [0, 100] — subpixel rounding or stale rects on hover-in can
|
||||
// push the raw percentage slightly out of range, which would offset the
|
||||
// divider past the container or invert the overlay's clipPath.
|
||||
const raw = ((event.clientX - rect.left) / rect.width) * 100
|
||||
sliderPosition.value = Math.max(0, Math.min(100, raw))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<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
|
||||
>
|
||||
|
||||
186
src/components/topbar/WorkflowTabs.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import WorkflowTabs from './WorkflowTabs.vue'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: { value: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
isOverflowing: { value: false },
|
||||
disposed: { value: false },
|
||||
checkOverflow: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
openWorkflow: vi.fn(),
|
||||
closeWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () =>
|
||||
reactive({
|
||||
openWorkflows: [],
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mouseDownUtil', () => ({
|
||||
whileMouseDown: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowOverflowMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'WorkflowOverflowMenuStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowTab.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'WorkflowTabStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./CurrentUserButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CurrentUserButtonStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./LoginButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'LoginButtonStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(WorkflowTabs, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
tooltip: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('WorkflowTabs feedback button', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
tabBarLayout.value = 'Default'
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the feedback button when the legacy tab bar is active', () => {
|
||||
distribution.isCloud = true
|
||||
tabBarLayout.value = 'Legacy'
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -119,7 +119,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -152,9 +152,12 @@ const isIntegratedTabBar = computed(
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
function openFeedback() {
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('topbar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
188
src/components/ui/dialog/Dialog.stories.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { FOR_STORIES } from '@/components/ui/dialog/dialog.variants'
|
||||
|
||||
const { sizes } = FOR_STORIES
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Dialog/Dialog',
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: sizes,
|
||||
defaultValue: 'md'
|
||||
}
|
||||
},
|
||||
args: {
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogClose
|
||||
},
|
||||
setup() {
|
||||
const open = ref(false)
|
||||
return { args, open }
|
||||
},
|
||||
template: `
|
||||
<Button @click="open = true">Open dialog</Button>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent :size="args.size">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="px-4 py-2">
|
||||
<DialogDescription>
|
||||
This action cannot be undone. The selected items will be permanently removed.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="textonly" @click="open = false">Cancel</Button>
|
||||
<Button variant="destructive" @click="open = false">Delete</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose
|
||||
},
|
||||
setup() {
|
||||
const open = ref(false)
|
||||
return { args, open }
|
||||
},
|
||||
template: `
|
||||
<Button @click="open = true">Open long content</Button>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent :size="args.size">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Long content scrolls</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="px-4 py-2 space-y-2 overflow-auto">
|
||||
<p v-for="n in 30" :key="n">
|
||||
Paragraph {{ n }} — the dialog body should scroll independently
|
||||
while the header and footer stay pinned.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Headless: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent
|
||||
},
|
||||
setup() {
|
||||
const open = ref(false)
|
||||
return { open }
|
||||
},
|
||||
template: `
|
||||
<Button @click="open = true">Open headless</Button>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent size="sm" class="p-6">
|
||||
<p class="text-sm">No header, no footer — fully custom content.</p>
|
||||
<Button class="mt-4" @click="open = false">Close</Button>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose
|
||||
},
|
||||
setup() {
|
||||
const openSize = ref<string | null>(null)
|
||||
return { openSize, sizes }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button v-for="s in sizes" :key="s" @click="openSize = s">{{ s }}</Button>
|
||||
</div>
|
||||
<Dialog
|
||||
v-for="s in sizes"
|
||||
:key="s"
|
||||
:open="openSize === s"
|
||||
@update:open="(o) => { if (!o) openSize = null }"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent :size="s">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Size: {{ s }}</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="px-4 py-2 text-sm">
|
||||
The {{ s }} size variant.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
`
|
||||
})
|
||||
}
|
||||
13
src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emit = defineEmits<DialogRootEmits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="props" @update:open="(open) => emit('update:open', open)">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
18
src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogClose } from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose as-child>
|
||||
<slot>
|
||||
<Button :aria-label="t('g.close')" size="icon" variant="muted-textonly">
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</slot>
|
||||
</DialogClose>
|
||||
</template>
|
||||
33
src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import { DialogContent, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { DialogContentSize } from './dialog.variants'
|
||||
import { dialogContentVariants } from './dialog.variants'
|
||||
|
||||
const {
|
||||
size,
|
||||
class: customClass = '',
|
||||
...restProps
|
||||
} = defineProps<
|
||||
DialogContentProps & {
|
||||
size?: DialogContentSize
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="cn(dialogContentVariants({ size }), customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
</template>
|
||||
20
src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import { DialogDescription } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '', ...delegated } = defineProps<
|
||||
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
v-bind="delegated"
|
||||
:class="cn('text-sm text-muted-foreground', customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
22
src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex shrink-0 items-center justify-end gap-2 px-4 pt-2 pb-4',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex shrink-0 items-center justify-between gap-2 px-4 pt-4 pb-2',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '', ...delegated } = defineProps<
|
||||
DialogOverlayProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
v-bind="delegated"
|
||||
:class="
|
||||
cn(
|
||||
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
12
src/components/ui/dialog/DialogPortal.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogPortalProps } from 'reka-ui'
|
||||
import { DialogPortal } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogPortalProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal v-bind="props">
|
||||
<slot />
|
||||
</DialogPortal>
|
||||
</template>
|
||||
20
src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import { DialogTitle } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '', ...delegated } = defineProps<
|
||||
DialogTitleProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
v-bind="delegated"
|
||||
:class="cn('text-base font-semibold text-base-foreground', customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
32
src/components/ui/dialog/dialog.variants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const dialogContentVariants = cva({
|
||||
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-xl',
|
||||
lg: 'sm:max-w-3xl',
|
||||
xl: 'sm:max-w-5xl',
|
||||
full: 'sm:max-w-[calc(100vw-1rem)]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
|
||||
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
|
||||
|
||||
export type DialogContentSize = NonNullable<DialogContentVariants['size']>
|
||||
|
||||
const sizes = [
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'xl',
|
||||
'full'
|
||||
] as const satisfies Array<DialogContentSize>
|
||||
|
||||
export const FOR_STORIES = { sizes } as const
|
||||
@@ -50,7 +50,7 @@
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="popoverStyle"
|
||||
:style="[popoverStyle, contentStyle]"
|
||||
:class="selectContentClass"
|
||||
@keydown="onContentKeydown"
|
||||
@focus-outside="preventFocusDismiss"
|
||||
@@ -152,6 +152,7 @@ import {
|
||||
ComboboxViewport
|
||||
} from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -183,7 +184,8 @@ const {
|
||||
searchPlaceholder,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
popoverMaxWidth,
|
||||
contentStyle
|
||||
} = defineProps<{
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
@@ -207,6 +209,7 @@ const {
|
||||
popoverMinWidth?: string
|
||||
/** Maximum width of the popover (default: auto) */
|
||||
popoverMaxWidth?: string
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const selectedItems = defineModel<SelectOption[]>({
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:style="contentStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
@@ -99,7 +100,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { HTMLAttributes, StyleValue } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
@@ -132,7 +133,8 @@ const {
|
||||
suggestions = [],
|
||||
optionLabel,
|
||||
optionKey,
|
||||
class: className
|
||||
class: className,
|
||||
contentStyle
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
@@ -144,6 +146,7 @@ const {
|
||||
optionLabel?: keyof T & string
|
||||
optionKey?: keyof T & string
|
||||
class?: HTMLAttributes['class']
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="optionStyle"
|
||||
:style="[optionStyle, contentStyle]"
|
||||
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
|
||||
@keydown="onContentKeydown"
|
||||
>
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
@@ -108,7 +109,8 @@ const {
|
||||
disabled = false,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
popoverMaxWidth,
|
||||
contentStyle
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
options?: SelectOption[]
|
||||
@@ -126,6 +128,7 @@ const {
|
||||
popoverMinWidth?: string
|
||||
/** Maximum width of the popover (default: auto) */
|
||||
popoverMaxWidth?: string
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
|
||||
195
src/composables/auth/useAuthActions.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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,6 +9,7 @@ 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'
|
||||
@@ -53,14 +54,30 @@ export const useAuthActions = () => {
|
||||
|
||||
const logout = wrapWithErrorHandlingAsync(async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
if (workflowStore.modifiedWorkflows.length > 0) {
|
||||
const modifiedWorkflows = workflowStore.modifiedWorkflows
|
||||
if (modifiedWorkflows.length > 0) {
|
||||
const dialogService = useDialogService()
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('auth.signOut.unsavedChangesTitle'),
|
||||
message: t('auth.signOut.unsavedChangesMessage'),
|
||||
type: 'dirtyClose'
|
||||
type: 'dirtyClose',
|
||||
denyLabel: t('auth.signOut.signOutAnyway')
|
||||
})
|
||||
if (!confirmed) return
|
||||
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 })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
@@ -233,6 +233,35 @@ describe('handleDrawing', () => {
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
rafSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sets DestinationOut composition when tool is eraser during move', async () => {
|
||||
mockStoreDef.currentTool = 'eraser'
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
const { startDrawing, handleDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await handleDrawing(makePointerEvent(55, 55))
|
||||
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
|
||||
'destination-out'
|
||||
)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sets DestinationOut composition when right mouse button held during move', async () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
const { startDrawing, handleDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await handleDrawing(makePointerEvent(55, 55, { buttons: 2 }))
|
||||
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
|
||||
'destination-out'
|
||||
)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawEnd canvas visibility', () => {
|
||||
@@ -272,6 +301,36 @@ describe('drawEnd', () => {
|
||||
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, false)
|
||||
})
|
||||
|
||||
it('passes isRgb=true to compositeStroke when active layer is rgb', async () => {
|
||||
mockStoreDef.activeLayer = 'rgb'
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(true, false)
|
||||
})
|
||||
|
||||
it('passes isErasing=true to compositeStroke when tool is eraser', async () => {
|
||||
mockStoreDef.currentTool = 'eraser'
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
|
||||
it('restores mask canvas opacity after drawing on mask layer', async () => {
|
||||
mockStoreDef.activeLayer = 'mask'
|
||||
const mockMaskCanvas = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
style: { opacity: '' }
|
||||
} as unknown as HTMLCanvasElement
|
||||
mockStoreDef.maskCanvas = mockMaskCanvas
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(mockMaskCanvas.style.opacity).toBe(String(mockStoreDef.maskOpacity))
|
||||
})
|
||||
|
||||
it('calls clearPreview to clean up the GPU overlay', async () => {
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
|
||||
@@ -74,6 +74,11 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
|
||||
isAssetPreviewSupported: vi.fn(() => false),
|
||||
persistThumbnail: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
describe('useLoad3d', () => {
|
||||
let mockLoad3d: Partial<Load3d>
|
||||
let mockNode: LGraphNode
|
||||
@@ -181,6 +186,12 @@ describe('useLoad3d', () => {
|
||||
resetGizmoTransform: vi.fn(),
|
||||
applyGizmoTransform: vi.fn(),
|
||||
fitToViewer: vi.fn(),
|
||||
getGizmoTransform: vi.fn().mockReturnValue({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}),
|
||||
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
|
||||
setAnimationTime: vi.fn(),
|
||||
renderer: {
|
||||
domElement: mockCanvas
|
||||
@@ -832,6 +843,7 @@ describe('useLoad3d', () => {
|
||||
'backgroundImageLoadingEnd',
|
||||
'modelLoadingStart',
|
||||
'modelLoadingEnd',
|
||||
'modelReady',
|
||||
'skeletonVisibilityChange',
|
||||
'exportLoadingStart',
|
||||
'exportLoadingEnd',
|
||||
@@ -1382,4 +1394,169 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleFitToViewer', () => {
|
||||
it('persists post-fit position and scale into modelConfig.gizmo so reload reapplies the transform via applyGizmoConfigToLoad3d', async () => {
|
||||
const fitTransform = {
|
||||
position: { x: 0, y: -1.25, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 0.42, y: 0.42, z: 0.42 }
|
||||
}
|
||||
vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue(fitTransform)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleFitToViewer()
|
||||
|
||||
expect(mockLoad3d.fitToViewer).toHaveBeenCalledOnce()
|
||||
expect(composable.modelConfig.value.gizmo!.position).toEqual(
|
||||
fitTransform.position
|
||||
)
|
||||
expect(composable.modelConfig.value.gizmo!.scale).toEqual(
|
||||
fitTransform.scale
|
||||
)
|
||||
// Rotation is owned by upDirection — fit must not overwrite it.
|
||||
expect(composable.modelConfig.value.gizmo!.rotation).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op when load3d is not initialized', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
// No initializeLoad3d() call.
|
||||
composable.handleFitToViewer()
|
||||
expect(mockLoad3d.fitToViewer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw when modelConfig.gizmo is missing', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
composable.modelConfig.value.gizmo = undefined
|
||||
|
||||
expect(() => composable.handleFitToViewer()).not.toThrow()
|
||||
expect(mockLoad3d.fitToViewer).toHaveBeenCalledOnce()
|
||||
// Without a gizmo slot we silently skip persistence — getGizmoTransform
|
||||
// is not called because the early-return saves the read.
|
||||
expect(mockLoad3d.getGizmoTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modelReady event handler (thumbnail capture)', () => {
|
||||
let originalFetch: typeof globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
blob: () => Promise.resolve(new Blob(['x'], { type: 'image/png' }))
|
||||
} as unknown as Response)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
async function getModelReadyHandler() {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
const call = vi
|
||||
.mocked(mockLoad3d.addEventListener!)
|
||||
.mock.calls.find(([event]) => event === 'modelReady')
|
||||
return { composable, handler: call![1] as () => void }
|
||||
}
|
||||
|
||||
it('registers a modelReady listener separate from modelLoadingEnd', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const events = vi
|
||||
.mocked(mockLoad3d.addEventListener!)
|
||||
.mock.calls.map(([event]) => event)
|
||||
expect(events).toContain('modelReady')
|
||||
expect(events).toContain('modelLoadingEnd')
|
||||
expect(composable).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not call captureThumbnail when asset preview is unsupported', async () => {
|
||||
const { isAssetPreviewSupported } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(false)
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
handler()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures thumbnail and persists it when asset preview is supported and a model_file widget has a value', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'',
|
||||
'cube.glb'
|
||||
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
|
||||
|
||||
const modelWidget = {
|
||||
name: 'model_file',
|
||||
value: 'cube.glb [output]'
|
||||
} as unknown as IWidget
|
||||
mockNode.widgets = [modelWidget]
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
handler()
|
||||
// Two awaits: one for captureThumbnail, one for fetch().blob() chain.
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockLoad3d.captureThumbnail).toHaveBeenCalledWith(256, 256)
|
||||
expect(persistThumbnail).toHaveBeenCalledWith(
|
||||
'cube.glb',
|
||||
expect.any(Blob)
|
||||
)
|
||||
})
|
||||
|
||||
it('skips persistence when the model widget has no value', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
mockNode.widgets = [
|
||||
{ name: 'model_file', value: '' } as unknown as IWidget
|
||||
]
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
handler()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
|
||||
expect(persistThumbnail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows captureThumbnail rejections silently', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'',
|
||||
'broken.glb'
|
||||
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
|
||||
vi.mocked(mockLoad3d.captureThumbnail!).mockRejectedValue(
|
||||
new Error('webgl context lost')
|
||||
)
|
||||
mockNode.widgets = [
|
||||
{ name: 'model_file', value: 'broken.glb' } as unknown as IWidget
|
||||
]
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
expect(() => handler()).not.toThrow()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(persistThumbnail).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -818,24 +818,24 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
applyGizmoConfigToLoad3d()
|
||||
isFirstModelLoad = false
|
||||
},
|
||||
modelReady: () => {
|
||||
if (!load3d || !isAssetPreviewSupported()) return
|
||||
|
||||
if (load3d && isAssetPreviewSupported()) {
|
||||
const node = nodeRef.value
|
||||
const node = nodeRef.value
|
||||
const modelWidget = node?.widgets?.find(
|
||||
(w) => w.name === 'model_file' || w.name === 'image'
|
||||
)
|
||||
const value = modelWidget?.value
|
||||
if (typeof value !== 'string' || !value) return
|
||||
|
||||
const modelWidget = node?.widgets?.find(
|
||||
(w) => w.name === 'model_file' || w.name === 'image'
|
||||
)
|
||||
const value = modelWidget?.value
|
||||
if (typeof value === 'string' && value) {
|
||||
const filename = value.trim().replace(/\s*\[output\]$/, '')
|
||||
const modelName = Load3dUtils.splitFilePath(filename)[1]
|
||||
load3d
|
||||
.captureThumbnail(256, 256)
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(modelName, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
const filename = value.trim().replace(/\s*\[output\]$/, '')
|
||||
const modelName = Load3dUtils.splitFilePath(filename)[1]
|
||||
load3d
|
||||
.captureThumbnail(256, 256)
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(modelName, blob))
|
||||
.catch(() => {})
|
||||
},
|
||||
skeletonVisibilityChange: (value: boolean) => {
|
||||
modelConfig.value.showSkeleton = value
|
||||
@@ -911,9 +911,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
|
||||
const handleFitToViewer = () => {
|
||||
if (load3d) {
|
||||
load3d.fitToViewer()
|
||||
}
|
||||
if (!load3d) return
|
||||
load3d.fitToViewer()
|
||||
|
||||
if (!modelConfig.value.gizmo) return
|
||||
const transform = load3d.getGizmoTransform()
|
||||
modelConfig.value.gizmo.position = transform.position
|
||||
modelConfig.value.gizmo.scale = transform.scale
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
|
||||
91
src/composables/usePopoverSizing.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
|
||||
describe('usePrimeVueOverlayChildStyle', () => {
|
||||
let scope: EffectScope | undefined
|
||||
|
||||
function mountComposable() {
|
||||
scope = effectScope()
|
||||
let composable: ReturnType<typeof usePrimeVueOverlayChildStyle> | undefined
|
||||
|
||||
scope.run(() => {
|
||||
composable = usePrimeVueOverlayChildStyle()
|
||||
})
|
||||
|
||||
if (!composable) {
|
||||
throw new Error('Failed to mount composable')
|
||||
}
|
||||
|
||||
return composable
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('preserves existing stacking when there is no PrimeVue parent overlay', () => {
|
||||
const { overlayScopeRef, contentStyle } = mountComposable()
|
||||
|
||||
overlayScopeRef.value = document.createElement('div')
|
||||
|
||||
expect(contentStyle.value).toEqual({})
|
||||
})
|
||||
|
||||
it('renders above the closest PrimeVue dialog mask', () => {
|
||||
const { overlayScopeRef, contentStyle } = mountComposable()
|
||||
|
||||
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 5000)
|
||||
|
||||
expect(contentStyle.value).toEqual({ zIndex: 5001 })
|
||||
})
|
||||
|
||||
it('renders above the closest PrimeVue overlay mask', () => {
|
||||
const { overlayScopeRef, contentStyle } = mountComposable()
|
||||
|
||||
overlayScopeRef.value = appendPrimeVueOverlay('p-overlay-mask', 4200)
|
||||
|
||||
expect(contentStyle.value).toEqual({ zIndex: 4201 })
|
||||
})
|
||||
|
||||
it('does not drop below the Reka select overlay z-index floor', () => {
|
||||
const { overlayScopeRef, contentStyle } = mountComposable()
|
||||
|
||||
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 1200)
|
||||
|
||||
expect(contentStyle.value).toEqual({ zIndex: 3000 })
|
||||
})
|
||||
|
||||
it('preserves existing stacking when the PrimeVue overlay z-index is not numeric', () => {
|
||||
const { overlayScopeRef, contentStyle } = mountComposable()
|
||||
|
||||
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask')
|
||||
|
||||
expect(contentStyle.value).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
function appendPrimeVueOverlay(
|
||||
className: string,
|
||||
zIndex?: number
|
||||
): HTMLElement {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = className
|
||||
if (zIndex !== undefined) {
|
||||
overlay.style.zIndex = String(zIndex)
|
||||
}
|
||||
|
||||
const anchor = document.createElement('div')
|
||||
overlay.append(anchor)
|
||||
document.body.append(overlay)
|
||||
|
||||
return anchor
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { computed } from 'vue'
|
||||
import type { CSSProperties, ComputedRef } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CSSProperties, ComputedRef, Ref } from 'vue'
|
||||
|
||||
interface PopoverSizeOptions {
|
||||
minWidth?: string
|
||||
maxWidth?: string
|
||||
}
|
||||
|
||||
// Matches the highest existing Reka popover z-index (e.g. z-3000 on SearchAutocomplete).
|
||||
const PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR = 3000
|
||||
|
||||
/**
|
||||
* Composable for managing popover sizing styles
|
||||
* @param options Popover size configuration
|
||||
@@ -29,3 +32,30 @@ export function usePopoverSizing(
|
||||
return style
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps portaled Reka popovers above their containing PrimeVue dialog.
|
||||
*
|
||||
* This is a temporary bridge while PrimeVue dialogs and controls are
|
||||
* incrementally migrated to Reka UI. Once the affected PrimeVue parents are
|
||||
* migrated, this helper should be removed with the compatibility patch.
|
||||
*/
|
||||
export function usePrimeVueOverlayChildStyle(): {
|
||||
overlayScopeRef: Ref<HTMLElement | null>
|
||||
contentStyle: ComputedRef<CSSProperties>
|
||||
} {
|
||||
const overlayScopeRef = ref<HTMLElement | null>(null)
|
||||
const contentStyle = computed<CSSProperties>(() => {
|
||||
const overlay = overlayScopeRef.value?.closest(
|
||||
'.p-dialog-mask, .p-overlay-mask'
|
||||
)
|
||||
if (!overlay) return {}
|
||||
|
||||
const zIndex = Number.parseInt(getComputedStyle(overlay).zIndex, 10)
|
||||
if (!Number.isFinite(zIndex)) return {}
|
||||
|
||||
return { zIndex: Math.max(PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR, zIndex + 1) }
|
||||
})
|
||||
|
||||
return { overlayScopeRef, contentStyle }
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const PRIMITIVE_NODE_CATEGORY = 'utils/primitive'
|
||||
83
src/extensions/core/cloudFeedbackTopbarButton.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
const registerExtension = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
describe('cloudFeedbackTopbarButton', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
registerExtension.mockReset()
|
||||
distribution.isCloud = false
|
||||
distribution.isNightly = false
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
function getRegisteredButtons(): ActionBarButton[] {
|
||||
expect(registerExtension).toHaveBeenCalledTimes(1)
|
||||
const extension = registerExtension.mock.calls[0]?.[0] as {
|
||||
actionBarButtons: ActionBarButton[]
|
||||
}
|
||||
return extension.actionBarButtons
|
||||
}
|
||||
|
||||
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
|
||||
tabBarLayout.value = 'Legacy'
|
||||
distribution.isCloud = true
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
const buttons = getRegisteredButtons()
|
||||
expect(buttons).toHaveLength(1)
|
||||
buttons[0].onClick?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1)
|
||||
const [url, target, features] = openSpy.mock.calls[0]
|
||||
expect(url).toBe(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
|
||||
)
|
||||
expect(target).toBe('_blank')
|
||||
expect(features).toBe('noopener,noreferrer')
|
||||
})
|
||||
|
||||
it('only registers the action bar button when the tab bar is Legacy', async () => {
|
||||
tabBarLayout.value = 'Default'
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
expect(getRegisteredButtons()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,20 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const TYPEFORM_SURVEY_URL = 'https://form.typeform.com/to/q7azbWPi'
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
window.open(TYPEFORM_SURVEY_URL, '_blank', 'noopener,noreferrer')
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('action-bar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
487
src/extensions/core/load3d.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const {
|
||||
registerExtensionMock,
|
||||
waitForLoad3dMock,
|
||||
configureMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension: registerExtensionMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({
|
||||
getLoad3d: getLoad3dMock,
|
||||
handleViewerClose: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock }),
|
||||
nodeToLoad3dMap: new Map()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configure = configureMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
|
||||
createExportMenuItems: vi.fn(() => [{ content: 'Export' }])
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn((p: string) => ['', p]),
|
||||
getResourceURL: vi.fn(() => '/view'),
|
||||
uploadFile: vi.fn(),
|
||||
uploadMultipleFiles: vi.fn(),
|
||||
uploadTempImage: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/constants', () => ({
|
||||
SUPPORTED_EXTENSIONS_ACCEPT: '.glb,.gltf'
|
||||
}))
|
||||
|
||||
vi.mock('@/components/load3d/Load3D.vue', () => ({ default: {} }))
|
||||
vi.mock('@/components/load3d/Load3dViewerContent.vue', () => ({ default: {} }))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn(),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { apiURL: (p: string) => p }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: {} } },
|
||||
ComfyApp: { copyToClipspace: vi.fn(), clipspace_return_node: null }
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: toastAddAlertMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLoad3dNode: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: { ContextMenu: vi.fn() }
|
||||
}))
|
||||
|
||||
type ExtCreated = ComfyExtension & {
|
||||
nodeCreated: (node: LGraphNode) => Promise<void>
|
||||
beforeRegisterNodeDef: (
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) => Promise<void>
|
||||
getNodeMenuItems: (node: LGraphNode) => unknown[]
|
||||
}
|
||||
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
serializeValue?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
function makePreview3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3D' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'model_file', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLoad3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
|
||||
size: [300, 600],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
setCameraFromMatrices: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
function makeLoad3dMock(): FakeLoad3d {
|
||||
return {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
setCameraFromMatrices: vi.fn(),
|
||||
setBackgroundImage: vi.fn(),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
}
|
||||
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3D.beforeRegisterNodeDef', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('rewrites the image input spec for Preview3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const nodeData = {
|
||||
name: 'Preview3D',
|
||||
input: { required: { image: ['STRING', {}] } }
|
||||
} as unknown as ComfyNodeDef
|
||||
|
||||
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(nodeData.input!.required!.image).toEqual(['PREVIEW_3D'])
|
||||
})
|
||||
|
||||
it('leaves non-Preview3D node defs unchanged', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const nodeData = {
|
||||
name: 'Load3D',
|
||||
input: { required: { image: ['STRING', {}] } }
|
||||
} as unknown as ComfyNodeDef
|
||||
|
||||
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(nodeData.input!.required!.image).toEqual(['STRING', {}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3D.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Preview3D', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not configure on creation when no Last Time Model File is persisted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores via configure with persisted cameraState when Last Time Model File is set', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const cameraState = { position: [1, 2, 3] }
|
||||
const node = makePreview3DNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'prev/model.glb',
|
||||
'Camera Config': { cameraType: 'perspective', state: cameraState }
|
||||
}
|
||||
})
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'output',
|
||||
modelWidget: expect.objectContaining({ value: 'prev/model.glb' }),
|
||||
cameraState,
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
it('persists Last Time Model File and normalizes backslashes after onExecuted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
|
||||
expect(configureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
loadFolder: 'output',
|
||||
silentOnNotFound: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards bgImagePath to load3d.setBackgroundImage on execute', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', undefined, 'bg.png'] })
|
||||
|
||||
expect(load3d.setBackgroundImage).toHaveBeenCalledWith('bg.png')
|
||||
})
|
||||
|
||||
it('applies camera matrices when load3d generation is unchanged', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const extrinsics = [
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
[0, 0, 0, 1]
|
||||
]
|
||||
const intrinsics = [
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, 1]
|
||||
]
|
||||
|
||||
const node = makePreview3DNode()
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, undefined, extrinsics, intrinsics]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraFromMatrices).toHaveBeenCalledWith(
|
||||
extrinsics,
|
||||
intrinsics
|
||||
)
|
||||
})
|
||||
|
||||
it('skips camera matrix application when load3d generation changes before whenLoadIdle resolves', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
let resolveIdle: () => void = () => {}
|
||||
load3d.whenLoadIdle = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveIdle = resolve
|
||||
})
|
||||
)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const node = makePreview3DNode()
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, undefined, [[1]], [[1]]]
|
||||
})
|
||||
|
||||
load3d.currentLoadGeneration = 6
|
||||
resolveIdle()
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraFromMatrices).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error toast when onExecuted has no file path', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Load3D', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('configures with the input folder and width/height widgets', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: 'model.glb' },
|
||||
{ name: 'width', value: 1024 },
|
||||
{ name: 'height', value: 768 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'input',
|
||||
modelWidget: widgets[0],
|
||||
cameraState: undefined,
|
||||
width: widgets[1],
|
||||
height: widgets[2]
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches a serializeValue function to the scene widget', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(typeof widgets[3].serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('skips configure when required widgets are missing', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({
|
||||
widgets: [{ name: 'model_file', value: '' }]
|
||||
})
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('Comfy.Load3D returns [] for non-Load3D nodes', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(load3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('Comfy.Preview3D returns [] for non-Preview3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when no load3d instance exists for the node', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue(null)
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns export menu items for non-splat 3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([{ content: 'Export' }])
|
||||
})
|
||||
})
|
||||
@@ -6,17 +6,22 @@ import Load3DConfiguration, {
|
||||
} from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoConfig,
|
||||
ModelConfig
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} 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: vi.fn()
|
||||
})
|
||||
useSettingStore: () => ({ get: settingsGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -43,13 +48,22 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
type WithPrivate = { loadModelConfig(): ModelConfig }
|
||||
type WithPrivate = {
|
||||
loadModelConfig(): ModelConfig
|
||||
loadSceneConfig(): SceneConfig
|
||||
loadCameraConfig(): CameraConfig
|
||||
loadLightConfig(): LightConfig
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -58,6 +72,13 @@ 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()
|
||||
@@ -187,7 +208,8 @@ describe('Load3DConfiguration.silentOnNotFound propagation', () => {
|
||||
setLightIntensity: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIEnabled: vi.fn()
|
||||
setHDRIEnabled: vi.fn(),
|
||||
emitModelReady: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
@@ -250,6 +272,52 @@ describe('Load3DConfiguration.silentOnNotFound propagation', () => {
|
||||
silentOnNotFound: false
|
||||
})
|
||||
})
|
||||
|
||||
it('emits modelReady AFTER setCameraState so thumbnail capture sees the restored view', async () => {
|
||||
const load3d = makeLoad3dMock()
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
const cameraState = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1,
|
||||
cameraType: 'perspective' as const
|
||||
}
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output',
|
||||
cameraState: cameraState as unknown as Parameters<
|
||||
Load3DConfiguration['configure']
|
||||
>[0]['cameraState']
|
||||
})
|
||||
await flush()
|
||||
|
||||
const setCameraStateMock = vi.mocked(load3d.setCameraState)
|
||||
const emitModelReadyMock = vi.mocked(load3d.emitModelReady)
|
||||
expect(setCameraStateMock).toHaveBeenCalledWith(cameraState)
|
||||
expect(emitModelReadyMock).toHaveBeenCalledTimes(1)
|
||||
expect(setCameraStateMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
emitModelReadyMock.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('emits modelReady even when no saved cameraState is provided', async () => {
|
||||
const load3d = makeLoad3dMock()
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output'
|
||||
})
|
||||
await flush()
|
||||
expect(vi.mocked(load3d.emitModelReady)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('configureForSaveMesh also emits modelReady once the load resolves', async () => {
|
||||
const load3d = makeLoad3dMock()
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
config.configureForSaveMesh('output', 'model.glb')
|
||||
await flush()
|
||||
expect(vi.mocked(load3d.emitModelReady)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnnotatedFilename', () => {
|
||||
@@ -295,3 +363,234 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ class Load3DConfiguration {
|
||||
)
|
||||
|
||||
if (filePath) {
|
||||
onModelWidgetUpdate(filePath)
|
||||
void onModelWidgetUpdate(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class Load3DConfiguration {
|
||||
silentOnNotFound
|
||||
)
|
||||
if (modelWidget.value) {
|
||||
onModelWidgetUpdate(modelWidget.value)
|
||||
void onModelWidgetUpdate(modelWidget.value)
|
||||
}
|
||||
|
||||
const originalCallback = modelWidget.callback
|
||||
@@ -131,7 +131,7 @@ class Load3DConfiguration {
|
||||
})
|
||||
|
||||
modelWidget.callback = (value: string | number | boolean | object) => {
|
||||
onModelWidgetUpdate(value)
|
||||
void onModelWidgetUpdate(value)
|
||||
|
||||
if (originalCallback) {
|
||||
originalCallback(value)
|
||||
@@ -309,6 +309,8 @@ class Load3DConfiguration {
|
||||
}
|
||||
isFirstLoad = false
|
||||
}
|
||||
|
||||
this.load3d.emitModelReady()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ function makeInstance() {
|
||||
// and ViewHelper, none of which are available in happy-dom. Skip it and
|
||||
// inject stubs directly onto the prototype instance so delegation methods
|
||||
// can be exercised in isolation.
|
||||
const eventManager = { emitEvent: vi.fn() }
|
||||
const load3d = Object.create(Load3d.prototype) as Load3d
|
||||
Object.assign(load3d, {
|
||||
gizmoManager: gizmo,
|
||||
@@ -92,6 +93,7 @@ function makeInstance() {
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
eventManager,
|
||||
adapterRef: { current: null },
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
@@ -106,6 +108,7 @@ function makeInstance() {
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
eventManager,
|
||||
forceRender: load3d.forceRender as ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
@@ -764,4 +767,86 @@ describe('Load3d', () => {
|
||||
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('emitModelReady', () => {
|
||||
it('emits a modelReady event on the eventManager', () => {
|
||||
ctx.load3d.emitModelReady()
|
||||
expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelReady',
|
||||
null
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureThumbnail', () => {
|
||||
function setupForCapture() {
|
||||
const cameraStub = {
|
||||
toggleCamera: vi.fn(),
|
||||
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
|
||||
getCameraState: vi.fn().mockReturnValue({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
}),
|
||||
setCameraState: vi.fn(),
|
||||
perspectiveCamera: new THREE.PerspectiveCamera()
|
||||
}
|
||||
const controlsStub = {
|
||||
controls: { target: { copy: vi.fn() }, update: vi.fn() }
|
||||
}
|
||||
const sceneCaptureMock = vi.fn().mockResolvedValue({
|
||||
scene: 'data:image/png;base64,scene',
|
||||
mask: 'm',
|
||||
normal: 'n'
|
||||
})
|
||||
const modelGroup = new THREE.Group()
|
||||
modelGroup.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)))
|
||||
Object.assign(ctx.load3d, {
|
||||
cameraManager: cameraStub,
|
||||
controlsManager: controlsStub,
|
||||
sceneManager: {
|
||||
...ctx.sceneManager,
|
||||
gridHelper: { visible: true },
|
||||
captureScene: sceneCaptureMock
|
||||
},
|
||||
modelManager: {
|
||||
...ctx.modelManager,
|
||||
currentModel: modelGroup
|
||||
}
|
||||
})
|
||||
return { cameraStub, sceneCaptureMock }
|
||||
}
|
||||
|
||||
it('throws when no model is loaded', async () => {
|
||||
Object.assign(ctx.load3d, {
|
||||
modelManager: { ...ctx.modelManager, currentModel: null }
|
||||
})
|
||||
|
||||
await expect(ctx.load3d.captureThumbnail()).rejects.toThrow(
|
||||
'No model loaded for thumbnail capture'
|
||||
)
|
||||
})
|
||||
|
||||
it('forces a render after restoring camera state so the visible canvas reflects the live scene, not the offscreen capture', async () => {
|
||||
const { cameraStub } = setupForCapture()
|
||||
|
||||
const result = await ctx.load3d.captureThumbnail(64, 64)
|
||||
|
||||
expect(result).toBe('data:image/png;base64,scene')
|
||||
expect(cameraStub.setCameraState).toHaveBeenCalled()
|
||||
// forceRender must be called AFTER the live state has been restored.
|
||||
const setCameraOrder = cameraStub.setCameraState.mock.invocationCallOrder
|
||||
const forceRenderOrder = ctx.forceRender.mock.invocationCallOrder
|
||||
expect(forceRenderOrder.at(-1)).toBeGreaterThan(setCameraOrder.at(-1)!)
|
||||
})
|
||||
|
||||
it('still forces a render in finally when captureScene rejects', async () => {
|
||||
const { sceneCaptureMock } = setupForCapture()
|
||||
sceneCaptureMock.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
await expect(ctx.load3d.captureThumbnail(64, 64)).rejects.toThrow('boom')
|
||||
expect(ctx.forceRender).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -632,6 +632,10 @@ class Load3d {
|
||||
this.eventManager.removeEventListener(event, callback)
|
||||
}
|
||||
|
||||
emitModelReady(): void {
|
||||
this.eventManager.emitEvent('modelReady', null)
|
||||
}
|
||||
|
||||
refreshViewport(): void {
|
||||
this.handleResize()
|
||||
}
|
||||
@@ -812,6 +816,8 @@ class Load3d {
|
||||
}
|
||||
this.cameraManager.setCameraState(savedState)
|
||||
this.controlsManager.controls?.update()
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
114
src/extensions/core/previewAny.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const capturedExtensions: ComfyExtension[] = []
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension: (ext: ComfyExtension) => {
|
||||
capturedExtensions.push(ext)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
element: { readOnly: boolean }
|
||||
callback?: (value: unknown) => void
|
||||
value: unknown
|
||||
hidden: boolean
|
||||
label: string
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
const createdWidgets: MockWidget[] = []
|
||||
|
||||
vi.mock('@/scripts/widgets', () => {
|
||||
const create =
|
||||
(kind: string) =>
|
||||
(
|
||||
node: { widgets?: MockWidget[] },
|
||||
name: string,
|
||||
_info: unknown,
|
||||
_app: unknown
|
||||
) => {
|
||||
const widget: MockWidget = {
|
||||
name,
|
||||
options: {},
|
||||
element: { readOnly: false },
|
||||
value: kind === 'BOOLEAN' ? false : '',
|
||||
hidden: false,
|
||||
label: ''
|
||||
}
|
||||
node.widgets = node.widgets ?? []
|
||||
node.widgets.push(widget)
|
||||
createdWidgets.push(widget)
|
||||
return { widget }
|
||||
}
|
||||
return {
|
||||
ComfyWidgets: {
|
||||
MARKDOWN: create('MARKDOWN'),
|
||||
STRING: create('STRING'),
|
||||
BOOLEAN: create('BOOLEAN')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('PreviewAny extension', () => {
|
||||
beforeEach(async () => {
|
||||
capturedExtensions.length = 0
|
||||
createdWidgets.length = 0
|
||||
vi.resetModules()
|
||||
await import('./previewAny')
|
||||
})
|
||||
|
||||
async function setupNode() {
|
||||
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
|
||||
expect(ext).toBeDefined()
|
||||
|
||||
const nodeType = { prototype: {} } as unknown as Parameters<
|
||||
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
>[0]
|
||||
const nodeData = { name: 'PreviewAny' } as Parameters<
|
||||
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
>[1]
|
||||
|
||||
await ext!.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
|
||||
)
|
||||
|
||||
const node: { widgets?: MockWidget[] } = {}
|
||||
const proto = nodeType.prototype as { onNodeCreated?: () => void }
|
||||
proto.onNodeCreated!.call(node)
|
||||
return node
|
||||
}
|
||||
|
||||
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
|
||||
await setupNode()
|
||||
|
||||
const previewMarkdown = createdWidgets.find(
|
||||
(w) => w.name === 'preview_markdown'
|
||||
)
|
||||
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
|
||||
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
|
||||
|
||||
expect(previewMarkdown).toBeDefined()
|
||||
expect(previewText).toBeDefined()
|
||||
expect(previewMode).toBeDefined()
|
||||
|
||||
// widget.options.serialize === false is what executionUtil.graphToPrompt
|
||||
// checks to exclude a widget from the API prompt sent to the backend.
|
||||
// Without this, post-execution widget value updates (the rendered preview
|
||||
// text) get serialized as inputs, change the cache signature, and cause
|
||||
// the node to re-execute on the next prompt.
|
||||
expect(previewMarkdown!.options.serialize).toBe(false)
|
||||
expect(previewText!.options.serialize).toBe(false)
|
||||
expect(previewMode!.options.serialize).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,7 @@ useExtensionService().registerExtension({
|
||||
showValueWidget.hidden = true
|
||||
showValueWidget.options.hidden = true
|
||||
showValueWidget.options.read_only = true
|
||||
showValueWidget.options.serialize = false
|
||||
showValueWidget.element.readOnly = true
|
||||
showValueWidget.serialize = false
|
||||
|
||||
@@ -64,8 +65,14 @@ useExtensionService().registerExtension({
|
||||
showValueWidgetPlain.hidden = false
|
||||
showValueWidgetPlain.options.hidden = false
|
||||
showValueWidgetPlain.options.read_only = true
|
||||
showValueWidgetPlain.options.serialize = false
|
||||
showValueWidgetPlain.element.readOnly = true
|
||||
showValueWidgetPlain.serialize = false
|
||||
|
||||
// The previewMode toggle is a frontend-only display preference and
|
||||
// is not declared in the backend INPUT_TYPES, so it must not be
|
||||
// serialized into the API prompt (would alter the cache signature).
|
||||
showAsPlaintextWidget.widget.options.serialize = false
|
||||
}
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
|
||||
197
src/extensions/core/saveMesh.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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,6 +81,32 @@ 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) {
|
||||
@@ -103,6 +129,9 @@ 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
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from '@/scripts/widgets'
|
||||
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
|
||||
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
|
||||
import { PRIMITIVE_NODE_CATEGORY } from '@/constants/primitiveNodes'
|
||||
import { mergeInputSpec } from '@/utils/nodeDefUtil'
|
||||
import { applyTextReplacements } from '@/utils/searchAndReplace'
|
||||
|
||||
@@ -625,6 +624,6 @@ app.registerExtension({
|
||||
title: 'Primitive'
|
||||
})
|
||||
)
|
||||
PrimitiveNode.category = PRIMITIVE_NODE_CATEGORY
|
||||
PrimitiveNode.category = 'utils'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
},
|
||||
"login": {
|
||||
"andText": "و",
|
||||
"backToGithubLogin": "سجّل باستخدام Github بدلاً من ذلك",
|
||||
"backToLogin": "العودة إلى تسجيل الدخول",
|
||||
"backToSocialLogin": "سجّل باستخدام Google أو Github بدلاً من ذلك",
|
||||
"confirmPasswordLabel": "تأكيد كلمة المرور",
|
||||
@@ -835,6 +836,7 @@
|
||||
"NOISE": "ضجيج",
|
||||
"OPENAI_CHAT_CONFIG": "إعدادات محادثة أوبن إيه آي",
|
||||
"OPENAI_INPUT_FILES": "ملفات إدخال أوبن إيه آي",
|
||||
"OPTICAL_FLOW": "التدفق البصري",
|
||||
"PHOTOMAKER": "صانع الصور",
|
||||
"PIXVERSE_TEMPLATE": "قالب PixVerse",
|
||||
"POSE_KEYPOINT": "نقطة مفتاحية للوضعية",
|
||||
|
||||
@@ -11917,6 +11917,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpticalFlowLoader": {
|
||||
"display_name": "تحميل نموذج التدفق البصري",
|
||||
"inputs": {
|
||||
"model_name": {
|
||||
"name": "model_name",
|
||||
"tooltip": "نموذج التدفق البصري المراد تحميله. يجب وضع الملفات في مجلد 'optical_flow'. حالياً، فقط raft_large.pth من torchvision مدعوم."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"OptimalStepsScheduler": {
|
||||
"display_name": "مجدول الخطوات الأمثل",
|
||||
"inputs": {
|
||||
@@ -17811,6 +17825,127 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDInpaintConditioning": {
|
||||
"display_name": "VOIDInpaintConditioning",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "عدد إطارات البكسل للمعالجة. بالنسبة لـ CogVideoX-Fun-V1.5 (patch_size_t=2)، يجب أن يكون latent_t عدداً زوجياً — الأطوال التي تنتج latent_t فردياً يتم تقريبها للأسفل (مثال: 49 → 45)."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"quadmask": {
|
||||
"name": "quadmask",
|
||||
"tooltip": "قناع رباعي معالج مسبقاً من VOIDQuadmaskPreprocess [T, H, W]"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "إطارات الفيديو المصدر [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDQuadmaskPreprocess": {
|
||||
"display_name": "VOIDQuadmaskPreprocess",
|
||||
"inputs": {
|
||||
"dilate_width": {
|
||||
"name": "dilate_width",
|
||||
"tooltip": "نصف قطر التوسيع لمنطقة القناع الأساسية (٠ = بدون توسيع)"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "quadmask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDSampler": {
|
||||
"display_name": "VOIDSampler",
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoise": {
|
||||
"display_name": "VOIDWarpedNoise",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "عدد إطارات البكسل. يتم التقريب للأسفل لجعل latent_t عدداً زوجياً (متطلب patch_size_t=2)، مثال: 49 → 45."
|
||||
},
|
||||
"optical_flow": {
|
||||
"name": "optical_flow",
|
||||
"tooltip": "نموذج التدفق البصري من OpticalFlowLoader (RAFT-large)."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "إطارات الفيديو الناتجة من المرحلة الأولى [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoiseSource": {
|
||||
"display_name": "VOIDWarpedNoiseSource",
|
||||
"inputs": {
|
||||
"warped_noise": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": "الضجيج المشوه (latent) من VOIDWarpedNoise"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VPScheduler": {
|
||||
"display_name": "مجدول VP",
|
||||
"inputs": {
|
||||
|
||||
@@ -979,6 +979,7 @@
|
||||
"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": {
|
||||
@@ -1819,6 +1820,7 @@
|
||||
"NOISE": "NOISE",
|
||||
"OPENAI_CHAT_CONFIG": "OPENAI_CHAT_CONFIG",
|
||||
"OPENAI_INPUT_FILES": "OPENAI_INPUT_FILES",
|
||||
"OPTICAL_FLOW": "OPTICAL_FLOW",
|
||||
"PHOTOMAKER": "PHOTOMAKER",
|
||||
"PIXVERSE_TEMPLATE": "PIXVERSE_TEMPLATE",
|
||||
"POSE_KEYPOINT": "POSE_KEYPOINT",
|
||||
@@ -2187,7 +2189,8 @@
|
||||
"freeTierBadge": "Eligible for Free Tier",
|
||||
"freeTierDescription": "Sign up with Google to get {credits} free credits every month. No card needed.",
|
||||
"freeTierDescriptionGeneric": "Sign up with Google to get free credits every month. No card needed.",
|
||||
"backToSocialLogin": "Sign up with Google or Github instead"
|
||||
"backToSocialLogin": "Sign up with Google or Github instead",
|
||||
"backToGithubLogin": "Sign up with Github instead"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
@@ -2209,7 +2212,9 @@
|
||||
"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?"
|
||||
"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."
|
||||
},
|
||||
"passwordUpdate": {
|
||||
"success": "Password Updated",
|
||||
|
||||