Compare commits

..

10 Commits

Author SHA1 Message Date
Alexander Brown
73c4af616d Merge branch 'main' into refactor/to-useimage 2026-07-16 15:26:53 -07:00
steven-comfy
46fec1d47d feat: add hidden /booking-confirmation page for meeting-booking confirmations (#13730)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/booking-confirmation`, a hidden `noindex` page intended as the
post-submit redirect target after a visitor books a meeting with the GTM
team. The page confirms the booking, tells the visitor to check their
email for the calendar invite, and offers a small list of "while you
wait" resources.

## Design

Mirrors the `/individual-submission` template landed in #13697 —
`BaseLayout` + `HeroSection` — so typography and spacing feel native to
the marketing site. The "Resources while you wait" heading uses the
yellow-italic accent used elsewhere on the site; each resource is a
centered `label — url` row.

Resource links source their URLs from `config/routes.ts` where possible
(`routes.learning`, `routes.customers`, `externalLinks.workflows`,
`externalLinks.docs`) so the page stays in sync if those paths ever
move.

## Hidden-page mechanics

Follows the pattern already used for `/individual-submission`,
`/terms-of-service`, and `/payment/*`:

- `noindex` meta tag on the page (both `en` and `zh-CN`)
- Both `/booking-confirmation` and `/zh-CN/booking-confirmation` added
to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts` via
`LOCALE_PREFIXES`, so neither URL is emitted in the sitemap
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`
- English + Simplified Chinese variants both exist as full pages
(matching the existing `/individual-submission` treatment)

## Next step (not in this PR)

Someone with HubSpot / Chili Piper access needs to configure the
meeting-booking flow to redirect visitors to
`https://comfy.org/booking-confirmation` after they schedule.

## Verification

- `pnpm typecheck` → 0 errors, 0 warnings (10 pre-existing hints
unrelated to this change)
- `pnpm build` (in `apps/website`) → 504 pages built;
`/booking-confirmation/` and `/zh-CN/booking-confirmation/` emitted;
neither appears in `dist/sitemap-0.xml`
- `oxlint` and `oxfmt` clean on changed files (also enforced by
lint-staged pre-commit)
- Playwright preview at 1280×900 (English + zh-CN) and 390×844 (English
mobile) — hero, body copy, section header, and all 4 resource rows
render as expected; no horizontal overflow on mobile; only console noise
was transient Vite dep-optimizer 504s unrelated to this change
- `/review` (oracle) flagged one issue on the initial revision: the
zh-CN page was `noindex` but its URL still appeared in the sitemap.
Fixed by extending the exclusion set across both `LOCALE_PREFIXES`; the
follow-up `/review` returned 0 findings.

## Screenshots

## Screenshots

![Desktop 1280x900 view of /booking-confirmation: centered hero 'You're
booked.', body 'Check your email for the calendar invite and meeting
link!', yellow-italic 'Resources while you wait' heading, and four
centered rows showing Learning Center/Workflow templates/Customer
stories/Docs with their comfy.org
URLs.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235033519-7d23611a-06c4-4a0b-9a2d-1bd641cc4141.png)

![Mobile 390x844 view of /booking-confirmation: same layout stacks
cleanly with no horizontal overflow; resource rows wrap onto two
centered lines
each.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235033915-819c8536-6bb6-4172-af7a-efd28e6df53e.png)

![Desktop 1280x900 view of /zh-CN/booking-confirmation: Chinese
translation with hero '预约成功。', '等待期间的资源' heading, and four resource rows
in Simplified
Chinese.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235034319-32b66ebb-50ae-47fb-ac64-be27b7e5a278.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Michael B <michael@imick.io>
2026-07-16 22:11:14 +00:00
Alexander Brown
1052658a02 refactor: isolate node locale serialization (#13643)
## Summary

Move node-definition locale serialization into a focused, testable
module while preserving generated locale behavior.

## Changes

- **What**: Extract locale key normalization and Vue I18n escaping from
the Playwright collector into `nodeDefLocaleSerializer.ts`.
- **What**: Keep input and output tooltips raw while escaping fields
compiled by Vue I18n.
- **What**: Replace broad utility tests with behavioral serializer
coverage for escaping, output shapes, ordering, and missing values.

## Review Focus

Check parity with the generator introduced in #13631, especially raw
tooltips, runtime widget labels, known data-type output elision, and
normalized keys.

Validation: `pnpm typecheck`, focused Vitest, oxlint, ESLint, and oxfmt.
2026-07-16 22:08:01 +00:00
Christian Byrne
53fc78c96c test: add coverage for migrated linearMode/ImagePreview and PackBanner
Two of the components migrated to useImage in this PR shipped without
test files, dragging codecov patch coverage to 54.23% (below the 80%
threshold). Add render/behavior tests covering:

- linearMode/ImagePreview: ZoomPane vs mobile rendering, useImage-driven
  width/height computation, showSize gating, and execution status
  message precedence.
- packBanner/PackBanner: default banner fallback, banner_url/icon
  precedence, and useImage error fallback to default banner.

Pushes patch coverage above the codecov threshold without changing any
production code.
2026-07-06 11:22:46 -07:00
bymyself
2a55fb4183 refactor(useImageCrop): use useImage and remove load/error event API
Replace the exported handleImageLoad/handleImageError handlers (and
the manual watch on imageUrl that toggled isLoading) with useImage
from @vueuse/core. Source is wrapped in computed() so useImage re-runs
when imageUrl changes.

- watch(isReady, imageState) -> set isLoading=false and call
  updateDisplayedDimensions with the loaded image (off-DOM but with
  guaranteed natural dimensions when isReady fires).
- watch(error) -> set isLoading=false and clear imageUrl (preserving
  prior handleImageError behaviour).
- updateDisplayedDimensions now accepts an optional image arg, falling
  back to imageEl.value for the resize observer path.
- Drop the imageCropLoadingAfterUrlChange helper (and its tests) — the
  behaviour is now expressed inline as a single watch.

WidgetImageCrop.vue: remove @load/@error wiring on the <img> and stop
destructuring handleImageLoad/handleImageError from useImageCrop.
Crucially, do NOT add brightness-50 to the image — the crop overlay
already darkens the surroundings via shadow-[0_0_0_9999px_rgba(0,0,0,0.5)].

Tests: mock useImage at module level so harnesses can drive isReady
and error refs synchronously, then update existing harness/render
tests to use triggerImageLoad/triggerImageError helpers in place of
calling vm.handleImageLoad/Error or dispatching DOM 'load' events.
2026-07-06 11:22:46 -07:00
bymyself
961b434c40 refactor(vueNodes/ImagePreview): migrate error detection to useImage
Replace imageError ref + handleImageError with useImage's reactive
error ref. Source wrapped in computed() so useImage re-runs on
currentImageUrl/imageAltText changes, and useImage auto-resets error
when the source changes (replacing the manual resets in setCurrentIndex
and the imageUrls watcher).

Keep the @load handler on the rendered <img> so handleImageLoad
receives the actual DOM <img> element to pass to syncLegacyNodeImgs
(useImage's state ref is an off-DOM Image() and is not a substitute).

Test mocks useImage at the module level so tests don't depend on
network behaviour.
2026-07-06 11:22:46 -07:00
bymyself
00d0f08cbf refactor(linearMode/ImagePreview): use useImage for dimensions
Replace template ref + @load handler with useImage from @vueuse/core
for natural dimension extraction. Source wrapped in computed() so
useImage re-runs when src prop changes.

Read naturalWidth/Height from useImage's state ref (off-DOM Image)
rather than the rendered <img>, removing the need for a template ref
and avoiding the @load wiring.
2026-07-06 11:18:55 -07:00
bymyself
84f0871277 refactor(LivePreview): use useImage and cache last good dimensions
Replace manual @load/@error handlers and imageError ref with useImage
from @vueuse/core. Source is wrapped in computed() so useImage re-runs
when imageUrl changes.

Cache the last successfully loaded naturalWidth/Height in refs that
update only when isReady fires. This avoids the placeholder text
flickering back to 'Calculating dimensions' each time imageUrl changes
during live preview streaming.

Test mocks useImage at module level and drives state/isReady/error
manually while preserving @testing-library/vue assertions.
2026-07-06 11:18:55 -07:00
bymyself
6f7ec5ba34 refactor(PackBanner): use useImage and split src ternary into v-if/v-else
Replace isImageError ref + @error handler on <img> with useImage from
@vueuse/core. Source is wrapped in computed() so useImage re-runs when
nodePack changes. Template now branches with v-if/v-else on isImageError
instead of a ternary on :src + :class for clearer reading order.
2026-07-06 11:18:55 -07:00
bymyself
62222b7c04 refactor(UserAvatar): use useImage with reactive computed source
Replace manual imageError ref + Avatar @error handler with useImage
from @vueuse/core. Wrap the source in computed() so useImage re-runs
when photoUrl/ariaLabel props change.

Test mocks useImage at module level to control the error ref while
preserving the @testing-library/vue behavioral test style.
2026-07-06 11:18:55 -07:00
26 changed files with 911 additions and 615 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>

View 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>

View 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>

View File

@@ -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('')
})
})
})

View File

@@ -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

View File

@@ -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>

View File

@@ -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))
})

View 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'])
})
})

View 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 }
}

View File

@@ -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()

View File

@@ -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>

View File

@@ -21,8 +21,6 @@ function createDefaultCropState() {
isLockEnabled: ref(false),
cropBoxStyle: ref({}),
resizeHandles: ref([]),
handleImageLoad: () => {},
handleImageError: () => {},
handleDragStart: () => {},
handleDragMove: () => {},
handleDragEnd: () => {},

View File

@@ -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,

View File

@@ -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' }))

View File

@@ -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,

View File

@@ -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)
})
})

View 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()
})
})

View File

@@ -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"

View File

@@ -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(

View File

@@ -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()
}
}

View File

@@ -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()

View File

@@ -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>

View File

@@ -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)
})
})

View File

@@ -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>