Compare commits

..

4 Commits

Author SHA1 Message Date
Christian Byrne
8df5ac979e test: extract expectedCreditsInput helper to dedup credits→pay conversion 2026-05-03 21:52:11 -07:00
Glary-Bot
fe69964ef5 test: add credits→pay reverse sync test and harden max-boundary assertion
Add credits-driven sync test covering the creditsModel setter reverse
path. Harden max-boundary test with blur + credits clamp assertion to
catch pay/credits desync bugs.
2026-05-03 01:32:19 -07:00
Glary-Bot
81f604edf8 fix: type mock responses with satisfies and extract expectedCredits helper
Address CodeRabbit review: type route.fulfill() payloads using
CloudSubscriptionStatusResponse and GetCustomerBalanceResponse with
satisfies, and replace hardcoded credit values with expectedCredits()
helper using imported CREDITS_PER_USD constant.
2026-05-03 01:32:19 -07:00
Glary-Bot
d04450dac4 test: add FormattedNumberStepper coverage and TopUpCredits dialog E2E tests
Add comprehensive Vitest component tests (24 cases) covering all 12
previously untested functions in FormattedNumberStepper.vue through
behavioral user interactions. Add E2E integration smoke test for the
TopUpCredits dialog with page object extending BaseDialog.
2026-05-03 01:31:40 -07:00
36 changed files with 681 additions and 2353 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -69,50 +69,6 @@ test.describe('Homepage @smoke', () => {
).toBeVisible()
})
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
await expect(cta).toBeVisible()
const [columnBox, ctaBox] = await Promise.all([
contentColumn.boundingBox(),
cta.boundingBox()
])
expect(columnBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
})
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const subheading = contentColumn.getByText(
/Videos & case studies from teams/i
)
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
const [subBox, ctaBox] = await Promise.all([
subheading.boundingBox(),
cta.boundingBox()
])
expect(subBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()

View File

@@ -1,83 +0,0 @@
# Website Scripts
## `refresh-ashby-snapshot.ts`
Pulls the latest job postings from Ashby and writes
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
GitHub Actions workflow; also runnable locally via
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.
## `process-videos.sh`
Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
frame for marketing videos using `ffmpeg`. Run **locally** before
uploading the outputs to `media.comfy.org`; this is not wired into CI.
```sh
apps/website/scripts/process-videos.sh \
./video-sources \
./dist/videos \
"640 960 1280 1920"
```
### Output
For each source video at `./video-sources/foo.mp4`, you get:
```text
foo-640.webm foo-640.mp4
foo-960.webm foo-960.mp4
foo-1280.webm foo-1280.mp4
foo-1920.webm foo-1920.mp4
foo-poster.jpg
```
The naming convention is enforced by `buildVideoSources()` in
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
emit `<source>` URLs.
### Pairing with `<SiteVideo>`
Once the assets are uploaded, render them with:
```vue
<SiteVideo
name="foo"
base-url="https://media.comfy.org/website/marketing"
:width="1280"
:formats="['webm', 'mp4']"
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
autoplay
loop
/>
```
### `<SiteVideo>` vs `<VideoPlayer>`
- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
autoplay marketing clips. No custom controls, no captions UI.
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
fullscreen, and caption toggles. Use this for content with subtitles or
user-driven playback.
If you need both responsive sources and the rich `VideoPlayer` chrome, the
two are not yet combined; either pick one or extend `VideoPlayer` to accept
a source list.
### Encoder choices
- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
required for Safari iOS.
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
the video shows something while loading.
### Why a single resolution per video
`<source media="...">` inside `<video>` is unreliable across browsers
(Safari ignores it). The simplest correct strategy is to ship one
well-sized resolution and let CSS scale it down on smaller viewports.
The script generates multiple widths so you can pick a different one
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
JavaScript-based selection later if metrics demand it.

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env bash
#
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
# for every source video in a given directory. Intended to be run locally
# before uploading the outputs to media.comfy.org.
#
# Usage:
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
#
# Example:
# apps/website/scripts/process-videos.sh \
# ./video-sources \
# ./dist/videos \
# "640 960 1280 1920"
#
# Defaults to widths "1280" if omitted.
#
# Output naming matches buildVideoSources() in src/utils/video.ts:
# <name>-<width>.webm
# <name>-<width>.mp4
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
#
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.
set -euo pipefail
if [[ $# -lt 2 ]]; then
cat <<USAGE >&2
Usage: $0 <input-dir> <output-dir> [widths]
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
USAGE
exit 64
fi
input_dir=$1
output_dir=$2
widths=${3:-1280}
for tool in ffmpeg ffprobe; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found on PATH" >&2
exit 127
fi
done
if [[ ! -d $input_dir ]]; then
echo "error: input dir not found: $input_dir" >&2
exit 66
fi
mkdir -p "$output_dir"
shopt -s nullglob nocaseglob
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
shopt -u nullglob nocaseglob
if [[ ${#sources[@]} -eq 0 ]]; then
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
exit 66
fi
for src in "${sources[@]}"; do
name=$(basename "$src")
name=${name%.*}
echo "==> $name"
for w in $widths; do
webm_out="$output_dir/${name}-${w}.webm"
mp4_out="$output_dir/${name}-${w}.mp4"
echo " encoding ${w}w VP9/WebM -> $webm_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
-c:a libopus -b:a 96k \
-f webm "$webm_out"
echo " encoding ${w}w H.264/MP4 -> $mp4_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$mp4_out"
done
poster_out="$output_dir/${name}-poster.jpg"
duration_raw=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true)
if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then
duration="$duration_raw"
else
duration=0
fi
if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then
poster_seek=1
else
poster_seek=0
fi
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
ffmpeg -y -hide_banner -loglevel error \
-ss "$poster_seek" -i "$src" \
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
-q:v 4 \
"$poster_out"
done
echo "done. upload contents of $output_dir to media.comfy.org."

View File

@@ -1,51 +0,0 @@
# Marketing Assets
Source images committed here are processed by Astro at build time and emitted
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).
## Usage
Drop a high-resolution source image (PNG or JPG) here, then render it with
Astro's built-in `<Picture>` component plus the shared defaults:
```astro
---
import { Picture } from 'astro:assets'
import {
MARKETING_FORMATS,
MARKETING_WIDTHS
} from '../utils/marketingImage'
import hero from '../assets/marketing/hero.png'
---
<Picture
src={hero}
alt="ComfyUI workflow preview"
formats={[...MARKETING_FORMATS]}
widths={[...MARKETING_WIDTHS]}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```
The component generates a `<picture>` element with `<source>` tags for AVIF
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
`dist/_website/` for long-term caching.
A custom Astro wrapper component is intentionally not provided: Astro's
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
a thin wrapper that mutates `widths` / `formats` impractical to type safely
without `as` casts. The shared constants give us the same consistency benefit
without that cost.
## When to use this vs. `media.comfy.org`
- **Use `src/assets/marketing/`** for static marketing images that are part of
page content (hero shots, product imagery, illustrations). Build-time
processing gives you AVIF/WebP variants automatically.
- **Use `media.comfy.org`** for video content, large/changing image libraries
(gallery), and anything shared across properties.
## Source image guidelines
- Provide the largest size you'll ever need (≥1920px wide).
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
- Astro will downscale; it will not upscale. Always supply at least 1920w.

View File

@@ -88,7 +88,7 @@ const contactColumn = {
{ label: t('footer.sales', locale), href: routes.contact },
{
label: t('footer.support', locale),
href: externalLinks.support,
href: externalLinks.discord,
external: true
},
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { buildVideoSources, videoKey } from '../../utils/video'
import type { VideoFormat } from '../../utils/video'
const {
name,
baseUrl,
width = 1280,
formats = ['webm', 'mp4'],
poster,
alt,
autoplay = false,
loop = false,
muted = autoplay,
controls = false,
preload = autoplay ? 'auto' : 'metadata',
containerClass,
videoClass
} = defineProps<{
name: string
baseUrl: string
width?: number
formats?: VideoFormat[]
poster?: string
alt?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
controls?: boolean
preload?: 'auto' | 'metadata' | 'none'
containerClass?: string
videoClass?: string
}>()
const sources = computed(() =>
buildVideoSources({ name, baseUrl, width, formats })
)
const remountKey = computed(() => videoKey(sources.value))
const decorative = computed(() => !alt && !controls)
</script>
<template>
<div :class="cn('relative', containerClass)">
<video
:key="remountKey"
:class="cn('size-full', videoClass)"
:poster
:preload
:autoplay
:loop
:muted
:controls
:aria-label="alt"
:aria-hidden="decorative ? true : undefined"
playsinline
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
</video>
</div>
</template>

View File

@@ -35,10 +35,7 @@ const routes = getRoutes(locale)
</div>
<!-- Right: content -->
<div
data-testid="case-study-content"
class="flex flex-col justify-between p-6 lg:flex-1"
>
<div class="flex flex-col justify-between p-6 lg:flex-1">
<div class="flex flex-col gap-8">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
@@ -55,8 +52,12 @@ const routes = getRoutes(locale)
</p>
</div>
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
<BrandButton :href="routes.customers" variant="outline">
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
:href="routes.customers"
variant="outline"
class="flex-1 text-center"
>
{{ t('caseStudy.seeAll', locale) }}
</BrandButton>
</div>

View File

@@ -101,9 +101,17 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<span
v-if="feature.isComingSoon"
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
>
{{ t('pricing.included.comingSoon', locale) }}
</span>
</div>
</div>
<!-- Description -->

View File

@@ -35,7 +35,6 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/api-reference/cloud',
github: 'https://github.com/Comfy-Org/ComfyUI',
platform: 'https://platform.comfy.org',
support: 'https://support.comfy.org/hc/en-us',
workflows: 'https://comfy.org/workflows',
youtube: 'https://www.youtube.com/@ComfyOrg'
} as const

View File

@@ -914,9 +914,9 @@ const translations = {
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
},
'cloud.faq.3.a': {
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1280,6 +1280,10 @@ const translations = {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
'pricing.included.comingSoon': {
en: 'coming soon',
'zh-CN': '即将推出'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },

View File

@@ -1,3 +0,0 @@
export const MARKETING_FORMATS = ['avif', 'webp'] as const
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const

View File

@@ -1,111 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildVideoSources, videoKey } from './video'
describe('buildVideoSources', () => {
it('builds a source per requested format', () => {
const sources = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/website/marketing',
width: 1280,
formats: ['webm', 'mp4']
})
expect(sources).toEqual([
{
src: 'https://media.comfy.org/website/marketing/hero-1280.webm',
type: 'video/webm',
format: 'webm'
},
{
src: 'https://media.comfy.org/website/marketing/hero-1280.mp4',
type: 'video/mp4',
format: 'mp4'
}
])
})
it('preserves caller-supplied format order', () => {
const sources = buildVideoSources({
name: 'clip',
baseUrl: 'https://cdn.example.com/v',
width: 960,
formats: ['mp4', 'webm']
})
expect(sources.map((s) => s.format)).toEqual(['mp4', 'webm'])
})
it('strips a single trailing slash from baseUrl', () => {
const sources = buildVideoSources({
name: 'reel',
baseUrl: 'https://media.comfy.org/website/marketing/',
width: 1920,
formats: ['webm']
})
expect(sources[0]?.src).toBe(
'https://media.comfy.org/website/marketing/reel-1920.webm'
)
})
it('returns an empty list when no formats are requested', () => {
const sources = buildVideoSources({
name: 'x',
baseUrl: 'https://example.com',
width: 640,
formats: []
})
expect(sources).toEqual([])
})
})
describe('videoKey', () => {
it('changes when the source URL list changes', () => {
const at1280 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const at640 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 640,
formats: ['webm', 'mp4']
})
expect(videoKey(at1280)).not.toBe(videoKey(at640))
})
it('is stable across repeated calls with the same inputs', () => {
const args = {
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4'] as const
}
expect(
videoKey(buildVideoSources({ ...args, formats: [...args.formats] }))
).toBe(videoKey(buildVideoSources({ ...args, formats: [...args.formats] })))
})
it('reflects format-order changes', () => {
const webmFirst = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const mp4First = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['mp4', 'webm']
})
expect(videoKey(webmFirst)).not.toBe(videoKey(mp4First))
})
})

