mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 17:58:23 +00:00
Compare commits
10 Commits
feat/dashb
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73c4af616d | ||
|
|
46fec1d47d | ||
|
|
1052658a02 | ||
|
|
53fc78c96c | ||
|
|
2a55fb4183 | ||
|
|
961b434c40 | ||
|
|
00d0f08cbf | ||
|
|
84f0871277 | ||
|
|
6f7ec5ba34 | ||
|
|
62222b7c04 |
70
.github/workflows/ci-dashboard-deploy.yaml
vendored
70
.github/workflows/ci-dashboard-deploy.yaml
vendored
@@ -1,70 +0,0 @@
|
||||
# Triggered by workflow_run (not push) so this only runs after 'CI: Tests E2E' has
|
||||
# finished merging its sharded blob reports into an HTML report on main — the
|
||||
# 'playwright-report-chromium' artifact this job downloads doesn't exist until that
|
||||
# merge-reports job completes.
|
||||
name: 'CI: Dashboard Deploy'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Tests E2E']
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: dashboard-deploy-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
(github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download merged Playwright report
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: playwright-report-chromium
|
||||
path: dashboard-site/playwright-report
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Check Playwright report was found
|
||||
id: report
|
||||
run: |
|
||||
if [ -f dashboard-site/playwright-report/index.html ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No merged Playwright report artifact found for this run; skipping the dashboard deploy so the existing Pages deployment isn't wiped out." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Assemble dashboard index
|
||||
if: steps.report.outputs.found == 'true'
|
||||
run: cp scripts/cicd/dashboard/index.html dashboard-site/index.html
|
||||
|
||||
- name: Upload to GitHub Pages
|
||||
if: steps.report.outputs.found == 'true'
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
|
||||
with:
|
||||
path: dashboard-site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
if: steps.report.outputs.found == 'true'
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
@@ -14,7 +14,8 @@ const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
),
|
||||
'/individual-submission'
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
|
||||
])
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
const { resources } = defineProps<{
|
||||
resources: { label: string; href: string; display: string }[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
|
||||
<li v-for="resource in resources" :key="resource.href">
|
||||
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
|
||||
<span class="text-primary-warm-gray"> — </span>
|
||||
<a
|
||||
:href="resource.href"
|
||||
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{{ resource.display }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
54
apps/website/src/pages/booking-confirmation.astro
Normal file
54
apps/website/src/pages/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: 'Learning Center',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: 'Workflow templates',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: 'Customer stories',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="You're booked - Comfy"
|
||||
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="You're booked." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>Check your email for the calendar invite and meeting link!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
Resources while you wait
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: '学习中心',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: '工作流模板',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: '客户案例',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: '文档',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="预约成功 - Comfy"
|
||||
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="预约成功。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>请查收邮件中的日历邀请和会议链接!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
等待期间的资源
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -478,49 +477,4 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,40 +178,6 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>ComfyUI_frontend CI Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a1a;
|
||||
--muted: #59636e;
|
||||
--border: #d8dee4;
|
||||
--card-bg: #f6f8fa;
|
||||
--link: #0969da;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--fg: #e6edf3;
|
||||
--muted: #9198a1;
|
||||
--border: #30363d;
|
||||
--card-bg: #161b22;
|
||||
--link: #4493f8;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2.5rem 1.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial,
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
main {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
p.lede {
|
||||
color: var(--muted);
|
||||
margin-top: 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
ul.reports {
|
||||
list-style: none;
|
||||
margin: 0 0 2rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
ul.reports li {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
ul.reports a {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
ul.reports a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
ul.reports .description {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
footer {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
code {
|
||||
background: var(--card-bg);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>ComfyUI_frontend CI Dashboard</h1>
|
||||
<p class="lede">
|
||||
Persistent links to CI-generated reports and tools for the
|
||||
<code>main</code> branch, rebuilt on every push.
|
||||
</p>
|
||||
|
||||
<ul class="reports">
|
||||
<li>
|
||||
<a href="https://main.comfy-storybook.pages.dev">Storybook</a>
|
||||
<div class="description">
|
||||
Component library, built and deployed from
|
||||
<code>ci-tests-storybook.yaml</code>.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a href="./playwright-report/">Playwright report</a>
|
||||
<div class="description">
|
||||
Merged chromium E2E report from the latest <code>main</code> run of
|
||||
<code>ci-tests-e2e.yaml</code>.
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<footer>
|
||||
More report types (coverage, bundle size, lint, etc.) will be added here
|
||||
in follow-up PRs as they get a persistent, hosted home. This page is
|
||||
intentionally minimal for now.
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,11 +3,9 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import type { WidgetLabels } from './nodeDefLocaleSerializer'
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
@@ -17,10 +15,6 @@ interface WidgetInfo {
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
@@ -47,26 +41,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
const inputDataTypes = Object.values(nodeDef.inputs).map(
|
||||
(inputSpec) => inputSpec.type
|
||||
)
|
||||
const outputDataTypes = nodeDef.outputs.map(
|
||||
(outputSpec) => outputSpec.type
|
||||
)
|
||||
const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap(
|
||||
(type: string) => type.split(',')
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
|
||||
@@ -95,14 +69,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
)
|
||||
|
||||
// Format runtime widgets
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
.map(([key, name]) => [key, { name }])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,97 +91,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
|
||||
const nodeDefLabels = await extractWidgetLabels()
|
||||
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(input.name),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function extractOutputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
i.toString(),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
const allNodeDefsLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...extractInputs(nodeDef),
|
||||
...(nodeDefLabels[nodeDef.name] ?? {})
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const allNodeCategoriesLocale = Object.fromEntries(
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(nodeDefs, nodeDefLabels)
|
||||
|
||||
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
|
||||
fs.writeFileSync(
|
||||
@@ -219,13 +100,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
JSON.stringify(
|
||||
{
|
||||
...locale,
|
||||
dataTypes: allDataTypesLocale,
|
||||
nodeCategories: allNodeCategoriesLocale
|
||||
dataTypes,
|
||||
nodeCategories
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2))
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2))
|
||||
})
|
||||
|
||||
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
function render(message: string): string {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
describe('serializeNodeDefLocales', () => {
|
||||
it('escapes compiled fields and preserves raw tooltips', () => {
|
||||
const syntax = '@ $ {value} | 50%{done}'
|
||||
const inputName = `Input ${syntax}`
|
||||
const outputName = `Output ${syntax}`
|
||||
const dataType = `TYPE ${syntax}`
|
||||
const category = `Category ${syntax}`
|
||||
const nodeDef = {
|
||||
name: 'Test.Node',
|
||||
display_name: `Display ${syntax}`,
|
||||
description: `Description ${syntax}`,
|
||||
category,
|
||||
inputs: {
|
||||
input: {
|
||||
name: inputName,
|
||||
type: dataType,
|
||||
tooltip: `Input tooltip ${syntax}`
|
||||
}
|
||||
},
|
||||
outputs: [
|
||||
{
|
||||
name: outputName,
|
||||
type: 'OTHER',
|
||||
tooltip: `Output tooltip ${syntax}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales([nodeDef], {
|
||||
'Test.Node': {
|
||||
'Runtime.Widget': { name: `Widget ${syntax}` }
|
||||
}
|
||||
})
|
||||
const serializedNode = nodeDefinitions.Test_Node
|
||||
const serializedInput =
|
||||
serializedNode.inputs['Input @ $ {value} | 50%{done}']
|
||||
const serializedOutput = serializedNode.outputs['0']
|
||||
|
||||
expect(render(serializedNode.display_name)).toBe(nodeDef.display_name)
|
||||
expect(render(serializedNode.description)).toBe(nodeDef.description)
|
||||
expect(render(serializedInput.name)).toBe(inputName)
|
||||
expect(render(serializedOutput.name)).toBe(outputName)
|
||||
expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe(
|
||||
`Widget ${syntax}`
|
||||
)
|
||||
expect(render(dataTypes[dataType])).toBe(dataType)
|
||||
expect(render(nodeCategories[category])).toBe(category)
|
||||
expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip)
|
||||
expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip)
|
||||
})
|
||||
|
||||
it('preserves locale shapes and ordering', () => {
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(
|
||||
[
|
||||
{
|
||||
name: 'Z.Node',
|
||||
description: '',
|
||||
category: 'group/sub.group',
|
||||
inputs: {
|
||||
omitted: { type: 'Z.TYPE' },
|
||||
tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' }
|
||||
},
|
||||
outputs: [
|
||||
{ name: 'A_TYPE', type: 'A_TYPE' },
|
||||
{ name: 'Custom.Output', type: 'Z.TYPE' },
|
||||
{ tooltip: 'raw output @ tooltip', type: 'Z.TYPE' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'A.Node',
|
||||
category: 'group',
|
||||
inputs: {},
|
||||
outputs: []
|
||||
}
|
||||
],
|
||||
{
|
||||
'Z.Node': {
|
||||
'Runtime.Widget': { name: 'Runtime.Label' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(dataTypes).toEqual({
|
||||
A_TYPE: 'A_TYPE',
|
||||
Z_TYPE: 'Z.TYPE'
|
||||
})
|
||||
expect(nodeCategories).toEqual({
|
||||
group: 'group',
|
||||
sub_group: 'sub.group'
|
||||
})
|
||||
expect(nodeDefinitions).toEqual({
|
||||
A_Node: {
|
||||
display_name: 'A.Node',
|
||||
description: undefined,
|
||||
inputs: undefined,
|
||||
outputs: undefined
|
||||
},
|
||||
Z_Node: {
|
||||
display_name: 'Z.Node',
|
||||
description: undefined,
|
||||
inputs: {
|
||||
'': { name: undefined, tooltip: 'raw @ tooltip' },
|
||||
Runtime_Widget: { name: 'Runtime.Label' }
|
||||
},
|
||||
outputs: {
|
||||
1: { name: 'Custom.Output', tooltip: undefined },
|
||||
2: { name: undefined, tooltip: 'raw output @ tooltip' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE'])
|
||||
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
|
||||
})
|
||||
})
|
||||
127
scripts/nodeDefLocaleSerializer.ts
Normal file
127
scripts/nodeDefLocaleSerializer.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
interface LocalizableInput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableOutput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableNodeDef {
|
||||
category: string
|
||||
inputs: Record<string, LocalizableInput>
|
||||
name: string
|
||||
outputs: LocalizableOutput[]
|
||||
description?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export type WidgetLabels = Record<
|
||||
string,
|
||||
Record<string, { name: string | undefined }>
|
||||
>
|
||||
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
|
||||
|
||||
function escapeMessage(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
export function serializeNodeDefLocales(
|
||||
nodeDefs: readonly LocalizableNodeDef[],
|
||||
widgetLabels: WidgetLabels = {}
|
||||
) {
|
||||
const dataTypes = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => [
|
||||
...Object.values(nodeDef.inputs).map(({ type }) => type),
|
||||
...nodeDef.outputs.map(({ type }) => type)
|
||||
])
|
||||
.flatMap((type) => type.split(','))
|
||||
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
function serializeInputs(nodeDef: LocalizableNodeDef) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => {
|
||||
if (name === undefined && tooltip === undefined) return []
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(name ?? ''),
|
||||
{
|
||||
name: name === undefined ? undefined : escapeMessage(name),
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function serializeOutputs(nodeDef: LocalizableNodeDef) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap(({ name, tooltip }, index) => {
|
||||
const serializedName =
|
||||
name === undefined || name in dataTypes
|
||||
? undefined
|
||||
: escapeMessage(name)
|
||||
if (serializedName === undefined && tooltip === undefined) return []
|
||||
|
||||
return [[index.toString(), { name: serializedName, tooltip }]]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
function serializeWidgetLabels(nodeName: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
|
||||
normalizeI18nKey(name),
|
||||
{
|
||||
name: label.name === undefined ? undefined : escapeMessage(label.name)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
const nodeDefinitions = Object.fromEntries(
|
||||
[...nodeDefs]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...serializeInputs(nodeDef),
|
||||
...serializeWidgetLabels(nodeDef.name)
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
|
||||
description: nodeDef.description
|
||||
? escapeMessage(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: serializeOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const nodeCategories = Object.fromEntries(
|
||||
nodeDefs.flatMap(({ category }) =>
|
||||
category
|
||||
.split('/')
|
||||
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
|
||||
)
|
||||
)
|
||||
|
||||
return { dataTypes, nodeCategories, nodeDefinitions }
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({ error: useImageMock.error })
|
||||
}
|
||||
})
|
||||
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -23,6 +38,10 @@ const i18n = createI18n({
|
||||
})
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
beforeEach(() => {
|
||||
if (useImageMock.error) useImageMock.error.value = null
|
||||
})
|
||||
|
||||
function renderComponent(props: ComponentProps<typeof UserAvatar> = {}) {
|
||||
return render(UserAvatar, {
|
||||
global: {
|
||||
@@ -67,10 +86,10 @@ describe('UserAvatar', () => {
|
||||
photoUrl: 'https://example.com/broken-image.jpg'
|
||||
})
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
|
||||
|
||||
await fireEvent.error(img)
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
|
||||
|
||||
@@ -11,22 +11,24 @@
|
||||
}"
|
||||
shape="circle"
|
||||
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { photoUrl, ariaLabel } = defineProps<{
|
||||
photoUrl?: string | null
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const imageError = ref(false)
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
}
|
||||
const { error: imageError } = useImage(
|
||||
computed(() => ({
|
||||
src: photoUrl ?? '',
|
||||
alt: ariaLabel ?? ''
|
||||
}))
|
||||
)
|
||||
const hasAvatar = computed(() => photoUrl && !imageError.value)
|
||||
</script>
|
||||
|
||||
@@ -21,8 +21,6 @@ function createDefaultCropState() {
|
||||
isLockEnabled: ref(false),
|
||||
cropBoxStyle: ref({}),
|
||||
resizeHandles: ref([]),
|
||||
handleImageLoad: () => {},
|
||||
handleImageError: () => {},
|
||||
handleDragStart: () => {},
|
||||
handleDragMove: () => {},
|
||||
handleDragEnd: () => {},
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
|
||||
@@ -181,8 +179,6 @@ const {
|
||||
cropBoxStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragEnd,
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createApp, defineComponent, nextTick, reactive, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -17,10 +18,35 @@ import {
|
||||
createMockSubgraphNode
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import { imageCropLoadingAfterUrlChange, useImageCrop } from './useImageCrop'
|
||||
import { useImageCrop } from './useImageCrop'
|
||||
|
||||
const resizeObserverCallbacks: Array<() => void> = []
|
||||
|
||||
const useImageMockState = vi.hoisted(() => {
|
||||
return {
|
||||
state: null as null | {
|
||||
state: Ref<HTMLImageElement | undefined>
|
||||
isReady: Ref<boolean>
|
||||
error: Ref<unknown>
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function getUseImageMock() {
|
||||
if (!useImageMockState.state) {
|
||||
useImageMockState.state = {
|
||||
state: ref<HTMLImageElement | undefined>(undefined),
|
||||
isReady: ref(false),
|
||||
error: ref<unknown>(null)
|
||||
}
|
||||
}
|
||||
return useImageMockState.state
|
||||
}
|
||||
|
||||
function resetUseImageMock() {
|
||||
useImageMockState.state = null
|
||||
}
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
return {
|
||||
@@ -28,7 +54,8 @@ vi.mock('@vueuse/core', async () => {
|
||||
useResizeObserver: (_target: unknown, cb: () => void) => {
|
||||
resizeObserverCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
}
|
||||
},
|
||||
useImage: () => getUseImageMock()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -178,10 +205,33 @@ function setupImageLayout(vm: CropVm, nw: number, nh: number) {
|
||||
value: nh
|
||||
})
|
||||
}
|
||||
;(vm.handleImageLoad as () => void)()
|
||||
triggerImageLoad(nw, nh)
|
||||
flushResizeObservers()
|
||||
}
|
||||
|
||||
function triggerImageLoad(nw: number, nh: number) {
|
||||
const mock = getUseImageMock()
|
||||
const fakeImg = new Image()
|
||||
Object.defineProperty(fakeImg, 'naturalWidth', {
|
||||
configurable: true,
|
||||
value: nw
|
||||
})
|
||||
Object.defineProperty(fakeImg, 'naturalHeight', {
|
||||
configurable: true,
|
||||
value: nh
|
||||
})
|
||||
mock.state.value = fakeImg
|
||||
mock.error.value = null
|
||||
mock.isReady.value = true
|
||||
}
|
||||
|
||||
function triggerImageError() {
|
||||
const mock = getUseImageMock()
|
||||
mock.state.value = undefined
|
||||
mock.isReady.value = false
|
||||
mock.error.value = new Error('image failed to load')
|
||||
}
|
||||
|
||||
const harnessCleanups: Array<() => void> = []
|
||||
|
||||
async function mountHarness(nodeId: NodeId = toNodeId(2)) {
|
||||
@@ -203,28 +253,6 @@ async function flushTicks() {
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('imageCropLoadingAfterUrlChange', () => {
|
||||
it('clears loading when url becomes null', () => {
|
||||
expect(imageCropLoadingAfterUrlChange(null, 'https://a/b.png')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps loading off when url stays null', () => {
|
||||
expect(imageCropLoadingAfterUrlChange(null, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('starts loading when url changes to a new string', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://b', 'https://a')).toBe(true)
|
||||
})
|
||||
|
||||
it('starts loading when first url is set', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://a', undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when url is unchanged so caller can skip updating', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://a', 'https://a')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useImageCrop', () => {
|
||||
let sourceNode: LGraphNode
|
||||
let cropNode: LGraphNode
|
||||
@@ -232,6 +260,7 @@ describe('useImageCrop', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resizeObserverCallbacks.length = 0
|
||||
resetUseImageMock()
|
||||
vi.clearAllMocks()
|
||||
outputStore = {
|
||||
nodeOutputs: reactive<Record<string, unknown>>({}),
|
||||
@@ -383,7 +412,8 @@ describe('useImageCrop', () => {
|
||||
configurable: true,
|
||||
value: 0
|
||||
})
|
||||
;(vm.handleImageLoad as () => void)()
|
||||
triggerImageLoad(0, 0)
|
||||
await flushTicks()
|
||||
vm.modelValue = { x: 0, y: 0, width: 100, height: 80 }
|
||||
const style = vm.cropBoxStyle as Record<string, string>
|
||||
expect(parseFloat(style.width)).toBeCloseTo(100, 1)
|
||||
@@ -411,14 +441,16 @@ describe('useImageCrop', () => {
|
||||
|
||||
expect(vm.imageUrl).toBe('https://example.com/b.png')
|
||||
expect(vm.isLoading).toBe(true)
|
||||
;(vm.handleImageLoad as () => void)()
|
||||
triggerImageLoad(800, 600)
|
||||
await flushTicks()
|
||||
expect(vm.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('clears imageUrl on image error', async () => {
|
||||
const vm = await mountHarness()
|
||||
expect(vm.imageUrl).toBeTruthy()
|
||||
;(vm.handleImageError as () => void)()
|
||||
triggerImageError()
|
||||
await flushTicks()
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
expect(vm.isLoading).toBe(false)
|
||||
})
|
||||
@@ -625,6 +657,7 @@ describe('WidgetImageCrop', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resizeObserverCallbacks.length = 0
|
||||
resetUseImageMock()
|
||||
vi.clearAllMocks()
|
||||
const outputStore: MockOutputStore = {
|
||||
nodeOutputs: reactive<Record<string, unknown>>({}),
|
||||
@@ -713,7 +746,7 @@ describe('WidgetImageCrop', () => {
|
||||
configurable: true,
|
||||
value: 400
|
||||
})
|
||||
img.dispatchEvent(new Event('load'))
|
||||
triggerImageLoad(400, 400)
|
||||
await flushTicks()
|
||||
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
|
||||
unmount()
|
||||
@@ -757,7 +790,7 @@ describe('WidgetImageCrop', () => {
|
||||
configurable: true,
|
||||
value: 400
|
||||
})
|
||||
img.dispatchEvent(new Event('load'))
|
||||
triggerImageLoad(400, 400)
|
||||
await flushTicks()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { useImage, useResizeObserver } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
@@ -35,19 +35,6 @@ const CORNER_SIZE = 10
|
||||
const MIN_CROP_SIZE = 16
|
||||
const CROP_BOX_BORDER = 2
|
||||
|
||||
/**
|
||||
* Next `isLoading` when `imageUrl` transitions. `null` means do not change
|
||||
* `isLoading` (e.g. same URL).
|
||||
*/
|
||||
export function imageCropLoadingAfterUrlChange(
|
||||
url: string | null,
|
||||
previous: string | null | undefined
|
||||
): boolean | null {
|
||||
if (url == null) return false
|
||||
if (url !== previous) return true
|
||||
return null
|
||||
}
|
||||
|
||||
export const ASPECT_RATIOS = {
|
||||
'1:1': 1,
|
||||
'3:4': 3 / 4,
|
||||
@@ -205,17 +192,37 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
imageUrl.value = getInputImageUrl()
|
||||
}
|
||||
|
||||
const {
|
||||
state: imageState,
|
||||
isReady: imageIsReady,
|
||||
error: imageLoadError
|
||||
} = useImage(computed(() => ({ src: imageUrl.value ?? '', alt: '' })))
|
||||
|
||||
watch(imageUrl, (url, previous) => {
|
||||
const next = imageCropLoadingAfterUrlChange(url, previous)
|
||||
if (next !== null) {
|
||||
isLoading.value = next
|
||||
if (url == null) {
|
||||
isLoading.value = false
|
||||
} else if (url !== previous) {
|
||||
isLoading.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const updateDisplayedDimensions = () => {
|
||||
if (!imageEl.value || !containerEl.value) return
|
||||
watch([imageIsReady, imageState], ([ready, img]) => {
|
||||
if (!ready || !img) return
|
||||
isLoading.value = false
|
||||
updateDisplayedDimensions(img)
|
||||
})
|
||||
|
||||
watch(imageLoadError, (err) => {
|
||||
if (err) {
|
||||
isLoading.value = false
|
||||
imageUrl.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const updateDisplayedDimensions = (loadedImg?: HTMLImageElement | null) => {
|
||||
const img = loadedImg ?? imageEl.value
|
||||
if (!img || !containerEl.value) return
|
||||
|
||||
const img = imageEl.value
|
||||
const container = containerEl.value
|
||||
|
||||
naturalWidth.value = img.naturalWidth
|
||||
@@ -371,16 +378,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
)
|
||||
})
|
||||
|
||||
const handleImageLoad = () => {
|
||||
isLoading.value = false
|
||||
updateDisplayedDimensions()
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
isLoading.value = false
|
||||
imageUrl.value = null
|
||||
}
|
||||
|
||||
const capturePointer = (e: PointerEvent) => {
|
||||
if (e.target instanceof HTMLElement) e.target.setPointerCapture(e.pointerId)
|
||||
}
|
||||
@@ -618,8 +615,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
cropBoxStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragEnd,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
|
||||
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
|
||||
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
|
||||
* sync). `collect-i18n-node-defs.ts` escapes such values with
|
||||
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
|
||||
* output actually compiles and renders the original literal text.
|
||||
*/
|
||||
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
|
||||
const compile = (message: string) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
it.for([
|
||||
'clips (tagged @Audio1-3 in the prompt)',
|
||||
'support@comfy.org',
|
||||
'resolution {width}x{height}',
|
||||
'foreground | background',
|
||||
'50%{done}',
|
||||
'all of @ { } | % together',
|
||||
'no special chars here'
|
||||
])('renders %s as the original literal text', (raw) => {
|
||||
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
|
||||
})
|
||||
})
|
||||
126
src/renderer/extensions/linearMode/ImagePreview.test.ts
Normal file
126
src/renderer/extensions/linearMode/ImagePreview.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
state: null as Ref<HTMLImageElement | undefined> | null,
|
||||
isReady: null as Ref<boolean> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
|
||||
useImageMock.isReady = ref(false)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({
|
||||
state: useImageMock.state,
|
||||
isReady: useImageMock.isReady
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const executionStatusMock = vi.hoisted(() => ({
|
||||
message: null as Ref<string | null> | null
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/useExecutionStatus', async () => {
|
||||
const { ref } = await import('vue')
|
||||
executionStatusMock.message = ref<string | null>(null)
|
||||
return {
|
||||
useExecutionStatus: () => ({
|
||||
executionStatusMessage: executionStatusMock.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
|
||||
|
||||
function renderImagePreview(props: Record<string, unknown> = {}) {
|
||||
return render(ImagePreview, {
|
||||
props: { src: 'https://example.com/image.png', ...props },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ZoomPane: {
|
||||
template: '<div data-testid="zoom-pane"><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setLoadedImage(width: number, height: number) {
|
||||
const fakeImage = { naturalWidth: width, naturalHeight: height } as
|
||||
| HTMLImageElement
|
||||
| undefined
|
||||
useImageMock.state!.value = fakeImage
|
||||
useImageMock.isReady!.value = true
|
||||
}
|
||||
|
||||
describe('ImagePreview (linearMode)', () => {
|
||||
beforeEach(() => {
|
||||
if (useImageMock.state) useImageMock.state.value = undefined
|
||||
if (useImageMock.isReady) useImageMock.isReady.value = false
|
||||
if (executionStatusMock.message) executionStatusMock.message.value = null
|
||||
})
|
||||
|
||||
it('renders src inside ZoomPane in desktop mode', () => {
|
||||
renderImagePreview()
|
||||
expect(screen.getByTestId('zoom-pane')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/image.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders bare img when mobile is true', () => {
|
||||
renderImagePreview({ mobile: true })
|
||||
expect(screen.queryByTestId('zoom-pane')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/image.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows dimensions once the image is ready', async () => {
|
||||
renderImagePreview()
|
||||
setLoadedImage(800, 600)
|
||||
await nextTick()
|
||||
expect(screen.getByText('800 x 600')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('appends label when provided alongside dimensions', async () => {
|
||||
renderImagePreview({ label: 'demo' })
|
||||
setLoadedImage(64, 32)
|
||||
await nextTick()
|
||||
expect(screen.getByText(/64 x 32/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/demo/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dimensions when showSize=false', async () => {
|
||||
renderImagePreview({ showSize: false })
|
||||
setLoadedImage(800, 600)
|
||||
await nextTick()
|
||||
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dimensions before the image is ready', () => {
|
||||
renderImagePreview()
|
||||
expect(screen.queryByText(/x/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows execution status message instead of dimensions when present', async () => {
|
||||
renderImagePreview()
|
||||
setLoadedImage(800, 600)
|
||||
executionStatusMock.message!.value = 'Generating…'
|
||||
await nextTick()
|
||||
expect(screen.getByText('Generating…')).toBeInTheDocument()
|
||||
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
|
||||
@@ -16,15 +17,20 @@ const { src, showSize = true } = defineProps<{
|
||||
showSize?: boolean
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
const width = ref<number | null>(null)
|
||||
const height = ref<number | null>(null)
|
||||
const { state: imageState, isReady } = useImage(
|
||||
computed(() => ({ src, alt: '' }))
|
||||
)
|
||||
|
||||
function onImageLoad() {
|
||||
if (!imageRef.value || !showSize) return
|
||||
width.value = imageRef.value.naturalWidth
|
||||
height.value = imageRef.value.naturalHeight
|
||||
}
|
||||
const width = computed(() =>
|
||||
showSize && isReady.value && imageState.value
|
||||
? imageState.value.naturalWidth || null
|
||||
: null
|
||||
)
|
||||
const height = computed(() =>
|
||||
showSize && isReady.value && imageState.value
|
||||
? imageState.value.naturalHeight || null
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<ZoomPane
|
||||
@@ -32,21 +38,9 @@ function onImageLoad() {
|
||||
v-slot="slotProps"
|
||||
:class="cn('w-full flex-1', $attrs.class as string)"
|
||||
>
|
||||
<img
|
||||
ref="imageRef"
|
||||
:src
|
||||
v-bind="slotProps"
|
||||
class="size-full object-contain"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<img :src v-bind="slotProps" class="size-full object-contain" />
|
||||
</ZoomPane>
|
||||
<img
|
||||
v-else
|
||||
ref="imageRef"
|
||||
class="grow object-contain contain-size"
|
||||
:src
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<img v-else class="grow object-contain contain-size" :src />
|
||||
<span
|
||||
v-if="executionStatusMessage"
|
||||
class="animate-pulse self-center text-muted md:z-10"
|
||||
|
||||
@@ -4,9 +4,24 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({ error: useImageMock.error })
|
||||
}
|
||||
})
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
|
||||
|
||||
@@ -85,6 +100,7 @@ describe('ImagePreview', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (useImageMock.error) useImageMock.error.value = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -163,7 +179,7 @@ describe('ImagePreview', () => {
|
||||
screen.getByRole('button', { name: 'Download image' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
await fireEvent.error(screen.getByTestId('main-image'))
|
||||
useImageMock.error!.value = new Error('failed to load')
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
draggable="false"
|
||||
class="pointer-events-none absolute inset-0 block size-full object-contain"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
@@ -195,7 +194,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize, useTimeoutFn } from '@vueuse/core'
|
||||
import { useElementSize, useImage, useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -243,7 +242,6 @@ const currentIndex = ref(0)
|
||||
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
|
||||
const galleryPanelEl = ref<HTMLDivElement>()
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const showLoader = ref(false)
|
||||
const imageAspectRatio = ref(1)
|
||||
|
||||
@@ -271,6 +269,20 @@ const gridCols = computed(() => {
|
||||
return Math.max(Math.round(Math.sqrt(imageUrls.length * bias)), 1)
|
||||
})
|
||||
|
||||
// Use useImage for error detection only. Load handling stays on the rendered
|
||||
// <img> @load handler so syncLegacyNodeImgs receives the actual DOM element.
|
||||
const { error: imageError } = useImage(
|
||||
computed(() => ({ src: currentImageUrl.value, alt: imageAltText.value }))
|
||||
)
|
||||
|
||||
watch(imageError, (err) => {
|
||||
if (err) {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
actualDimensions.value = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => imageUrls,
|
||||
(newUrls, oldUrls) => {
|
||||
@@ -287,11 +299,11 @@ watch(
|
||||
currentIndex.value = 0
|
||||
}
|
||||
|
||||
// Reset loading and error states when URLs change
|
||||
// Reset loading and dimensions when URLs change. `imageError` is reset
|
||||
// automatically by `useImage` when the source changes.
|
||||
actualDimensions.value = null
|
||||
|
||||
viewMode.value = defaultViewMode(newUrls)
|
||||
imageError.value = false
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -302,7 +314,6 @@ function handleImageLoad(event: Event) {
|
||||
const img = event.target
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
@@ -320,13 +331,6 @@ function updateAspectRatio(event: Event, index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
|
||||
function handleEditMask() {
|
||||
if (!nodeId) return
|
||||
const node = resolveNode(nodeId)
|
||||
@@ -351,7 +355,6 @@ function setCurrentIndex(index: number) {
|
||||
if (index >= 0 && index < imageUrls.length) {
|
||||
const urlChanged = imageUrls[index] !== currentImageUrl.value
|
||||
currentIndex.value = index
|
||||
imageError.value = false
|
||||
if (urlChanged) startDelayedLoader()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
state: null as Ref<HTMLImageElement | undefined> | null,
|
||||
isReady: null as Ref<boolean> | null,
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
|
||||
useImageMock.isReady = ref(false)
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({
|
||||
state: useImageMock.state,
|
||||
isReady: useImageMock.isReady,
|
||||
error: useImageMock.error
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -21,6 +44,19 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function makeFakeLoadedImage(width: number, height: number): HTMLImageElement {
|
||||
const img = new Image()
|
||||
Object.defineProperty(img, 'naturalWidth', {
|
||||
configurable: true,
|
||||
value: width
|
||||
})
|
||||
Object.defineProperty(img, 'naturalHeight', {
|
||||
configurable: true,
|
||||
value: height
|
||||
})
|
||||
return img
|
||||
}
|
||||
|
||||
describe('LivePreview', () => {
|
||||
const defaultProps = {
|
||||
imageUrl: '/api/view?filename=test_sample.png&type=temp'
|
||||
@@ -43,6 +79,12 @@ describe('LivePreview', () => {
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useImageMock.state!.value = undefined
|
||||
useImageMock.isReady!.value = false
|
||||
useImageMock.error!.value = null
|
||||
})
|
||||
|
||||
it('renders preview when imageUrl provided', () => {
|
||||
renderLivePreview()
|
||||
|
||||
@@ -74,54 +116,62 @@ describe('LivePreview', () => {
|
||||
|
||||
it('handles image load event', async () => {
|
||||
const { container } = renderLivePreview()
|
||||
const img = screen.getByRole('img')
|
||||
|
||||
Object.defineProperty(img, 'naturalWidth', {
|
||||
writable: false,
|
||||
value: 512
|
||||
})
|
||||
Object.defineProperty(img, 'naturalHeight', {
|
||||
writable: false,
|
||||
value: 512
|
||||
})
|
||||
|
||||
await fireEvent.load(img)
|
||||
useImageMock.state!.value = makeFakeLoadedImage(512, 512)
|
||||
useImageMock.isReady!.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(container.textContent).toContain('512 x 512')
|
||||
})
|
||||
|
||||
it('keeps last good dimensions when imageUrl changes (no flicker)', async () => {
|
||||
const { container, rerender } = renderLivePreview()
|
||||
|
||||
useImageMock.state!.value = makeFakeLoadedImage(800, 600)
|
||||
useImageMock.isReady!.value = true
|
||||
await nextTick()
|
||||
expect(container.textContent).toContain('800 x 600')
|
||||
|
||||
// Simulate the source changing during live preview streaming. useImage
|
||||
// would normally reset isReady to false until the next image is ready.
|
||||
useImageMock.isReady!.value = false
|
||||
await rerender({
|
||||
imageUrl: '/api/view?filename=test_sample_2.png&type=temp'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Dimensions should still display, not flicker back to "Calculating".
|
||||
expect(container.textContent).toContain('800 x 600')
|
||||
expect(container.textContent).not.toContain('Calculating dimensions')
|
||||
})
|
||||
|
||||
it('handles image error state', async () => {
|
||||
renderLivePreview()
|
||||
const img = screen.getByRole('img')
|
||||
|
||||
await fireEvent.error(img)
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
screen.getByText('Image failed to load')
|
||||
})
|
||||
|
||||
it('resets state when imageUrl changes', async () => {
|
||||
it('resets error state when imageUrl changes', async () => {
|
||||
const { container, rerender } = renderLivePreview()
|
||||
const img = screen.getByRole('img')
|
||||
|
||||
await fireEvent.error(img)
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
expect(container.textContent).toContain('Error loading image')
|
||||
|
||||
// useImage resets error automatically when src changes.
|
||||
useImageMock.error!.value = null
|
||||
await rerender({ imageUrl: '/new-image.png' })
|
||||
await nextTick()
|
||||
|
||||
expect(container.textContent).toContain('Calculating dimensions')
|
||||
expect(container.textContent).not.toContain('Error loading image')
|
||||
})
|
||||
|
||||
it('shows error state when image fails to load', async () => {
|
||||
const { container } = renderLivePreview()
|
||||
const img = screen.getByRole('img')
|
||||
|
||||
await fireEvent.error(img)
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
:src="imageUrl"
|
||||
:alt="$t('g.liveSamplingPreview')"
|
||||
class="pointer-events-none min-h-55 w-full flex-1 object-contain contain-size"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="text-node-component-header-text mt-1 text-center text-xs">
|
||||
{{
|
||||
@@ -26,7 +24,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface LivePreviewProps {
|
||||
imageUrl: string
|
||||
@@ -34,29 +34,36 @@ interface LivePreviewProps {
|
||||
|
||||
const props = defineProps<LivePreviewProps>()
|
||||
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const { t } = useI18n()
|
||||
|
||||
watch(
|
||||
() => props.imageUrl,
|
||||
() => {
|
||||
// Reset error state when URL changes, but keep previous dimensions
|
||||
// to avoid flickering "Calculating dimensions" text during live preview
|
||||
imageError.value = false
|
||||
}
|
||||
const {
|
||||
state: imageState,
|
||||
isReady,
|
||||
error
|
||||
} = useImage(
|
||||
computed(() => ({ src: props.imageUrl, alt: t('g.liveSamplingPreview') }))
|
||||
)
|
||||
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
// Cache last successfully loaded dimensions so the placeholder text does not
|
||||
// flicker back to "Calculating dimensions" each time `imageUrl` changes during
|
||||
// live preview streaming. Update only when a new image is ready, never on
|
||||
// URL change alone.
|
||||
const cachedWidth = ref<number | null>(null)
|
||||
const cachedHeight = ref<number | null>(null)
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
watch([isReady, imageState], ([ready, img]) => {
|
||||
if (!ready || !img) return
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
cachedWidth.value = img.naturalWidth
|
||||
cachedHeight.value = img.naturalHeight
|
||||
}
|
||||
})
|
||||
|
||||
const imageError = computed(() => !!error.value)
|
||||
|
||||
const actualDimensions = computed(() =>
|
||||
cachedWidth.value && cachedHeight.value
|
||||
? `${cachedWidth.value} x ${cachedHeight.value}`
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({ error: useImageMock.error })
|
||||
}
|
||||
})
|
||||
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
defaultBanner: 'Default banner'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makePack(
|
||||
overrides: Partial<components['schemas']['Node']> = {}
|
||||
): components['schemas']['Node'] {
|
||||
return {
|
||||
id: 'pack-id',
|
||||
name: 'TestPack',
|
||||
...overrides
|
||||
} as components['schemas']['Node']
|
||||
}
|
||||
|
||||
function renderPackBanner(nodePack: components['schemas']['Node']) {
|
||||
return render(PackBanner, {
|
||||
props: { nodePack },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('PackBanner', () => {
|
||||
beforeEach(() => {
|
||||
if (useImageMock.error) useImageMock.error.value = null
|
||||
})
|
||||
|
||||
it('renders the default banner when both banner_url and icon are missing', () => {
|
||||
renderPackBanner(makePack())
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', DEFAULT_BANNER)
|
||||
expect(img).toHaveAttribute('alt', 'Default banner')
|
||||
})
|
||||
|
||||
it('renders the banner_url image when provided', () => {
|
||||
renderPackBanner(makePack({ banner_url: 'https://example.com/banner.png' }))
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/banner.png')
|
||||
expect(img).toHaveAttribute('alt', 'TestPack banner')
|
||||
})
|
||||
|
||||
it('falls back to icon when banner_url is missing but icon is set', () => {
|
||||
renderPackBanner(makePack({ icon: 'https://example.com/icon.svg' }))
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/icon.svg'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to default banner when image fails to load', async () => {
|
||||
renderPackBanner(makePack({ banner_url: 'https://example.com/broken.png' }))
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/broken.png'
|
||||
)
|
||||
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', DEFAULT_BANNER)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@
|
||||
<div v-else class="relative size-full">
|
||||
<!-- blur background -->
|
||||
<div
|
||||
v-if="imgSrc"
|
||||
v-if="imgSrc && !isImageError"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{
|
||||
backgroundImage: `url(${imgSrc})`,
|
||||
@@ -21,21 +21,24 @@
|
||||
></div>
|
||||
<!-- image -->
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative z-10 size-full object-cover'
|
||||
: 'relative z-10 size-full object-contain'
|
||||
"
|
||||
@error="isImageError = true"
|
||||
v-if="isImageError"
|
||||
:src="DEFAULT_BANNER"
|
||||
:alt="bannerAlt"
|
||||
class="relative z-10 size-full object-cover"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="imgSrc"
|
||||
:alt="bannerAlt"
|
||||
class="relative z-10 size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
@@ -45,8 +48,11 @@ const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
|
||||
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon || '')
|
||||
const bannerAlt = computed(() => `${nodePack.name} banner`)
|
||||
|
||||
const { error: isImageError } = useImage(
|
||||
computed(() => ({ src: imgSrc.value, alt: bannerAlt.value }))
|
||||
)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user