View File

@@ -1,49 +0,0 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
/** @knipIgnoreUsedByStackedPR */
export type VideoSource = {
src: string
type: `video/${VideoFormat}`
format: VideoFormat
}
const MIME_TYPES: Record<VideoFormat, VideoSource['type']> = {
webm: 'video/webm',
mp4: 'video/mp4'
}
type BuildArgs = {
name: string
baseUrl: string
width: number
formats: VideoFormat[]
}
/**
* Expects assets named `${name}-${width}.${format}` under `${baseUrl}/`,
* matching the output of `apps/website/scripts/process-videos.sh`.
*/
export function buildVideoSources({
name,
baseUrl,
width,
formats
}: BuildArgs): VideoSource[] {
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
return formats.map((format) => ({
src: `${base}/${name}-${width}.${format}`,
type: MIME_TYPES[format],
format
}))
}
/**
* Stable identifier for a list of video sources, suitable as a Vue `key`.
* Browsers do not reload a `<video>` when nested `<source>` children change;
* keying the parent forces a remount when the source set changes.
*/
export function videoKey(sources: VideoSource[]): string {
return sources.map((s) => s.src).join('|')
}

View File

@@ -1,7 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
import type { WorkspaceStore } from '@e2e/types/globals'
export class TopUpCreditsDialog extends BaseDialog {
readonly heading: Locator
@@ -13,8 +13,21 @@ export class TopUpCreditsDialog extends BaseDialog {
readonly payAmountInput: Locator
readonly pricingLink: Locator
readonly payInput: Locator
readonly creditsInput: Locator
readonly decrementPay: Locator
readonly incrementPay: Locator
readonly decrementCredits: Locator
readonly incrementCredits: Locator
readonly presetButtons: Locator
readonly buyButton: Locator
override readonly closeButton: Locator
readonly minWarning: Locator
readonly ceilingWarning: Locator
constructor(page: Page) {
super(page)
this.heading = this.root.getByRole('heading', { name: 'Add more credits' })
this.insufficientHeading = this.root.getByRole('heading', {
name: 'Add more credits to run'
@@ -41,6 +54,29 @@ export class TopUpCreditsDialog extends BaseDialog {
this.pricingLink = this.root.getByRole('link', {
name: 'View pricing details'
})
const steppers = this.root.locator('label')
const payStepper = steppers.first()
const creditsStepper = steppers.nth(1)
this.payInput = payStepper.locator('input[inputmode="numeric"]')
this.creditsInput = creditsStepper.locator('input[inputmode="numeric"]')
this.decrementPay = payStepper.getByRole('button', { name: 'Decrement' })
this.incrementPay = payStepper.getByRole('button', { name: 'Increment' })
this.decrementCredits = creditsStepper.getByRole('button', {
name: 'Decrement'
})
this.incrementCredits = creditsStepper.getByRole('button', {
name: 'Increment'
})
this.presetButtons = this.root.getByRole('button', { name: /^\$\d+$/ })
this.buyButton = this.root.getByRole('button', {
name: /continue to payment|add credits/i
})
// Headless dialog uses its own X button, not PrimeVue's header close
this.closeButton = this.root.locator('button:has([class*="lucide--x"])')
this.minWarning = this.root.getByText(/minimum/i)
this.ceilingWarning = this.root.getByText(/maximum/i)
}
async open(options?: { isInsufficientCredits?: boolean }) {
@@ -51,4 +87,8 @@ export class TopUpCreditsDialog extends BaseDialog {
}, options)
await this.waitForVisible()
}
getPresetButton(amount: number): Locator {
return this.root.getByRole('button', { name: `$${amount}`, exact: true })
}
}

View File

@@ -1,8 +1,26 @@
import type { operations } from '@comfyorg/registry-types'
import { expect } from '@playwright/test'
import { CREDITS_PER_USD } from '@/base/credits/comfyCredits'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
type GetCustomerBalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
// Step: $5 when pay < $100, $50 when pay < $1000, $100 otherwise
// MIN_AMOUNT = $5, MAX_AMOUNT = $10,000
function expectedCredits(usd: number): string {
return (usd * CREDITS_PER_USD).toLocaleString('en-US')
}
function expectedCreditsInput(usd: number): string {
return expectedCredits(usd).replaceAll(',', '')
}
test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
let dialog: TopUpCreditsDialog
@@ -56,3 +74,113 @@ test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
await expect(dialog.pricingLink).toHaveAttribute('target', '_blank')
})
})
test.describe('Top Up Credits Dialog', { tag: '@ui' }, () => {
let dialog: TopUpCreditsDialog
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.route(
'**/cloud-subscription-status**',
async (route) => {
await route.fulfill({
json: {
is_active: true,
subscription_tier: 'PRO'
} satisfies CloudSubscriptionStatusResponse
})
}
)
await comfyPage.page.route('**/customers/balance**', async (route) => {
await route.fulfill({
json: {
amount_micros: 1_000_000,
currency: 'usd'
} satisfies GetCustomerBalanceResponse
})
})
dialog = new TopUpCreditsDialog(comfyPage.page)
await dialog.open()
})
test('preset buttons update both steppers', async () => {
await dialog.getPresetButton(25).click()
await expect(dialog.payInput).toHaveValue('25')
await expect(dialog.creditsInput).toHaveValue(expectedCredits(25))
})
test('increment pay stepper updates credits', async () => {
await dialog.getPresetButton(25).click()
await dialog.incrementPay.click()
await expect(dialog.payInput).toHaveValue('30')
await expect(dialog.creditsInput).toHaveValue(expectedCredits(30))
})
test('decrement pay stepper updates credits', async () => {
await dialog.getPresetButton(50).click()
await dialog.decrementPay.click()
await expect(dialog.payInput).toHaveValue('45')
await expect(dialog.creditsInput).toHaveValue(expectedCredits(45))
})
test('typing in pay stepper updates credits', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('500')
await dialog.payInput.blur()
await expect(dialog.creditsInput).toHaveValue(expectedCredits(500))
})
test('typing in credits stepper updates pay', async () => {
const credits = expectedCreditsInput(50)
await dialog.creditsInput.fill('')
await dialog.creditsInput.pressSequentially(credits)
await dialog.creditsInput.blur()
await expect(dialog.payInput).toHaveValue('50')
})
test('max ceiling warning appears when exceeding max', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('99999')
await dialog.payInput.blur()
await expect(dialog.ceilingWarning).toBeVisible()
await expect(dialog.payInput).toHaveValue('10,000')
await expect(dialog.creditsInput).toHaveValue(expectedCredits(10_000))
})
test('min amount warning appears for values below minimum', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('2')
await dialog.payInput.blur()
await expect(dialog.minWarning).toBeVisible()
})
test('buy button disabled for sub-minimum amount', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('3')
await dialog.payInput.blur()
await expect(dialog.buyButton).toBeDisabled()
await expect(dialog.minWarning).toBeVisible()
})
test('buy button disabled when amount is zero', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('0')
await dialog.payInput.blur()
await expect(dialog.buyButton).toBeDisabled()
})
test('dialog closes via close button', async () => {
await dialog.closeButton.click()
await expect(dialog.root).toBeHidden()
})
})

View File

@@ -54,9 +54,6 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

@@ -13569,7 +13569,7 @@ export interface components {
stream: boolean | null;
};
/** @enum {string} */
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "gpt-5.5" | "gpt-5.5-pro" | "chatgpt-4o-latest";
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "chatgpt-4o-latest";
MoonvalleyTextToVideoInferenceParams: {
/**
* @description Height of the generated video in pixels

View File

@@ -98,7 +98,6 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
@@ -108,7 +107,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
@@ -121,7 +119,6 @@ const onClearHistoryFromMenu = (close: () => void) => {
}
const onToggleDockedJobHistory = async (close: () => void) => {
trackFeatureUsed()
close()
try {
@@ -141,7 +138,6 @@ const onToggleDockedJobHistory = async (close: () => void) => {
}
const onToggleRunProgressBar = async () => {
trackFeatureUsed()
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value

View File

@@ -13,7 +13,7 @@
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="onUpdateSelectedJobTab"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@@ -50,7 +50,6 @@ import type {
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -82,7 +81,6 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -97,11 +95,6 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
emit('update:selectedJobTab', value)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)

View File

@@ -66,7 +66,6 @@ import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -94,7 +93,6 @@ const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const {
totalPercentFormatted,
@@ -190,7 +188,6 @@ const {
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const jobId = item.taskRef?.jobId
if (!jobId) return
@@ -212,7 +209,6 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
@@ -228,12 +224,10 @@ const setExpanded = (expanded: boolean) => {
}
const viewAllJobs = () => {
trackFeatureUsed()
setExpanded(true)
}
const toggleAssetsSidebar = () => {
trackFeatureUsed()
sidebarTabStore.toggleSidebarTab('assets')
}
@@ -263,14 +257,12 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
trackFeatureUsed()
await openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
// Capture pending jobIds before clearing
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
@@ -283,7 +275,6 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const tasks = queueStore.runningTasks
const jobIds = tasks
.map((task) => task.jobId)
@@ -307,7 +298,6 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
})
const onClearHistoryFromMenu = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
</script>

View File

@@ -122,7 +122,6 @@ import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
const {
hideShowAssetsAction = false,
@@ -148,7 +147,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
@@ -172,7 +170,6 @@ const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
trackFeatureUsed()
selectWorkflowFilter(value)
close()
}
@@ -182,7 +179,6 @@ const selectSortMode = (value: JobSortMode) => {
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
trackFeatureUsed()
selectSortMode(value)
close()
}

View File

@@ -2,16 +2,15 @@
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="onClearHistory" />
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
</div>
</template>
<template #header>
<div class="flex flex-col gap-2 pb-1">
<div class="px-3 py-2">
<JobFilterTabs
:selected-job-tab="selectedJobTab"
v-model:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="onUpdateSelectedJobTab"
/>
</div>
<JobFilterActions
@@ -82,14 +81,13 @@ import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem, JobTab } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -106,17 +104,6 @@ const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const onClearHistory = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
selectedJobTab.value = value
}
const {
selectedJobTab,
selectedWorkflowFilter,
@@ -158,7 +145,6 @@ const activeQueueSummary = computed(() => {
})
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
@@ -174,7 +160,6 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
@@ -209,12 +194,10 @@ const { jobMenuEntries, cancelJob } = useJobMenu(
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
await cancelJob(item)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})

View File

@@ -1,51 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import ProgressToastItem from './ProgressToastItem.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
progressToast: {
finished: 'Finished',
failed: 'Failed',
pending: 'Pending'
}
}
}
})
function completedJob(): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'controlnet-canny.safetensors',
bytesTotal: 100,
bytesDownloaded: 100,
progress: 1,
status: 'completed',
lastUpdate: Date.now()
}
}
describe('ProgressToastItem — completed state', () => {
it('keeps the finished badge outside the dimmed (opacity-50) subtree', () => {
render(ProgressToastItem, {
props: { job: completedJob() },
global: { plugins: [i18n] }
})
const badge = screen.getByText('Finished')
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
expect(badge.closest('.opacity-50')).toBeNull()
const assetName = screen.getByText('controlnet-canny.safetensors')
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
expect(assetName.closest('.opacity-50')).not.toBeNull()
})
})

View File

@@ -22,9 +22,14 @@ const isPending = computed(() => job.status === 'created')
<template>
<div
class="flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3"
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
isCompleted && 'opacity-50'
)
"
>
<div :class="cn('min-w-0 flex-1', isCompleted && 'opacity-50')">
<div class="min-w-0 flex-1">
<span class="block truncate text-sm text-base-foreground">{{
job.assetName
}}</span>

View File

@@ -0,0 +1,334 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
decrement: 'Decrement',
increment: 'Increment'
}
}
}
})
async function flush() {
await nextTick()
await nextTick()
}
function renderStepper(
props?: Partial<{
modelValue: number
min: number
max: number
step: number | ((value: number) => number)
formatOptions: Intl.NumberFormatOptions
disabled: boolean
'onUpdate:modelValue': (value: number) => void
onMaxReached: () => void
}>
) {
const user = userEvent.setup()
const result = render(FormattedNumberStepper, {
props: { modelValue: 0, ...props },
global: { plugins: [i18n] }
})
return { user, ...result }
}
describe('FormattedNumberStepper', () => {
describe('rendering', () => {
it('renders increment and decrement buttons', () => {
renderStepper()
expect(
screen.getByRole('button', { name: 'Decrement' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Increment' })
).toBeInTheDocument()
})
it('renders formatted initial value', () => {
renderStepper({ modelValue: 1000 })
expect(screen.getByRole('textbox')).toHaveValue('1,000')
})
it('renders prefix slot content', () => {
render(FormattedNumberStepper, {
props: { modelValue: 0 },
slots: { prefix: '<span data-testid="prefix">$</span>' },
global: { plugins: [i18n] }
})
expect(screen.getByTestId('prefix')).toBeInTheDocument()
})
it('renders suffix slot content', () => {
render(FormattedNumberStepper, {
props: { modelValue: 0 },
slots: { suffix: '<span data-testid="suffix">USD</span>' },
global: { plugins: [i18n] }
})
expect(screen.getByTestId('suffix')).toBeInTheDocument()
})
it('applies disabled state to input and buttons', () => {
renderStepper({ disabled: true })
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Decrement' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Increment' })).toBeDisabled()
})
})
describe('handleStep', () => {
it('clicking + increments by step amount', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 1,
step: 2,
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(onUpdate).toHaveBeenCalledWith(3)
expect(screen.getByRole('textbox')).toHaveValue('3')
})
it('clicking decrements by step amount', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 10,
min: 0,
step: 2,
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Decrement' }))
expect(onUpdate).toHaveBeenCalledWith(8)
expect(screen.getByRole('textbox')).toHaveValue('8')
})
it('disables button at min', () => {
renderStepper({ modelValue: 0, min: 0 })
expect(screen.getByRole('button', { name: 'Decrement' })).toBeDisabled()
})
it('disables + button at max', () => {
renderStepper({ modelValue: 10, max: 10 })
expect(screen.getByRole('button', { name: 'Increment' })).toBeDisabled()
})
it('clamps value to [min, max] range', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 9,
min: 0,
max: 10,
step: 5,
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(onUpdate).toHaveBeenCalledWith(10)
expect(screen.getByRole('textbox')).toHaveValue('10')
})
it('calls function-based step prop with current value', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const stepFn = vi.fn((value: number) => value + 1)
const { user } = renderStepper({
modelValue: 4,
step: stepFn,
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(stepFn).toHaveBeenCalledWith(4)
expect(onUpdate).toHaveBeenCalledWith(9)
})
})
describe('handleInputChange', () => {
it('typing digits updates modelValue', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 0,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '1234')
expect(onUpdate).toHaveBeenLastCalledWith(1234)
})
it('strips non-numeric characters', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 0,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'a1b2c3')
expect(onUpdate).toHaveBeenLastCalledWith(123)
expect(input).toHaveValue('123')
})
it('formats input with grouping separators', async () => {
const { user } = renderStepper({ modelValue: 0 })
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '1000')
expect(input).toHaveValue('1,000')
})
it('emits max-reached when input exceeds max', async () => {
const onMaxReached = vi.fn()
const { user } = renderStepper({
modelValue: 0,
max: 500,
onMaxReached
})
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '999')
expect(onMaxReached).toHaveBeenCalled()
})
it('clamps to max when input exceeds max', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 0,
max: 500,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '999')
expect(onUpdate).toHaveBeenLastCalledWith(500)
expect(input).toHaveValue('500')
})
it('resolves empty input to 0', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 123,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.clear(input)
expect(onUpdate).toHaveBeenLastCalledWith(0)
expect(input).toHaveValue('0')
})
})
describe('handleInputBlur', () => {
it('clamps value below min to min on blur', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 5,
min: 10,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.click(input)
await user.tab()
expect(onUpdate).toHaveBeenLastCalledWith(10)
expect(input).toHaveValue('10')
})
it('reformats display on blur', async () => {
const { user } = renderStepper({ modelValue: 1000 })
const input = screen.getByRole('textbox')
await user.click(input)
await user.tab()
expect(input).toHaveValue('1,000')
})
})
describe('handleInputFocus', () => {
it('moves cursor to end of input on focus', async () => {
const { user } = renderStepper({ modelValue: 1000 })
const input = screen.getByRole('textbox') as HTMLInputElement
input.setSelectionRange(0, 0)
await user.click(input)
await flush()
expect(input.selectionStart).toBe(input.value.length)
expect(input.selectionEnd).toBe(input.value.length)
})
})
describe('v-model reactivity', () => {
it('external modelValue change updates displayed text', async () => {
const { rerender } = renderStepper({ modelValue: 1000 })
expect(screen.getByRole('textbox')).toHaveValue('1,000')
await rerender({ modelValue: 2500 })
await flush()
expect(screen.getByRole('textbox')).toHaveValue('2,500')
})
it('external modelValue change does not overwrite while focused', async () => {
const { user, rerender } = renderStepper({ modelValue: 1000 })
const input = screen.getByRole('textbox')
await user.click(input)
await user.clear(input)
await user.type(input, '2')
await rerender({ modelValue: 9000 })
await flush()
expect(input).toHaveValue('2')
})
})
describe('formatNumber', () => {
it('respects formatOptions prop', () => {
renderStepper({
modelValue: 1000,
formatOptions: {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}
})
expect(screen.getByRole('textbox')).toHaveValue('$1,000')
})
it('applies locale formatting with default options', () => {
renderStepper({ modelValue: 1234567 })
expect(screen.getByRole('textbox')).toHaveValue('1,234,567')
})
})
})

View File

@@ -633,14 +633,6 @@ export function useMediaAssetActions() {
)
if (hasOutputAssets) {
const succeededOutputIds = assetArray
.filter(
(a, i) =>
getAssetType(a) === 'output' &&
results[i].status === 'fulfilled'
)
.map((a) => a.id)
assetsStore.removeHistoryItems(succeededOutputIds)
await assetsStore.updateHistory()
}
if (hasInputAssets) {

View File

@@ -1,12 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
assetService,
isBlake3AssetHash,
toBlake3AssetHash
} from '@/platform/assets/services/assetService'
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
@@ -49,10 +44,6 @@ vi.mock('@/i18n', () => ({
const fetchApiMock = vi.mocked(api.fetchApi)
const validBlake3Hash =
'1111111111111111111111111111111111111111111111111111111111111111'
const validBlake3AssetHash = `blake3:${validBlake3Hash}`
function buildResponse(
body: unknown,
init: { ok?: boolean; status?: number } = {}
@@ -189,98 +180,9 @@ describe(assetService.getAssetMetadata, () => {
})
})
describe(isBlake3AssetHash, () => {
it('accepts only prefixed 64-character blake3 hashes', () => {
expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true)
expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe(
true
)
expect(isBlake3AssetHash('blake3:abc')).toBe(false)
expect(isBlake3AssetHash(validBlake3Hash)).toBe(false)
})
})
describe(toBlake3AssetHash, () => {
it('normalizes 64-character blake3 hex values to asset hashes', () => {
expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash)
expect(toBlake3AssetHash('abc')).toBeNull()
expect(toBlake3AssetHash(undefined)).toBeNull()
})
})
describe(assetService.uploadAssetFromUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png',
tags: ['input']
})
).rejects.toThrow('Failed to upload asset')
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
consoleSpy.mockRestore()
})
it('requires upload responses to include created_new', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
)
await assetService.getInputAssetsIncludingPublic()
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png',
tags: ['input']
})
).rejects.toThrow('Failed to upload asset')
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
consoleSpy.mockRestore()
})
it('returns validated upload responses with created_new', async () => {
const uploadedAsset = {
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
created_new: true
}
fetchApiMock.mockResolvedValueOnce(buildResponse(uploadedAsset))
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png',
tags: ['input']
})
).resolves.toEqual(uploadedAsset)
})
})
describe(assetService.uploadAssetFromBase64, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('throws before calling the network when data is not a data URL', async () => {
@@ -293,63 +195,6 @@ describe(assetService.uploadAssetFromBase64, () => {
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
await expect(
assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt',
tags: ['input']
})
).rejects.toThrow('Failed to upload asset')
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
fetchSpy.mockRestore()
consoleSpy.mockRestore()
})
it('rejects upload responses with a non-boolean created_new', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse({
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
created_new: 'true'
})
)
await assetService.getInputAssetsIncludingPublic()
await expect(
assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt',
tags: ['input']
})
).rejects.toThrow('Failed to upload asset')
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
fetchSpy.mockRestore()
consoleSpy.mockRestore()
})
})
describe(assetService.uploadAssetAsync, () => {
@@ -509,391 +354,3 @@ describe(assetService.getAssetsByTag, () => {
expect(params.get('include_public')).toBe('true')
})
})
describe(assetService.getAllAssetsByTag, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
]
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'c', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['a', 'b', 'c'])
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('limit')).toBe('2')
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
})
it('paginates from raw response size before filtering missing-tagged assets', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
]
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
if (typeof secondUrl !== 'string') {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
})
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
has_more: true
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
has_more: false
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 3
})
expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
if (typeof secondUrl !== 'string') {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
})
it('passes abort signals through paginated requests', async () => {
const controller = new AbortController()
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'a', tags: ['input'] })]
})
)
await assetService.getAllAssetsByTag('input', true, {
limit: 2,
signal: controller.signal
})
expect(fetchApiMock).toHaveBeenCalledWith(expect.any(String), {
signal: controller.signal
})
})
it('stops pagination when aborted between pages', async () => {
const controller = new AbortController()
fetchApiMock.mockImplementationOnce(async () => {
controller.abort()
return buildResponse({
assets: [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
]
})
})
await expect(
assetService.getAllAssetsByTag('input', true, {
limit: 2,
signal: controller.signal
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchApiMock).toHaveBeenCalledOnce()
})
})
describe(assetService.getInputAssetsIncludingPublic, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('loads input assets with public assets included and reuses the cache', async () => {
const assets = [
validAsset({ id: 'user-input', tags: ['input'] }),
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
]
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
const first = await assetService.getInputAssetsIncludingPublic()
const second = await assetService.getInputAssetsIncludingPublic()
expect(first).toEqual(assets)
expect(second).toBe(first)
expect(fetchApiMock).toHaveBeenCalledOnce()
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('true')
expect(params.get('limit')).toBe('500')
})
it('fetches fresh input assets after explicit invalidation', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it('does not let one caller abort the shared input asset load for other callers', async () => {
const firstController = new AbortController()
const secondController = new AbortController()
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
let resolveResponse!: (response: Response) => void
let serviceSignal: AbortSignal | undefined
fetchApiMock.mockImplementationOnce(async (_url, options) => {
serviceSignal = options?.signal ?? undefined
return await new Promise<Response>((resolve) => {
resolveResponse = resolve
})
})
const first = assetService.getInputAssetsIncludingPublic(
firstController.signal
)
const second = assetService.getInputAssetsIncludingPublic(
secondController.signal
)
firstController.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(serviceSignal).toBeUndefined()
resolveResponse(buildResponse({ assets }))
await expect(second).resolves.toEqual(assets)
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('keeps the shared input asset load alive after all callers abort', async () => {
const firstController = new AbortController()
const secondController = new AbortController()
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
let resolveResponse!: (response: Response) => void
fetchApiMock.mockImplementationOnce(
async () =>
await new Promise<Response>((resolve) => {
resolveResponse = resolve
})
)
const first = assetService.getInputAssetsIncludingPublic(
firstController.signal
)
const second = assetService.getInputAssetsIncludingPublic(
secondController.signal
)
firstController.abort()
secondController.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
resolveResponse(buildResponse({ assets }))
await Promise.resolve()
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
assets
)
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('does not abort in-flight input asset loads when invalidated', async () => {
const assets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
let resolveResponse!: (response: Response) => void
fetchApiMock
.mockImplementationOnce(
async () =>
await new Promise<Response>((resolve) => {
resolveResponse = resolve
})
)
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
const inFlight = assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
resolveResponse(buildResponse({ assets }))
await expect(inFlight).resolves.toEqual(assets)
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
freshAssets
)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it('invalidates cached input assets after deleting an asset', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(null))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
await assetService.deleteAsset('stale-input')
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(3)
expect(fetchApiMock.mock.calls[1]).toEqual([
'/assets/stale-input',
expect.objectContaining({ method: 'DELETE' })
])
})
it('invalidates cached input assets after an input asset upload', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
const freshAssets = [uploadedAsset]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(uploadedAsset))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/input.png',
tags: ['input']
})
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(3)
})
it('does not invalidate cached input assets for pending async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'running' },
{ ok: true, status: 202 }
)
)
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/input.png',
tags: ['input']
})
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it('does not invalidate cached input assets for non-input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors',
tags: ['models']
})
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
})
describe(assetService.checkAssetHash, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
[200, 'exists'],
[404, 'missing'],
[400, 'invalid']
] as const)('maps %s responses to %s', async (status, expected) => {
const hash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status }))
await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected)
expect(fetchApiMock).toHaveBeenCalledWith(
`/assets/hash/${encodeURIComponent(hash)}`,
{
method: 'HEAD',
signal: undefined
}
)
})
it('throws for unexpected responses', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 }))
await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow(
'Unexpected asset hash check status: 500'
)
})
})

View File

@@ -1,5 +1,4 @@
import { fromZodError } from 'zod-validation-error'
import { z } from 'zod'
import { st } from '@/i18n'
@@ -30,14 +29,9 @@ export interface PaginationOptions {
offset?: number
}
interface AssetPaginationOptions extends PaginationOptions {
signal?: AbortSignal
}
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
includePublic?: boolean
signal?: AbortSignal
}
interface AssetExportOptions {
@@ -176,61 +170,10 @@ const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
export const MODELS_TAG = 'models'
/** Asset tag used by the backend for placeholder records that are not installed. */
export const MISSING_TAG = 'missing'
/** Result of a HEAD lookup against an exact asset hash. */
export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i
const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i
const uploadedAssetResponseSchema = assetItemSchema.extend({
created_new: z.boolean()
})
/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */
export function isBlake3AssetHash(value: string): boolean {
return BLAKE3_ASSET_HASH_PATTERN.test(value)
}
/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */
export function toBlake3AssetHash(hash: string | undefined): string | null {
if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null
return `blake3:${hash}`
}
function createAbortError(): DOMException {
return new DOMException('Aborted', 'AbortError')
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createAbortError()
}
async function withCallerAbort<T>(
promise: Promise<T>,
signal?: AbortSignal
): Promise<T> {
throwIfAborted(signal)
if (!signal) return await promise
let removeAbortListener = () => {}
const abortPromise = new Promise<never>((_, reject) => {
const onAbort = () => reject(createAbortError())
signal.addEventListener('abort', onAbort, { once: true })
removeAbortListener = () => signal.removeEventListener('abort', onAbort)
})
try {
return await Promise.race([promise, abortPromise])
} finally {
removeAbortListener()
}
}
/**
* Validates asset response data using Zod schema
*/
@@ -244,43 +187,11 @@ function validateAssetResponse(data: unknown): AssetResponse {
)
}
function validateUploadedAssetResponse(
data: unknown
): AssetItem & { created_new: boolean } {
const result = uploadedAssetResponseSchema.safeParse(data)
if (result.success) {
return result.data
}
console.error('Invalid asset upload response:', fromZodError(result.error))
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to upload asset. Please try again.'
)
)
}
/**
* Private service for asset-related network requests
* Not exposed globally - used internally by ComfyApi
*/
function createAssetService() {
let inputAssetsIncludingPublic: AssetItem[] | null = null
let inputAssetsIncludingPublicRequestId = 0
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
function invalidateInputAssetsIncludingPublic(): void {
inputAssetsIncludingPublicRequestId++
pendingInputAssetsIncludingPublic = null
inputAssetsIncludingPublic = null
}
function invalidateInputAssetsCacheIfNeeded(tags?: string[]): void {
if (tags?.includes('input')) invalidateInputAssetsIncludingPublic()
}
/**
* Handles API response with consistent error handling and Zod validation
*/
@@ -292,8 +203,7 @@ function createAssetService() {
includeTags,
limit = DEFAULT_LIMIT,
offset,
includePublic,
signal
includePublic
} = options
const queryParams = new URLSearchParams({
include_tags: includeTags.join(','),
@@ -307,9 +217,7 @@ function createAssetService() {
}
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
const res = signal
? await api.fetchApi(url, { signal })
: await api.fetchApi(url)
const res = await api.fetchApi(url)
if (!res.ok) {
throw new Error(
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
@@ -495,16 +403,15 @@ function createAssetService() {
* @param options - Pagination options
* @param options.limit - Maximum number of assets to return (default: 500)
* @param options.offset - Number of assets to skip (default: 0)
* @param options.signal - Optional abort signal for cancelling the request
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
*/
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
{ limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {}
): Promise<AssetItem[]> {
const data = await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic, signal },
{ includeTags: [tag], limit, offset, includePublic },
`assets for tag ${tag}`
)
@@ -513,116 +420,6 @@ function createAssetService() {
)
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
* @param options - Pagination options
* @param options.limit - Page size for each request (default: 500)
* @param options.signal - Optional abort signal for cancelling requests
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag
*/
async function getAllAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
let offset = 0
while (true) {
if (signal?.aborted) throw createAbortError()
const data = await handleAssetRequest(
{
includeTags: [tag],
limit: pageSize,
offset,
includePublic,
signal
},
`assets for tag ${tag}`
)
const batch = data.assets ?? []
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
const noMoreFromServer = data.has_more === false
const inferredLastPage =
data.has_more === undefined && batch.length < pageSize
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
return assets
}
offset += batch.length
}
}
function startInputAssetsIncludingPublicRequest(): Promise<AssetItem[]> {
const requestId = ++inputAssetsIncludingPublicRequestId
pendingInputAssetsIncludingPublic = getAllAssetsByTag('input', true, {
limit: INPUT_ASSETS_WITH_PUBLIC_LIMIT
})
.then((assets) => {
if (requestId === inputAssetsIncludingPublicRequestId) {
inputAssetsIncludingPublic = assets
}
return assets
})
.finally(() => {
if (requestId === inputAssetsIncludingPublicRequestId) {
pendingInputAssetsIncludingPublic = null
}
})
void pendingInputAssetsIncludingPublic.catch(() => {})
return pendingInputAssetsIncludingPublic
}
/**
* Gets cached input assets including public assets for missing media checks.
* Caller aborts cancel only that caller; shared fetches are invalidated
* through invalidateInputAssetsIncludingPublic().
*/
async function getInputAssetsIncludingPublic(
signal?: AbortSignal
): Promise<AssetItem[]> {
throwIfAborted(signal)
if (inputAssetsIncludingPublic) return inputAssetsIncludingPublic
const request =
pendingInputAssetsIncludingPublic ??
startInputAssetsIncludingPublicRequest()
return await withCallerAbort(request, signal)
}
/**
* Checks whether an asset exists for an exact asset hash.
*
* Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses:
* 200 -> exists, 404 -> missing, and 400 -> invalid hash format.
*/
async function checkAssetHash(
assetHash: string,
signal?: AbortSignal
): Promise<AssetHashStatus> {
const response = await api.fetchApi(
`${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`,
{
method: 'HEAD',
signal
}
)
if (response.status === 200) return 'exists'
if (response.status === 404) return 'missing'
if (response.status === 400) return 'invalid'
throw new Error(`Unexpected asset hash check status: ${response.status}`)
}
/**
* Deletes an asset by ID
* Only available in cloud environment
@@ -641,8 +438,6 @@ function createAssetService() {
`Unable to delete asset ${id}: Server returned ${res.status}`
)
}
invalidateInputAssetsIncludingPublic()
}
/**
@@ -750,9 +545,7 @@ function createAssetService() {
)
}
const asset = validateUploadedAssetResponse(await res.json())
invalidateInputAssetsCacheIfNeeded(params.tags)
return asset
return await res.json()
}
/**
@@ -805,9 +598,7 @@ function createAssetService() {
)
}
const asset = validateUploadedAssetResponse(await res.json())
invalidateInputAssetsCacheIfNeeded(params.tags)
return asset
return await res.json()
}
/**
@@ -837,7 +628,6 @@ function createAssetService() {
if (!parseResult.success) {
throw fromZodError(parseResult.error)
}
invalidateInputAssetsIncludingPublic()
return parseResult.data
}
@@ -868,7 +658,6 @@ function createAssetService() {
if (!parseResult.success) {
throw fromZodError(parseResult.error)
}
invalidateInputAssetsIncludingPublic()
return parseResult.data
}
@@ -920,13 +709,6 @@ function createAssetService() {
)
)
}
if (
params.tags?.includes('input') &&
result.data.type === 'async' &&
result.data.task.status === 'completed'
) {
invalidateInputAssetsIncludingPublic()
}
return result.data
}
@@ -942,7 +724,6 @@ function createAssetService() {
)
)
}
invalidateInputAssetsCacheIfNeeded(params.tags)
return result.data
}
@@ -983,10 +764,6 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
getAllAssetsByTag,
getInputAssetsIncludingPublic,
invalidateInputAssetsIncludingPublic,
checkAssetHash,
deleteAsset,
updateAsset,
addAssetTags,

View File

@@ -1,11 +1,9 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
@@ -15,13 +13,6 @@ import {
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted(
() => ({
mockCheckAssetHash: vi.fn(),
mockGetInputAssetsIncludingPublic: vi.fn()
})
)
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
@@ -30,21 +21,6 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
) => node._testExecutionId ?? String(node.id)
}))
vi.mock('@/platform/assets/services/assetService', async () => {
const actual = await vi.importActual<typeof AssetServiceModule>(
'@/platform/assets/services/assetService'
)
return {
...actual,
assetService: {
...actual.assetService,
checkAssetHash: mockCheckAssetHash,
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
}
}
})
function makeCandidate(
nodeId: string,
name: string,
@@ -94,16 +70,6 @@ function makeGraph(nodes: LGraphNode[]): LGraph {
return fromAny<LGraph, unknown>({ _testNodes: nodes })
}
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
return {
id: name,
name,
asset_hash: assetHash,
mime_type: null,
tags: ['input']
}
}
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
@@ -266,43 +232,37 @@ describe('groupCandidatesByMediaType', () => {
})
describe('verifyCloudMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
const missingHash =
'blake3:2222222222222222222222222222222222222222222222222222222222222222'
beforeEach(() => {
vi.clearAllMocks()
mockCheckAssetHash.mockResolvedValue('missing')
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
})
it('marks candidates missing when the asset hash is not found', async () => {
it('marks candidates missing when not in input assets', async () => {
const candidates = [
makeCandidate('1', missingHash, { isMissing: undefined }),
makeCandidate('2', existingHash, { isMissing: undefined })
makeCandidate('1', 'abc123.png', { isMissing: undefined }),
makeCandidate('2', 'def456.png', { isMissing: undefined })
]
const checkAssetHash = vi.fn(async (assetHash: string) =>
assetHash === existingHash ? ('exists' as const) : ('missing' as const)
)
const mockStore = {
updateInputs: async () => {},
inputAssets: [{ asset_hash: 'def456.png', name: 'my-photo.png' }]
}
await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash)
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(candidates[0].isMissing).toBe(true)
expect(candidates[1].isMissing).toBe(false)
})
it('uses assetService.checkAssetHash by default', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
mockCheckAssetHash.mockResolvedValue('exists')
it('calls updateInputs before checking assets', async () => {
let updateCalled = false
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
await verifyCloudMediaCandidates(candidates)
const mockStore = {
updateInputs: async () => {
updateCalled = true
},
inputAssets: []
}
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(updateCalled).toBe(true)
})
it('respects abort signal before execution', async () => {
@@ -310,221 +270,69 @@ describe('verifyCloudMediaCandidates', () => {
controller.abort()
const candidates = [
makeCandidate('1', missingHash, { isMissing: undefined })
makeCandidate('1', 'abc123.png', { isMissing: undefined })
]
await verifyCloudMediaCandidates(candidates, controller.signal)
expect(candidates[0].isMissing).toBeUndefined()
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('respects abort signal after hash verification', async () => {
it('respects abort signal after updateInputs', async () => {
const controller = new AbortController()
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
const checkAssetHash = vi.fn(async () => {
controller.abort()
return 'exists' as const
})
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
await verifyCloudMediaCandidates(
candidates,
controller.signal,
checkAssetHash
)
const mockStore = {
updateInputs: async () => {
controller.abort()
},
inputAssets: [{ asset_hash: 'abc.png', name: 'photo.png' }]
}
await verifyCloudMediaCandidates(candidates, controller.signal, mockStore)
expect(candidates[0].isMissing).toBeUndefined()
})
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
await verifyCloudMediaCandidates(candidates)
const mockStore = {
updateInputs: async () => {},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(candidates[0].isMissing).toBe(true)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
const candidates = [makeCandidate('1', 'abc.png', { isMissing: false })]
await verifyCloudMediaCandidates(candidates)
const mockStore = {
updateInputs: async () => {},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('skips entirely when no pending candidates', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
let updateCalled = false
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
await verifyCloudMediaCandidates(candidates)
const mockStore = {
updateInputs: async () => {
updateCalled = true
},
inputAssets: []
}
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
it('falls back to input assets for non-blake3 candidate names', async () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined }),
makeCandidate('2', 'missing.png', { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => [
makeAsset('stored-photo.png', 'photo.png')
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(true)
})
it('uses public input assets for default legacy fallback', async () => {
const candidates = [
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
]
const inputAssets = Array.from({ length: 500 }, (_, index) =>
makeAsset(`asset-${index}.png`)
)
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
await verifyCloudMediaCandidates(candidates)
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
expect(candidates[0].isMissing).toBe(false)
})
it('silences aborts while loading legacy fallback input assets', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => {
controller.abort()
throw abortError
})
await expect(
verifyCloudMediaCandidates(
candidates,
controller.signal,
undefined,
fetchInputAssets
)
).resolves.toBeUndefined()
expect(candidates[0].isMissing).toBeUndefined()
})
it('silences aborts from the default legacy fallback input asset store path', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
controller.abort()
throw abortError
})
await expect(
verifyCloudMediaCandidates(candidates, controller.signal)
).resolves.toBeUndefined()
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
controller.signal
)
expect(candidates[0].isMissing).toBeUndefined()
})
it('falls back to input assets when the hash endpoint returns 400', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
mockCheckAssetHash.mockResolvedValue('invalid')
const fetchInputAssets = vi.fn(async () => [
makeAsset('photo.png', existingHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
})
it('falls back to input assets when hash verification fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
const checkAssetHash = vi.fn(async () => {
throw new Error('network failed')
})
const fetchInputAssets = vi.fn(async () => [
makeAsset('photo.png', existingHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
checkAssetHash,
fetchInputAssets
)
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
expect(warn).toHaveBeenCalledOnce()
warn.mockRestore()
})
it('does not call the hash endpoint for malformed blake3-looking values', async () => {
const malformedHash = 'blake3:abc'
const candidates = [
makeCandidate('1', malformedHash, { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => [
makeAsset('legacy.png', malformedHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
})
it('deduplicates checks for repeated candidate names', async () => {
const candidates = [
makeCandidate('1', missingHash, { isMissing: undefined }),
makeCandidate('2', missingHash, { isMissing: undefined })
]
await verifyCloudMediaCandidates(candidates)
expect(mockCheckAssetHash).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(true)
expect(candidates[1].isMissing).toBe(true)
expect(updateCalled).toBe(false)
})
})

View File

@@ -18,12 +18,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import {
assetService,
isBlake3AssetHash
} from '@/platform/assets/services/assetService'
/** Map of node types to their media widget name and media type. */
const MEDIA_NODE_WIDGETS: Record<
@@ -112,130 +106,41 @@ export function scanNodeMediaCandidates(
return candidates
}
type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
candidatesByHash: Map<string, MissingMediaCandidate[]>
legacyCandidates: MissingMediaCandidate[]
} {
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
const legacyCandidates: MissingMediaCandidate[] = []
for (const candidate of candidates) {
if (!isBlake3AssetHash(candidate.name)) {
legacyCandidates.push(candidate)
continue
}
const hashCandidates = candidatesByHash.get(candidate.name)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(candidate.name, [candidate])
}
return { candidatesByHash, legacyCandidates }
}
async function verifyCandidatesByHash(
candidatesByHash: Map<string, MissingMediaCandidate[]>,
legacyCandidates: MissingMediaCandidate[],
signal: AbortSignal | undefined,
checkAssetHash: AssetHashVerifier
): Promise<void> {
await Promise.all(
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
if (signal?.aborted) return
let status: AssetHashStatus
try {
status = await checkAssetHash(assetHash, signal)
if (signal?.aborted) return
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
console.warn(
'[Missing Media Pipeline] Failed to verify asset hash:',
err
)
legacyCandidates.push(...hashCandidates)
return
}
if (status === 'invalid') {
legacyCandidates.push(...hashCandidates)
return
}
for (const candidate of hashCandidates) {
candidate.isMissing = status === 'missing'
}
})
)
interface InputVerifier {
updateInputs: () => Promise<unknown>
inputAssets: Array<{ asset_hash?: string | null; name: string }>
}
/**
* Verify cloud media candidates by probing the asset hash endpoint first.
* Invalid hash values fall back to the legacy input asset list check.
* Verify cloud media candidates against the input assets fetched from the
* assets store. Mutates candidates' `isMissing` in place.
*/
export async function verifyCloudMediaCandidates(
candidates: MissingMediaCandidate[],
signal?: AbortSignal,
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash,
fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
assetsStore?: InputVerifier
): Promise<void> {
if (signal?.aborted) return
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
const { candidatesByHash, legacyCandidates } =
groupCandidatesForHashLookup(pending)
await verifyCandidatesByHash(
candidatesByHash,
legacyCandidates,
signal,
checkAssetHash
)
const store =
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
if (signal?.aborted || legacyCandidates.length === 0) return
let inputAssets: AssetItem[]
try {
inputAssets = await fetchInputAssets(signal)
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
throw err
}
await store.updateInputs()
if (signal?.aborted) return
const assetHashes = new Set(
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
store.inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
)
for (const candidate of legacyCandidates) {
candidate.isMissing = !assetHashes.has(candidate.name)
for (const c of pending) {
c.isMissing = !assetHashes.has(c.name)
}
}
async function fetchMissingInputAssets(
signal?: AbortSignal
): Promise<AssetItem[]> {
return await assetService.getInputAssetsIncludingPublic(signal)
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
err.name === 'AbortError'
)
}
/** Group confirmed-missing candidates by file name into view models. */
export function groupCandidatesByName(
candidates: MissingMediaCandidate[]

View File

@@ -19,11 +19,6 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
const { mockCheckAssetHash } = vi.hoisted(() => ({
mockCheckAssetHash: vi.fn()
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
@@ -33,20 +28,6 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
) => node._testExecutionId ?? String(node.id)
}))
vi.mock('@/platform/assets/services/assetService', async () => {
const actual = await vi.importActual<typeof AssetServiceModule>(
'@/platform/assets/services/assetService'
)
return {
...actual,
assetService: {
...actual.assetService,
checkAssetHash: mockCheckAssetHash
}
}
})
/** Helper: create a combo widget mock */
function makeComboWidget(
name: string,
@@ -62,7 +43,7 @@ function makeComboWidget(
}
/** Helper: create an asset widget mock (Cloud combo replacement) */
function makeAssetWidget(name: string, value: unknown): IBaseWidget {
function makeAssetWidget(name: string, value: string): IBaseWidget {
return fromAny<IBaseWidget, unknown>({
type: 'asset',
name,
@@ -570,16 +551,6 @@ describe('scanAllModelCandidates', () => {
expect(result).toEqual([])
})
it('should skip asset widgets with non-string values', () => {
const graph = makeGraph([
makeNode(1, 'SomeNode', [makeAssetWidget('ckpt_name', 123)])
])
const result = scanAllModelCandidates(graph, noAssetSupport)
expect(result).toEqual([])
})
it('should scan both combo and asset widgets on the same node', () => {
const graph = makeGraph([
makeNode(1, 'DualLoaderNode', [
@@ -1440,7 +1411,6 @@ function makeAssetCandidate(
describe('verifyAssetSupportedCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckAssetHash.mockResolvedValue('missing')
mockIsModelLoading.mockReturnValue(false)
mockHasMore.mockReturnValue(false)
mockGetAssets.mockReturnValue([])
@@ -1458,125 +1428,6 @@ describe('verifyAssetSupportedCandidates', () => {
)
})
it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => {
const hash =
'1111111111111111111111111111111111111111111111111111111111111111'
const candidates = [
makeAssetCandidate('model.safetensors', {
hash,
hashType: 'blake3'
})
]
mockCheckAssetHash.mockResolvedValue('exists')
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined)
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
})
it('should fall back to asset store matching when the blake3 hash is not found', async () => {
const hash =
'2222222222222222222222222222222222222222222222222222222222222222'
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash,
hashType: 'blake3'
})
]
mockCheckAssetHash.mockResolvedValue('missing')
mockGetAssets.mockReturnValue([
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
}
])
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
})
it('should fall back to asset store matching when hash verification fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const hash =
'3333333333333333333333333333333333333333333333333333333333333333'
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash,
hashType: 'blake3'
})
]
mockCheckAssetHash.mockRejectedValue(new Error('network failed'))
mockGetAssets.mockReturnValue([
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
}
])
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(warn).toHaveBeenCalledOnce()
warn.mockRestore()
})
it('should skip malformed blake3 hashes and use asset store matching', async () => {
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash: 'abc123',
hashType: 'blake3'
})
]
mockGetAssets.mockReturnValue([
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
}
])
await verifyAssetSupportedCandidates(candidates)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(candidates[0].isMissing).toBe(false)
})
it('should not warn or fall back when hash verification is aborted', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const hash =
'4444444444444444444444444444444444444444444444444444444444444444'
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash,
hashType: 'blake3'
})
]
mockCheckAssetHash.mockRejectedValue(abortError)
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBeUndefined()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()
})
it('should resolve isMissing=false when asset with matching hash exists', async () => {
const candidates = [
makeAssetCandidate('model.safetensors', {
@@ -1591,7 +1442,6 @@ describe('verifyAssetSupportedCandidates', () => {
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('should resolve isMissing=false when asset with matching filename exists', async () => {

View File

@@ -24,11 +24,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import {
assetService,
toBlake3AssetHash
} from '@/platform/assets/services/assetService'
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
models?: ModelFile[]
@@ -182,7 +177,7 @@ function scanAssetWidget(
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
if (typeof value !== 'string' || !value.trim()) return null
if (!value.trim()) return null
if (!isModelFileName(value)) return null
return {
@@ -450,68 +445,20 @@ interface AssetVerifier {
getAssets: (nodeType: string) => AssetItem[] | undefined
}
type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
assetsStore?: AssetVerifier,
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
assetsStore?: AssetVerifier
): Promise<void> {
if (signal?.aborted) return
const pendingCandidates = candidates.filter(
(c) => c.isAssetSupported && c.isMissing === undefined
)
if (pendingCandidates.length === 0) return
const pendingNodeTypes = new Set<string>()
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
for (const candidate of pendingCandidates) {
const assetHash = getBlake3AssetHash(candidate)
if (!assetHash) {
pendingNodeTypes.add(candidate.nodeType)
continue
for (const c of candidates) {
if (c.isAssetSupported && c.isMissing === undefined) {
pendingNodeTypes.add(c.nodeType)
}
const hashCandidates = candidatesByHash.get(assetHash)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(assetHash, [candidate])
}
await Promise.all(
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
if (signal?.aborted) return
try {
const status = await checkAssetHash(assetHash, signal)
if (signal?.aborted) return
if (status === 'exists') {
for (const candidate of hashCandidates) {
candidate.isMissing = false
}
return
}
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
console.warn(
'[Missing Model Pipeline] Failed to verify asset hash:',
err
)
}
for (const candidate of hashCandidates) {
pendingNodeTypes.add(candidate.nodeType)
}
})
)
if (signal?.aborted) return
if (pendingNodeTypes.size === 0) return
const store =
@@ -544,20 +491,6 @@ export async function verifyAssetSupportedCandidates(
}
}
function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
if (candidate.hashType?.toLowerCase() !== 'blake3') return null
return toBlake3AssetHash(candidate.hash)
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
err.name === 'AbortError'
)
}
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}

View File

@@ -11,12 +11,6 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
triggerThreshold: 3,
delayMs: 5000
},
'queue-progress-overlay': {
featureId: 'queue-progress-overlay',
typeformId: 'HZ5saxry',
triggerThreshold: 16,
delayMs: 5000
},
'error-panel': {
featureId: 'error-panel',
typeformId: 'iFp4p4mV',

View File

@@ -24,9 +24,7 @@ vi.mock('@/scripts/api', () => ({
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetsByTag: vi.fn(),
getAllAssetsByTag: vi.fn(),
getAssetsForNodeType: vi.fn(),
invalidateInputAssetsIncludingPublic: vi.fn(),
updateAsset: vi.fn(),
addAssetTags: vi.fn(),
removeAssetTags: vi.fn()
@@ -426,93 +424,6 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('Deletion', () => {
it('should remove items by id from history', async () => {
const mockHistory = Array.from({ length: 5 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(5)
store.removeHistoryItems(['prompt_1', 'prompt_3'])
expect(store.historyAssets).toHaveLength(3)
const ids = store.historyAssets.map((a) => a.id)
expect(ids).not.toContain('prompt_1')
expect(ids).not.toContain('prompt_3')
})
it('should adjust pagination offset after deletion', async () => {
const mockHistory = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
// Delete 3 items — offset shifts from 200 to 197 so the next page
// request lines up with the server's post-deletion cursor and we don't
// skip the rows that backfilled into the old page-1 boundary.
store.removeHistoryItems(['prompt_1', 'prompt_3', 'prompt_5'])
expect(store.historyAssets).toHaveLength(197)
const nextBatch = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(nextBatch)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 197 })
})
it('should compose deletion offset with drift on next loadMore', async () => {
// Initial page 1 (200 items: prompt_0 .. prompt_199)
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
// 3 net-new items merge in via a refresh — drift becomes 3.
const newJobs = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(7000 + i)
)
const refreshedPage1 = [...newJobs, ...page1.slice(0, 197)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
// Delete 2 loaded items — historyOffset should drop from 200 to 198.
store.removeHistoryItems(['prompt_10', 'prompt_20'])
// Next loadMore offset must compose deletion + drift:
// adjustedOffset = (200 - 2) + 3 = 201
// If historyOffset is not decremented the request becomes 203 and the
// server rows at the new positions 201202 are silently skipped.
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 201 })
})
it('should allow re-inserting a removed item on next updateHistory', async () => {
const mockHistory = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
store.removeHistoryItems(['prompt_1'])
expect(store.historyAssets).toHaveLength(2)
// Simulate the item reappearing from the server (e.g. delete failed)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(3)
})
})
describe('Error Handling', () => {
it('should preserve existing data when loadMore fails', async () => {
// First successful load - full batch
@@ -629,142 +540,6 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('Incremental Update', () => {
it('should preserve loaded items when updateHistory is called after pagination', async () => {
// Use indices 100-299 for page1 so there's room for a "newer" item
const page1 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(100 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(300 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(store.historyAssets).toHaveLength(400)
// Index 0 → Date.now() - 0ms, which is newer than index 100+
const newJob = createMockJobItem(0)
const refreshedPage1 = [newJob, ...page1.slice(0, 199)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(401)
expect(store.historyAssets[0].id).toBe('prompt_0')
})
it('should not duplicate items when updateHistory fetches overlapping data', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([...page1])
await store.updateHistory()
expect(store.historyAssets).toHaveLength(200)
})
it('should adjust offset for loadMore after new items are prepended', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const newJobs = Array.from({ length: 5 }, (_, i) =>
createMockJobItem(5000 + i)
)
const refreshedPage1 = [...newJobs, ...page1.slice(0, 195)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 205 })
})
it('should preserve drift counter when loadMore fails', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const newJobs = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(6000 + i)
)
const refreshedPage1 = [...newJobs, ...page1.slice(0, 197)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
// loadMore fails — drift counter should be preserved
vi.mocked(api.getHistory).mockRejectedValueOnce(new Error('fail'))
await store.loadMoreHistory()
// Retry loadMore — should still include the drift adjustment
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 203 })
})
it('should re-enable pagination when history grows past one page', async () => {
// Initial load with fewer than BATCH_SIZE items — pagination exhausted
const smallPage = Array.from({ length: 50 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(smallPage)
await store.updateHistory()
expect(store.hasMoreHistory).toBe(false)
// Many new jobs arrive — first page is now full (BATCH_SIZE items)
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(7000 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(fullPage)
await store.updateHistory()
expect(store.hasMoreHistory).toBe(true)
})
it('should not accumulate drift when pagination is exhausted', async () => {
const smallPage = Array.from({ length: 50 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(smallPage)
await store.updateHistory()
// Subsequent update with a few new items, still under BATCH_SIZE
const newJobs = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(8000 + i)
)
const refreshed = [...newJobs, ...smallPage.slice(0, 47)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshed)
await store.updateHistory()
// Re-enable pagination with a full page
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(9000 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(fullPage)
await store.updateHistory()
// loadMore should use offset 200 (no drift accumulated while exhausted)
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 200 })
})
})
describe('jobDetailView Support', () => {
it('should include outputCount and allOutputs in user_metadata', async () => {
const mockHistory = Array.from({ length: 5 }, (_, i) =>
@@ -1484,9 +1259,6 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
false,
{ limit: 100 }
)
expect(
assetService.invalidateInputAssetsIncludingPublic
).toHaveBeenCalledOnce()
} finally {
mockIsCloud.value = false
}

View File

@@ -94,7 +94,7 @@ export const useAssetsStore = defineStore('assets', () => {
// Track assets currently being deleted (for loading overlay)
const deletingAssetIds = shallowReactive(new Set<string>())
function setAssetDeleting(assetId: string, isDeleting: boolean) {
const setAssetDeleting = (assetId: string, isDeleting: boolean) => {
if (isDeleting) {
deletingAssetIds.add(assetId)
} else {
@@ -102,7 +102,7 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
function isAssetDeleting(assetId: string): boolean {
const isAssetDeleting = (assetId: string): boolean => {
return deletingAssetIds.has(assetId)
}
@@ -110,7 +110,6 @@ export const useAssetsStore = defineStore('assets', () => {
const historyOffset = ref(0)
const hasMoreHistory = ref(true)
const isLoadingMore = ref(false)
const prependedSinceLastLoad = ref(0)
const allHistoryItems = ref<AssetItem[]>([])
@@ -124,7 +123,7 @@ export const useAssetsStore = defineStore('assets', () => {
state: inputAssets,
isLoading: inputLoading,
error: inputError,
execute: executeUpdateInputs
execute: updateInputs
} = useAsyncState(fetchInputFiles, [], {
immediate: false,
resetOnExecute: false,
@@ -133,131 +132,68 @@ export const useAssetsStore = defineStore('assets', () => {
}
})
const updateInputs = async () => {
const result = await executeUpdateInputs()
assetService.invalidateInputAssetsIncludingPublic()
return result
}
/**
* Insert an asset into the sorted list at the correct position (newest first).
* Skips duplicates already tracked in loadedIds.
* @returns true if the asset was inserted, false if it was a duplicate.
* Fetch history assets with pagination support
* @param loadMore - true for pagination (append), false for initial load (replace)
*/
function insertAssetSorted(asset: AssetItem): boolean {
if (loadedIds.has(asset.id)) return false
loadedIds.add(asset.id)
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
// Reset state for initial load
if (!loadMore) {
historyOffset.value = 0
hasMoreHistory.value = true
allHistoryItems.value = []
loadedIds.clear()
}
const assetTime = new Date(asset.created_at ?? 0).getTime()
// Sort: newer first; ties broken by lexicographically larger id first
// so insertion order is stable across repeated merges.
const insertIndex = allHistoryItems.value.findIndex((item) => {
const itemTime = new Date(item.created_at ?? 0).getTime()
if (itemTime !== assetTime) return itemTime < assetTime
return item.id < asset.id
// Fetch from server with offset
const history = await api.getHistory(BATCH_SIZE, {
offset: historyOffset.value
})
if (insertIndex === -1) {
allHistoryItems.value.push(asset)
} else {
allHistoryItems.value.splice(insertIndex, 0, asset)
}
return true
}
/**
* Remove items from the local view (used after the server confirmed the
* delete). Decrements historyOffset by the count of removed loaded items so
* the canonical caller flow — `removeHistoryItems(ids)` then
* `await updateHistory()` then `loadMoreHistory()` — produces the correct
* server offset:
*
* After deletion, the refreshed first page that `updateHistory()` fetches
* gets backfilled with items that previously sat just past the page-1
* boundary. Those backfilled items legitimately count as drift. If we
* leave `historyOffset` untouched, `loadMoreHistory()` then computes
* `historyOffset + drift` which double-counts the deletion: it adds the
* backfilled items on top of the original cursor and skips that many
* unseen rows on the next page.
*/
function removeHistoryItems(ids: string[]) {
const idSet = new Set(ids)
const removedCount = allHistoryItems.value.filter((item) =>
idSet.has(item.id)
).length
allHistoryItems.value = allHistoryItems.value.filter(
(item) => !idSet.has(item.id)
)
ids.forEach((id) => loadedIds.delete(id))
historyOffset.value = Math.max(0, historyOffset.value - removedCount)
historyAssets.value = allHistoryItems.value
}
function trimToMaxItems() {
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
const removed = allHistoryItems.value.splice(MAX_HISTORY_ITEMS)
removed.forEach((item) => loadedIds.delete(item.id))
}
}
/**
* Fetch the first page of history. On initial load (empty state), replaces
* all items. On subsequent calls, incrementally merges new items without
* clearing existing pagination state — fixing the "disappearing assets" bug.
*/
async function fetchHistoryIncremental(): Promise<void> {
const history = await api.getHistory(BATCH_SIZE, { offset: 0 })
// Convert JobListItems to AssetItems
const newAssets = mapHistoryToAssets(history)
if (allHistoryItems.value.length === 0) {
if (loadMore) {
// Filter out duplicates and insert in sorted order
for (const asset of newAssets) {
if (loadedIds.has(asset.id)) {
continue // Skip duplicates
}
loadedIds.add(asset.id)
// Find insertion index to maintain sorted order (newest first)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
// Asset is oldest, append to end
allHistoryItems.value.push(asset)
} else {
// Insert at the correct position
allHistoryItems.value.splice(insertIndex, 0, asset)
}
}
} else {
// Initial load: replace all
allHistoryItems.value = newAssets
newAssets.forEach((asset) => loadedIds.add(asset.id))
historyOffset.value = BATCH_SIZE
hasMoreHistory.value = history.length === BATCH_SIZE
} else {
let newCount = 0
for (const asset of newAssets) {
if (insertAssetSorted(asset)) newCount++
}
if (hasMoreHistory.value) {
prependedSinceLastLoad.value += newCount
} else if (history.length === BATCH_SIZE) {
hasMoreHistory.value = true
prependedSinceLastLoad.value = 0
}
}
trimToMaxItems()
}
/**
* Fetch the next page of history for infinite scroll pagination.
* Adjusts offset to account for items prepended since the last page load.
*/
async function fetchHistoryNextPage(): Promise<void> {
const driftAtRequest = prependedSinceLastLoad.value
const adjustedOffset = historyOffset.value + driftAtRequest
const history = await api.getHistory(BATCH_SIZE, {
offset: adjustedOffset
})
const newAssets = mapHistoryToAssets(history)
// Subtract only the drift captured at request time so concurrent
// updateHistory() increments during the in-flight fetch are preserved.
prependedSinceLastLoad.value = Math.max(
0,
prependedSinceLastLoad.value - driftAtRequest
)
for (const asset of newAssets) {
insertAssetSorted(asset)
}
historyOffset.value = adjustedOffset + BATCH_SIZE
// Update pagination state
historyOffset.value += BATCH_SIZE
hasMoreHistory.value = history.length === BATCH_SIZE
trimToMaxItems()
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
// Clean up Set
removed.forEach((item) => loadedIds.delete(item.id))
}
return allHistoryItems.value
}
const historyAssets = ref<AssetItem[]>([])
@@ -265,19 +201,18 @@ export const useAssetsStore = defineStore('assets', () => {
const historyError = ref<unknown>(null)
/**
* Load or refresh history assets. On first call, performs a full load.
* On subsequent calls (e.g. after job completion), incrementally merges
* new items without clearing existing pagination state.
* Initial load of history assets
*/
async function updateHistory() {
const updateHistory = async () => {
historyLoading.value = true
historyError.value = null
try {
await fetchHistoryIncremental()
await fetchHistoryAssets(false)
historyAssets.value = allHistoryItems.value
} catch (err) {
console.error('Error fetching history assets:', err)
historyError.value = err
// Keep existing data when error occurs
if (!historyAssets.value.length) {
historyAssets.value = []
}
@@ -289,18 +224,20 @@ export const useAssetsStore = defineStore('assets', () => {
/**
* Load more history items (infinite scroll)
*/
async function loadMoreHistory() {
const loadMoreHistory = async () => {
// Guard: prevent concurrent loads and check if more items available
if (!hasMoreHistory.value || isLoadingMore.value) return
isLoadingMore.value = true
historyError.value = null
try {
await fetchHistoryNextPage()
await fetchHistoryAssets(true)
historyAssets.value = allHistoryItems.value
} catch (err) {
console.error('Error loading more history:', err)
historyError.value = err
// Keep existing data when error occurs (consistent with updateHistory)
if (!historyAssets.value.length) {
historyAssets.value = []
}
@@ -804,7 +741,6 @@ export const useAssetsStore = defineStore('assets', () => {
updateInputs,
updateHistory,
loadMoreHistory,
removeHistoryItems,
// Input mapping helpers
inputAssetsByFilename,