mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
4 Commits
glary/remo
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
521fed4fe9 | ||
|
|
4afe0baa72 | ||
|
|
f508a060a0 | ||
|
|
5f43461623 |
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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': '播放' },
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const MARKETING_FORMATS = ['avif', 'webp'] as const
|
||||
|
||||
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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('|')
|
||||
}
|
||||
@@ -1,9 +1,60 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { STABLE_INPUT_IMAGE } from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
const test = comfyPageFixture
|
||||
const PUBLIC_INPUT_ASSET_HASH = 'nonexistent_test_image_12345.png'
|
||||
const PUBLIC_INPUT_ASSET: Asset = {
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'test-public-input-001',
|
||||
name: 'public_reference_photo.png',
|
||||
asset_hash: PUBLIC_INPUT_ASSET_HASH
|
||||
}
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
const cloudTest = comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
stubCloudAssets: void
|
||||
}>({
|
||||
cloudAssetRequests: async ({ page: _page }, use) => {
|
||||
await use([])
|
||||
},
|
||||
stubCloudAssets: [
|
||||
async ({ cloudAssetRequests, page }, use) => {
|
||||
const assetsRoutePattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetsRouteHandler = (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = url.searchParams.get('include_tags') ?? ''
|
||||
const tags = includeTags.split(',').filter(Boolean)
|
||||
const includePublic = url.searchParams.get('include_public') === 'true'
|
||||
const assets =
|
||||
tags.includes('input') && includePublic ? [PUBLIC_INPUT_ASSET] : []
|
||||
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(assets))
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(assetsRoutePattern, assetsRouteHandler)
|
||||
await use()
|
||||
await page.unroute(assetsRoutePattern, assetsRouteHandler)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
@@ -203,3 +254,49 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
cloudTest.describe(
|
||||
'Errors tab - Missing media cloud assets',
|
||||
{
|
||||
tag: '@cloud'
|
||||
},
|
||||
() => {
|
||||
cloudTest.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
cloudTest(
|
||||
'does not report public input assets as missing',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((requestUrl) => {
|
||||
const url = new URL(requestUrl)
|
||||
const includeTags = url.searchParams.get('include_tags') ?? ''
|
||||
return (
|
||||
includeTags.split(',').includes('input') &&
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tanstack/vue-query": "catalog:",
|
||||
"@tanstack/vue-virtual": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
|
||||
404
pnpm-lock.yaml
generated
404
pnpm-lock.yaml
generated
@@ -108,9 +108,6 @@ catalogs:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.83.0
|
||||
version: 5.100.9
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.12
|
||||
version: 3.13.12
|
||||
@@ -479,9 +476,6 @@ importers:
|
||||
'@sparkjsdev/spark':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.10
|
||||
'@tanstack/vue-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.100.9(vue@3.5.13(typescript@5.9.3))
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: 'catalog:'
|
||||
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -857,7 +851,7 @@ importers:
|
||||
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vue-component-type-helpers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.6
|
||||
@@ -1003,7 +997,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
packages/design-system:
|
||||
dependencies:
|
||||
@@ -2657,41 +2651,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@inquirer/ansi@2.0.5':
|
||||
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
|
||||
'@inquirer/confirm@6.0.12':
|
||||
resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@11.1.9':
|
||||
resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@2.0.5':
|
||||
resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
|
||||
'@inquirer/type@4.0.5':
|
||||
resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@internationalized/date@3.9.0':
|
||||
resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==}
|
||||
|
||||
@@ -2827,10 +2786,6 @@ packages:
|
||||
'@mixpanel/rrweb@2.0.0-alpha.18.2':
|
||||
resolution: {integrity: sha512-J3dVTEu6Z4p8di7y9KKvUooNuBjX97DdG6XGWoPEPi07A9512h9M8MEtvlY3mK0PGfuC0Mz5Pv/Ws6gjGYfKQg==}
|
||||
|
||||
'@mswjs/interceptors@0.41.8':
|
||||
resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -2972,18 +2927,6 @@ packages:
|
||||
'@one-ini/wasm@0.1.1':
|
||||
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
|
||||
|
||||
'@open-draft/deferred-promise@2.2.0':
|
||||
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
|
||||
|
||||
'@open-draft/deferred-promise@3.0.0':
|
||||
resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
|
||||
|
||||
'@open-draft/logger@0.3.0':
|
||||
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
|
||||
|
||||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -4254,25 +4197,9 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^8.0.0
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/query-core@5.100.9':
|
||||
resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12':
|
||||
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
|
||||
|
||||
'@tanstack/vue-query@5.100.9':
|
||||
resolution: {integrity: sha512-wGiv/AirRuITlTDl87zdBRaZIZTejMItUswKgMzzcX/1gfn95iKw2EaCuz7qlX9ceB0DwBj9FqaroLnDoJCecg==}
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.1.2
|
||||
vue: ^2.6.0 || ^3.3.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12':
|
||||
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
|
||||
peerDependencies:
|
||||
@@ -4578,15 +4505,9 @@ packages:
|
||||
'@types/semver@7.7.0':
|
||||
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
|
||||
|
||||
'@types/set-cookie-parser@2.4.10':
|
||||
resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
|
||||
|
||||
'@types/stats.js@0.17.3':
|
||||
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
|
||||
|
||||
'@types/statuses@2.0.6':
|
||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||
|
||||
'@types/three@0.169.0':
|
||||
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
|
||||
|
||||
@@ -5678,10 +5599,6 @@ packages:
|
||||
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
cli-width@4.1.0:
|
||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -6532,12 +6449,6 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-string-truncated-width@3.0.3:
|
||||
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
|
||||
|
||||
fast-unique-numbers@9.0.22:
|
||||
resolution: {integrity: sha512-dBR+30yHAqBGvOuxxQdnn2lTLHCO6r/9B+M4yF8mNrzr3u1yiF+YVJ6u3GTyPN/VRWqaE1FcscZDdBgVKmrmQQ==}
|
||||
engines: {node: '>=18.2.0'}
|
||||
@@ -6545,9 +6456,6 @@ packages:
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fast-wrap-ansi@0.2.0:
|
||||
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
|
||||
|
||||
fastest-levenshtein@1.0.16:
|
||||
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
|
||||
engines: {node: '>= 4.9.1'}
|
||||
@@ -6824,10 +6732,6 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
graphql@16.13.2:
|
||||
resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
|
||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||
|
||||
gray-matter@4.0.3:
|
||||
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -6910,9 +6814,6 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
headers-polyfill@5.0.1:
|
||||
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
@@ -7169,9 +7070,6 @@ packages:
|
||||
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-node-process@1.2.0:
|
||||
resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
|
||||
|
||||
is-npm@6.1.0:
|
||||
resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -8017,16 +7915,6 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
msw@2.14.3:
|
||||
resolution: {integrity: sha512-kk8G5cocVlJ4wsKMGZegn2H6XLOEKjbA+nSJE2354e/SRp4mDicCHUYnMXpymzVcVDCs+GUAsmNqSn+yHv4T2A==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>= 4.8.x'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
@@ -8034,10 +7922,6 @@ packages:
|
||||
resolution: {integrity: sha512-SsI/exkodHsh+ofCV7An2PZWRaJC7eFVl7gtHQlMWFEDmWtb7cELr/GK32Nhe/6dZQhbr81o+Moswx9aXN3RRg==}
|
||||
engines: {node: '>=18.2.0'}
|
||||
|
||||
mute-stream@3.0.0:
|
||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -8222,9 +8106,6 @@ packages:
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
outvariant@1.4.3:
|
||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||
|
||||
own-keys@1.0.1:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -8353,9 +8234,6 @@ packages:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||
|
||||
path-type@4.0.0:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8801,9 +8679,6 @@ packages:
|
||||
remark-stringify@11.0.0:
|
||||
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||
|
||||
remove-accents@0.5.0:
|
||||
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||
|
||||
request-light@0.5.8:
|
||||
resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==}
|
||||
|
||||
@@ -8862,9 +8737,6 @@ packages:
|
||||
retext@9.0.0:
|
||||
resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==}
|
||||
|
||||
rettime@0.11.11:
|
||||
resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
@@ -8960,9 +8832,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-cookie-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9096,10 +8965,6 @@ packages:
|
||||
standardized-audio-context@25.3.77:
|
||||
resolution: {integrity: sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@@ -9119,9 +8984,6 @@ packages:
|
||||
stream-replace-string@2.0.0:
|
||||
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
|
||||
|
||||
string-argv@0.3.2:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
@@ -9378,10 +9240,6 @@ packages:
|
||||
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
@@ -9460,10 +9318,6 @@ packages:
|
||||
resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
type-fest@5.6.0:
|
||||
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9720,9 +9574,6 @@ packages:
|
||||
uploadthing:
|
||||
optional: true
|
||||
|
||||
until-async@3.0.2:
|
||||
resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
|
||||
|
||||
update-browserslist-db@1.2.2:
|
||||
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
|
||||
hasBin: true
|
||||
@@ -10032,8 +9883,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.8:
|
||||
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
|
||||
vue-component-type-helpers@3.2.7:
|
||||
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -12206,64 +12057,6 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/ansi@2.0.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/confirm@6.0.12(@types/node@24.10.4)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.1.9(@types/node@24.10.4)
|
||||
'@inquirer/type': 4.0.5(@types/node@24.10.4)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.4
|
||||
optional: true
|
||||
|
||||
'@inquirer/confirm@6.0.12(@types/node@25.0.3)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.1.9(@types/node@25.0.3)
|
||||
'@inquirer/type': 4.0.5(@types/node@25.0.3)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.3
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@11.1.9(@types/node@24.10.4)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.5
|
||||
'@inquirer/figures': 2.0.5
|
||||
'@inquirer/type': 4.0.5(@types/node@24.10.4)
|
||||
cli-width: 4.1.0
|
||||
fast-wrap-ansi: 0.2.0
|
||||
mute-stream: 3.0.0
|
||||
signal-exit: 4.1.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.4
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@11.1.9(@types/node@25.0.3)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.5
|
||||
'@inquirer/figures': 2.0.5
|
||||
'@inquirer/type': 4.0.5(@types/node@25.0.3)
|
||||
cli-width: 4.1.0
|
||||
fast-wrap-ansi: 0.2.0
|
||||
mute-stream: 3.0.0
|
||||
signal-exit: 4.1.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.3
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@2.0.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@4.0.5(@types/node@24.10.4)':
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.4
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@4.0.5(@types/node@25.0.3)':
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.3
|
||||
optional: true
|
||||
|
||||
'@internationalized/date@3.9.0':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
@@ -12503,16 +12296,6 @@ snapshots:
|
||||
base64-arraybuffer: 1.0.2
|
||||
mitt: 3.0.1
|
||||
|
||||
'@mswjs/interceptors@0.41.8':
|
||||
dependencies:
|
||||
'@open-draft/deferred-promise': 2.2.0
|
||||
'@open-draft/logger': 0.3.0
|
||||
'@open-draft/until': 2.1.0
|
||||
is-node-process: 1.2.0
|
||||
outvariant: 1.4.3
|
||||
strict-event-emitter: 0.5.1
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.8.1
|
||||
@@ -12719,7 +12502,7 @@ snapshots:
|
||||
tsconfig-paths: 4.2.0
|
||||
tslib: 2.8.1
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/traverse'
|
||||
- '@swc-node/register'
|
||||
@@ -12739,7 +12522,7 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/traverse'
|
||||
- '@swc-node/register'
|
||||
@@ -12768,21 +12551,6 @@ snapshots:
|
||||
|
||||
'@one-ini/wasm@0.1.1': {}
|
||||
|
||||
'@open-draft/deferred-promise@2.2.0':
|
||||
optional: true
|
||||
|
||||
'@open-draft/deferred-promise@3.0.0':
|
||||
optional: true
|
||||
|
||||
'@open-draft/logger@0.3.0':
|
||||
dependencies:
|
||||
is-node-process: 1.2.0
|
||||
outvariant: 1.4.3
|
||||
optional: true
|
||||
|
||||
'@open-draft/until@2.1.0':
|
||||
optional: true
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -13637,7 +13405,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.8
|
||||
vue-component-type-helpers: 3.2.7
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -13718,22 +13486,8 @@ snapshots:
|
||||
tailwindcss: 4.2.0
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
dependencies:
|
||||
remove-accents: 0.5.0
|
||||
|
||||
'@tanstack/query-core@5.100.9': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
'@tanstack/vue-query@5.100.9(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/match-sorter-utils': 8.19.4
|
||||
'@tanstack/query-core': 5.100.9
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.3))
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.12
|
||||
@@ -14078,16 +13832,8 @@ snapshots:
|
||||
|
||||
'@types/semver@7.7.0': {}
|
||||
|
||||
'@types/set-cookie-parser@2.4.10':
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
optional: true
|
||||
|
||||
'@types/stats.js@0.17.3': {}
|
||||
|
||||
'@types/statuses@2.0.6':
|
||||
optional: true
|
||||
|
||||
'@types/three@0.169.0':
|
||||
dependencies:
|
||||
'@tweenjs/tween.js': 23.1.3
|
||||
@@ -14372,7 +14118,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -14393,22 +14139,20 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@4.0.16(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.16
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.14.3(@types/node@24.10.4)(typescript@5.9.3)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/mocker@4.0.16(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.16
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.14.3(@types/node@25.0.3)(typescript@5.9.3)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
@@ -14445,7 +14189,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -15483,9 +15227,6 @@ snapshots:
|
||||
slice-ansi: 7.1.2
|
||||
string-width: 8.2.0
|
||||
|
||||
cli-width@4.1.0:
|
||||
optional: true
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -16488,14 +16229,6 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-string-truncated-width@3.0.3:
|
||||
optional: true
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
dependencies:
|
||||
fast-string-truncated-width: 3.0.3
|
||||
optional: true
|
||||
|
||||
fast-unique-numbers@9.0.22:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -16503,11 +16236,6 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-wrap-ansi@0.2.0:
|
||||
dependencies:
|
||||
fast-string-width: 3.0.2
|
||||
optional: true
|
||||
|
||||
fastest-levenshtein@1.0.16: {}
|
||||
|
||||
fastq@1.20.1:
|
||||
@@ -16824,9 +16552,6 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
graphql@16.13.2:
|
||||
optional: true
|
||||
|
||||
gray-matter@4.0.3:
|
||||
dependencies:
|
||||
js-yaml: 3.14.2
|
||||
@@ -16972,12 +16697,6 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
headers-polyfill@5.0.1:
|
||||
dependencies:
|
||||
'@types/set-cookie-parser': 2.4.10
|
||||
set-cookie-parser: 3.1.0
|
||||
optional: true
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
hookified@1.14.0: {}
|
||||
@@ -17239,9 +16958,6 @@ snapshots:
|
||||
is-negative-zero@2.0.3:
|
||||
optional: true
|
||||
|
||||
is-node-process@1.2.0:
|
||||
optional: true
|
||||
|
||||
is-npm@6.1.0: {}
|
||||
|
||||
is-number-object@1.1.1:
|
||||
@@ -18238,58 +17954,6 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@inquirer/confirm': 6.0.12(@types/node@24.10.4)
|
||||
'@mswjs/interceptors': 0.41.8
|
||||
'@open-draft/deferred-promise': 3.0.0
|
||||
'@types/statuses': 2.0.6
|
||||
cookie: 1.1.1
|
||||
graphql: 16.13.2
|
||||
headers-polyfill: 5.0.1
|
||||
is-node-process: 1.2.0
|
||||
outvariant: 1.4.3
|
||||
path-to-regexp: 6.3.0
|
||||
picocolors: 1.1.1
|
||||
rettime: 0.11.11
|
||||
statuses: 2.0.2
|
||||
strict-event-emitter: 0.5.1
|
||||
tough-cookie: 6.0.1
|
||||
type-fest: 5.6.0
|
||||
until-async: 3.0.2
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
optional: true
|
||||
|
||||
msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@inquirer/confirm': 6.0.12(@types/node@25.0.3)
|
||||
'@mswjs/interceptors': 0.41.8
|
||||
'@open-draft/deferred-promise': 3.0.0
|
||||
'@types/statuses': 2.0.6
|
||||
cookie: 1.1.1
|
||||
graphql: 16.13.2
|
||||
headers-polyfill: 5.0.1
|
||||
is-node-process: 1.2.0
|
||||
outvariant: 1.4.3
|
||||
path-to-regexp: 6.3.0
|
||||
picocolors: 1.1.1
|
||||
rettime: 0.11.11
|
||||
statuses: 2.0.2
|
||||
strict-event-emitter: 0.5.1
|
||||
tough-cookie: 6.0.1
|
||||
type-fest: 5.6.0
|
||||
until-async: 3.0.2
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
optional: true
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
multi-buffer-data-view@6.0.22:
|
||||
@@ -18297,9 +17961,6 @@ snapshots:
|
||||
'@babel/runtime': 7.29.2
|
||||
tslib: 2.8.1
|
||||
|
||||
mute-stream@3.0.0:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
@@ -18545,9 +18206,6 @@ snapshots:
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
outvariant@1.4.3:
|
||||
optional: true
|
||||
|
||||
own-keys@1.0.1:
|
||||
dependencies:
|
||||
get-intrinsic: 1.3.0
|
||||
@@ -18757,9 +18415,6 @@ snapshots:
|
||||
lru-cache: 11.2.6
|
||||
minipass: 7.1.3
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
optional: true
|
||||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
pathe@0.2.0: {}
|
||||
@@ -19361,8 +19016,6 @@ snapshots:
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
unified: 11.0.5
|
||||
|
||||
remove-accents@0.5.0: {}
|
||||
|
||||
request-light@0.5.8: {}
|
||||
|
||||
request-light@0.7.0: {}
|
||||
@@ -19425,9 +19078,6 @@ snapshots:
|
||||
retext-stringify: 4.0.0
|
||||
unified: 11.0.5
|
||||
|
||||
rettime@0.11.11:
|
||||
optional: true
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
@@ -19550,9 +19200,6 @@ snapshots:
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
set-cookie-parser@3.1.0:
|
||||
optional: true
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -19734,9 +19381,6 @@ snapshots:
|
||||
automation-events: 7.1.11
|
||||
tslib: 2.8.1
|
||||
|
||||
statuses@2.0.2:
|
||||
optional: true
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -19769,9 +19413,6 @@ snapshots:
|
||||
|
||||
stream-replace-string@2.0.0: {}
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
optional: true
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
@@ -20073,11 +19714,6 @@ snapshots:
|
||||
dependencies:
|
||||
tldts: 7.0.19
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.19
|
||||
optional: true
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tr46@6.0.0:
|
||||
@@ -20146,11 +19782,6 @@ snapshots:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
type-fest@5.6.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
optional: true
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -20414,9 +20045,6 @@ snapshots:
|
||||
ofetch: 1.5.1
|
||||
ufo: 1.6.3
|
||||
|
||||
until-async@3.0.2:
|
||||
optional: true
|
||||
|
||||
update-browserslist-db@1.2.2(browserslist@4.28.1):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
@@ -20706,10 +20334,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
'@vitest/runner': 4.0.16
|
||||
'@vitest/snapshot': 4.0.16
|
||||
@@ -20748,10 +20376,10 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
'@vitest/runner': 4.0.16
|
||||
'@vitest/snapshot': 4.0.16
|
||||
@@ -20902,7 +20530,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-component-type-helpers@3.2.8: {}
|
||||
vue-component-type-helpers@3.2.7: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -37,7 +37,6 @@ catalog:
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tanstack/vue-query': ^5.83.0
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const PAYLOAD_KEY_SAMPLE = 10
|
||||
|
||||
export function summarizeError(err: unknown): Record<string, unknown> {
|
||||
if (axios.isAxiosError(err)) {
|
||||
return {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
status: err.response?.status
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { message: err.message, name: err.name }
|
||||
}
|
||||
return { message: String(err) }
|
||||
}
|
||||
|
||||
export function summarizePayload(data: unknown): Record<string, unknown> {
|
||||
if (data === null) return { type: 'null' }
|
||||
if (data === undefined) return { type: 'undefined' }
|
||||
if (Array.isArray(data)) return { type: 'array', length: data.length }
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data as Record<string, unknown>)
|
||||
return {
|
||||
type: 'object',
|
||||
keys: keys.slice(0, PAYLOAD_KEY_SAMPLE),
|
||||
keyCount: keys.length
|
||||
}
|
||||
}
|
||||
return { type: typeof data }
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import * as fc from 'fast-check'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapToDropdownItem } from '@/base/remote/itemSchema'
|
||||
|
||||
describe('mapToDropdownItem property tests', () => {
|
||||
it('mapping is total and stable for arbitrary string fields', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
id: fc.string(),
|
||||
name: fc.string()
|
||||
}),
|
||||
(raw) => {
|
||||
const schema = {
|
||||
value_field: 'id',
|
||||
label_field: 'name',
|
||||
preview_type: 'image' as const
|
||||
}
|
||||
const a = mapToDropdownItem(raw, schema)
|
||||
const b = mapToDropdownItem(raw, schema)
|
||||
expect(a).toEqual(b)
|
||||
expect(typeof a.id).toBe('string')
|
||||
expect(typeof a.name).toBe('string')
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('id is non-empty when value_field is present in raw', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1 }),
|
||||
name: fc.string()
|
||||
}),
|
||||
(raw) => {
|
||||
const schema = {
|
||||
value_field: 'id',
|
||||
label_field: 'name',
|
||||
preview_type: 'image' as const
|
||||
}
|
||||
const item = mapToDropdownItem(raw, schema)
|
||||
expect(item.id.length).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,354 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSearchText,
|
||||
displayName,
|
||||
extractItems,
|
||||
getByPath,
|
||||
mapToDropdownItem,
|
||||
resolveLabel
|
||||
} from '@/base/remote/itemSchema'
|
||||
|
||||
describe('getByPath', () => {
|
||||
it('returns a top-level value for a plain key', () => {
|
||||
expect(getByPath({ name: 'Alice' }, 'name')).toBe('Alice')
|
||||
})
|
||||
|
||||
it('traverses nested objects via dot-path', () => {
|
||||
expect(getByPath({ profile: { name: 'Alice' } }, 'profile.name')).toBe(
|
||||
'Alice'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats numeric segments as array indices', () => {
|
||||
expect(getByPath({ items: ['a', 'b', 'c'] }, 'items.1')).toBe('b')
|
||||
})
|
||||
|
||||
it('combines nested objects and array indices', () => {
|
||||
const obj = { data: { results: [{ id: 'x' }, { id: 'y' }] } }
|
||||
expect(getByPath(obj, 'data.results.1.id')).toBe('y')
|
||||
})
|
||||
|
||||
it('returns undefined for a missing top-level key', () => {
|
||||
expect(getByPath({ a: 1 }, 'b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when traversing past a null segment', () => {
|
||||
expect(getByPath({ a: null }, 'a.b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the root is null', () => {
|
||||
expect(getByPath(null, 'a')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the root is undefined', () => {
|
||||
expect(getByPath(undefined, 'a')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for an out-of-bounds array index', () => {
|
||||
expect(getByPath({ items: ['a'] }, 'items.5')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLabel', () => {
|
||||
it('resolves a plain dot-path to its value', () => {
|
||||
expect(resolveLabel('name', { name: 'Alice' })).toBe('Alice')
|
||||
})
|
||||
|
||||
it('resolves a nested dot-path without placeholders', () => {
|
||||
expect(resolveLabel('profile.name', { profile: { name: 'Alice' } })).toBe(
|
||||
'Alice'
|
||||
)
|
||||
})
|
||||
|
||||
it('substitutes a single {field} placeholder', () => {
|
||||
expect(resolveLabel('Name: {name}', { name: 'Alice' })).toBe('Name: Alice')
|
||||
})
|
||||
|
||||
it('substitutes multiple placeholders', () => {
|
||||
expect(
|
||||
resolveLabel('{first} {last}', { first: 'Alice', last: 'Liddell' })
|
||||
).toBe('Alice Liddell')
|
||||
})
|
||||
|
||||
it('substitutes placeholders with dot-paths', () => {
|
||||
expect(
|
||||
resolveLabel('{profile.name} ({profile.age})', {
|
||||
profile: { name: 'Alice', age: 30 }
|
||||
})
|
||||
).toBe('Alice (30)')
|
||||
})
|
||||
|
||||
it('replaces missing placeholder fields with an empty string', () => {
|
||||
expect(resolveLabel('{name} - {missing}', { name: 'Alice' })).toBe(
|
||||
'Alice - '
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an empty string when a plain path resolves to undefined', () => {
|
||||
expect(resolveLabel('missing', { a: 1 })).toBe('')
|
||||
})
|
||||
|
||||
it('coerces numeric values to strings', () => {
|
||||
expect(resolveLabel('{count}', { count: 5 })).toBe('5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapToDropdownItem', () => {
|
||||
it('maps required fields to id and name', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ voice_id: 'v1', label: 'Roger' },
|
||||
{ value_field: 'voice_id', label_field: 'label', preview_type: 'image' }
|
||||
)
|
||||
|
||||
expect(item).toEqual({
|
||||
id: 'v1',
|
||||
name: 'Roger',
|
||||
description: undefined,
|
||||
preview_url: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('includes description when description_field is set', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'v1', label: 'Roger', desc: 'Laid-back American male' },
|
||||
{
|
||||
value_field: 'id',
|
||||
label_field: 'label',
|
||||
description_field: 'desc',
|
||||
preview_type: 'image'
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.description).toBe('Laid-back American male')
|
||||
})
|
||||
|
||||
it('includes preview_url when preview_url_field is set', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'v1', label: 'Roger', sample: 'https://example.com/a.mp3' },
|
||||
{
|
||||
value_field: 'id',
|
||||
label_field: 'label',
|
||||
preview_url_field: 'sample',
|
||||
preview_type: 'audio'
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.preview_url).toBe('https://example.com/a.mp3')
|
||||
})
|
||||
|
||||
it('resolves label_field templates with placeholders', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'v1', first: 'Alice', last: 'Liddell' },
|
||||
{
|
||||
value_field: 'id',
|
||||
label_field: '{first} {last}',
|
||||
preview_type: 'image'
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.name).toBe('Alice Liddell')
|
||||
})
|
||||
|
||||
it('resolves dot-path fields for nested data', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ task_result: { elements: [{ element_id: 'e1', name: 'Elem' }] } },
|
||||
{
|
||||
value_field: 'task_result.elements.0.element_id',
|
||||
label_field: 'task_result.elements.0.name',
|
||||
preview_type: 'image'
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.id).toBe('e1')
|
||||
expect(item.name).toBe('Elem')
|
||||
})
|
||||
|
||||
it('stringifies non-string value_field', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 42, label: 'Answer' },
|
||||
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
|
||||
)
|
||||
|
||||
expect(item.id).toBe('42')
|
||||
})
|
||||
|
||||
it('returns an empty string id when value_field is missing', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ label: 'Orphan' },
|
||||
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
|
||||
)
|
||||
|
||||
expect(item.id).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractItems', () => {
|
||||
it('returns the full response when responseKey is undefined', () => {
|
||||
expect(extractItems([1, 2, 3])).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('extracts items from a top-level key', () => {
|
||||
expect(
|
||||
extractItems({ voices: [{ id: 'a' }, { id: 'b' }] }, 'voices')
|
||||
).toEqual([{ id: 'a' }, { id: 'b' }])
|
||||
})
|
||||
|
||||
it('extracts items via a dot-path', () => {
|
||||
expect(extractItems({ data: { items: [1, 2] } }, 'data.items')).toEqual([
|
||||
1, 2
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty array for a valid empty list', () => {
|
||||
expect(extractItems([])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null when the path does not exist', () => {
|
||||
expect(extractItems({ a: 1 }, 'nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the path resolves to a non-array', () => {
|
||||
expect(
|
||||
extractItems({ data: { items: 'not an array' } }, 'data.items')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the full response is not an array', () => {
|
||||
expect(extractItems({ not: 'array' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when response is null', () => {
|
||||
expect(extractItems(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSearchText', () => {
|
||||
it('joins multiple fields with a space', () => {
|
||||
expect(buildSearchText({ a: 'Hello', b: 'World' }, ['a', 'b'])).toBe(
|
||||
'hello world'
|
||||
)
|
||||
})
|
||||
|
||||
it('lowercases the result', () => {
|
||||
expect(buildSearchText({ name: 'ALICE' }, ['name'])).toBe('alice')
|
||||
})
|
||||
|
||||
it('drops missing fields', () => {
|
||||
expect(buildSearchText({ name: 'Alice' }, ['name', 'missing'])).toBe(
|
||||
'alice'
|
||||
)
|
||||
})
|
||||
|
||||
it('supports dot-path fields', () => {
|
||||
expect(
|
||||
buildSearchText({ profile: { name: 'Alice', age: 30 } }, [
|
||||
'profile.name',
|
||||
'profile.age'
|
||||
])
|
||||
).toBe('alice 30')
|
||||
})
|
||||
|
||||
it('returns an empty string when all fields are missing', () => {
|
||||
expect(buildSearchText({ name: 'Alice' }, ['missing'])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapToDropdownItem preview_url normalization', () => {
|
||||
const baseSchema = {
|
||||
value_field: 'id',
|
||||
label_field: 'name',
|
||||
preview_url_field: 'thumb',
|
||||
preview_type: 'image' as const
|
||||
}
|
||||
|
||||
it('preserves absolute https URLs', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A', thumb: 'https://cdn.example.com/a.png' },
|
||||
baseSchema,
|
||||
{ previewBaseUrl: 'https://api.comfy.org' }
|
||||
)
|
||||
expect(item.preview_url).toBe('https://cdn.example.com/a.png')
|
||||
})
|
||||
|
||||
it('preserves protocol-relative URLs', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A', thumb: '//cdn.example.com/a.png' },
|
||||
baseSchema,
|
||||
{ previewBaseUrl: 'https://api.comfy.org' }
|
||||
)
|
||||
expect(item.preview_url).toBe('//cdn.example.com/a.png')
|
||||
})
|
||||
|
||||
it('preserves data: URIs', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A', thumb: 'data:image/png;base64,AAA' },
|
||||
baseSchema,
|
||||
{ previewBaseUrl: 'https://api.comfy.org' }
|
||||
)
|
||||
expect(item.preview_url).toBe('data:image/png;base64,AAA')
|
||||
})
|
||||
|
||||
it('preserves blob: URLs', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A', thumb: 'blob:https://app/abc' },
|
||||
baseSchema,
|
||||
{ previewBaseUrl: 'https://api.comfy.org' }
|
||||
)
|
||||
expect(item.preview_url).toBe('blob:https://app/abc')
|
||||
})
|
||||
|
||||
it('joins relative paths against the previewBaseUrl', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A', thumb: '/voices/1/preview.png' },
|
||||
baseSchema,
|
||||
{ previewBaseUrl: 'https://api.comfy.org' }
|
||||
)
|
||||
expect(item.preview_url).toBe('https://api.comfy.org/voices/1/preview.png')
|
||||
})
|
||||
|
||||
it('adds a leading slash when relative path lacks one', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A', thumb: 'voices/1/preview.png' },
|
||||
baseSchema,
|
||||
{ previewBaseUrl: 'https://api.comfy.org' }
|
||||
)
|
||||
expect(item.preview_url).toBe('https://api.comfy.org/voices/1/preview.png')
|
||||
})
|
||||
|
||||
it('strips trailing slashes from previewBaseUrl', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A', thumb: '/x.png' },
|
||||
baseSchema,
|
||||
{ previewBaseUrl: 'https://api.comfy.org/' }
|
||||
)
|
||||
expect(item.preview_url).toBe('https://api.comfy.org/x.png')
|
||||
})
|
||||
|
||||
it('returns relative path unchanged when no previewBaseUrl is provided', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A', thumb: '/x.png' },
|
||||
baseSchema
|
||||
)
|
||||
expect(item.preview_url).toBe('/x.png')
|
||||
})
|
||||
|
||||
it('returns undefined when preview_url_field is unset', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'a', name: 'A' },
|
||||
{ value_field: 'id', label_field: 'name', preview_type: 'image' },
|
||||
{ previewBaseUrl: 'https://api.comfy.org' }
|
||||
)
|
||||
expect(item.preview_url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayName', () => {
|
||||
it('returns name when present', () => {
|
||||
expect(displayName({ id: 'abc', name: 'Cool Asset' })).toBe('Cool Asset')
|
||||
})
|
||||
|
||||
it('falls back to id when name is empty string', () => {
|
||||
expect(displayName({ id: 'abc-123', name: '' })).toBe('abc-123')
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
|
||||
|
||||
export interface DropdownItemShape {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
preview_url?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing label for a dropdown item. Falls back to id when name
|
||||
* is missing or empty, so trigger/list rows never render blank.
|
||||
*/
|
||||
export function displayName(item: DropdownItemShape): string {
|
||||
return item.name || item.id
|
||||
}
|
||||
|
||||
export function getByPath(obj: unknown, path: string): unknown {
|
||||
return path.split('.').reduce((acc: unknown, key: string) => {
|
||||
if (acc == null) return undefined
|
||||
const idx = Number(key)
|
||||
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
|
||||
return (acc as Record<string, unknown>)[key]
|
||||
}, obj)
|
||||
}
|
||||
|
||||
export function resolveLabel(template: string, item: unknown): string {
|
||||
if (!template.includes('{')) {
|
||||
return String(getByPath(item, template) ?? '')
|
||||
}
|
||||
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
|
||||
String(getByPath(item, path) ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
const ABSOLUTE_URL_REGEX = /^([a-z][a-z0-9+.-]*:)?\/\//i
|
||||
const DATA_URL_PREFIX = 'data:'
|
||||
const BLOB_URL_PREFIX = 'blob:'
|
||||
|
||||
function resolvePreviewUrl(
|
||||
raw: string | undefined,
|
||||
baseUrl?: string
|
||||
): string | undefined {
|
||||
if (!raw) return undefined
|
||||
const lowered = raw.toLowerCase()
|
||||
if (
|
||||
ABSOLUTE_URL_REGEX.test(raw) ||
|
||||
lowered.startsWith(DATA_URL_PREFIX) ||
|
||||
lowered.startsWith(BLOB_URL_PREFIX)
|
||||
) {
|
||||
return raw
|
||||
}
|
||||
if (!baseUrl) return raw
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, '')
|
||||
const normalizedPath = raw.startsWith('/') ? raw : `/${raw}`
|
||||
return normalizedBase + normalizedPath
|
||||
}
|
||||
|
||||
export function mapToDropdownItem(
|
||||
raw: unknown,
|
||||
schema: RemoteItemSchema,
|
||||
options: { previewBaseUrl?: string } = {}
|
||||
): DropdownItemShape {
|
||||
const previewRaw = schema.preview_url_field
|
||||
? String(getByPath(raw, schema.preview_url_field) ?? '')
|
||||
: undefined
|
||||
return {
|
||||
id: String(getByPath(raw, schema.value_field) ?? ''),
|
||||
name: resolveLabel(schema.label_field, raw),
|
||||
description: schema.description_field
|
||||
? resolveLabel(schema.description_field, raw)
|
||||
: undefined,
|
||||
preview_url: resolvePreviewUrl(previewRaw, options.previewBaseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function extractItems(
|
||||
response: unknown,
|
||||
responseKey?: string
|
||||
): unknown[] | null {
|
||||
const data = responseKey ? getByPath(response, responseKey) : response
|
||||
return Array.isArray(data) ? data : null
|
||||
}
|
||||
|
||||
export function buildSearchText(raw: unknown, searchFields: string[]): string {
|
||||
return searchFields
|
||||
.map((field) => String(getByPath(raw, field) ?? ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const BACKOFF_BASE_MS = 1000
|
||||
const BACKOFF_CAP_MS = 16000
|
||||
|
||||
export function getBackoff(retryCount: number): number {
|
||||
return Math.min(BACKOFF_BASE_MS * Math.pow(2, retryCount), BACKOFF_CAP_MS)
|
||||
}
|
||||
|
||||
export function isRetriableError(err: unknown): boolean {
|
||||
if (!axios.isAxiosError(err)) return true
|
||||
if (err.code === 'ERR_CANCELED') return false
|
||||
const status = err.response?.status
|
||||
if (status == null) return true
|
||||
if (status >= 500) return true
|
||||
return status === 408 || status === 429
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { AxiosError, AxiosHeaders } from 'axios'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getBackoff, isRetriableError } from '@/base/remote/retry'
|
||||
import { summarizeError, summarizePayload } from '@/base/remote/diagnostics'
|
||||
|
||||
describe('getBackoff', () => {
|
||||
it('grows exponentially from 1s', () => {
|
||||
expect(getBackoff(1)).toBe(2000)
|
||||
expect(getBackoff(2)).toBe(4000)
|
||||
expect(getBackoff(3)).toBe(8000)
|
||||
expect(getBackoff(4)).toBe(16000)
|
||||
})
|
||||
|
||||
it('caps at 16s for higher attempt counts', () => {
|
||||
expect(getBackoff(5)).toBe(16000)
|
||||
expect(getBackoff(10)).toBe(16000)
|
||||
expect(getBackoff(100)).toBe(16000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRetriableError', () => {
|
||||
function axiosErrorWithStatus(status: number): AxiosError {
|
||||
return new AxiosError(
|
||||
`HTTP ${status}`,
|
||||
'ERR_BAD_RESPONSE',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
it('retries non-axios errors (e.g. unexpected throws)', () => {
|
||||
expect(isRetriableError(new Error('boom'))).toBe(true)
|
||||
expect(isRetriableError('string error')).toBe(true)
|
||||
expect(isRetriableError(undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('retries axios errors with no response (network failures)', () => {
|
||||
const err = new AxiosError('Network Error', 'ERR_NETWORK')
|
||||
expect(isRetriableError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('retries 5xx responses', () => {
|
||||
expect(isRetriableError(axiosErrorWithStatus(500))).toBe(true)
|
||||
expect(isRetriableError(axiosErrorWithStatus(502))).toBe(true)
|
||||
expect(isRetriableError(axiosErrorWithStatus(503))).toBe(true)
|
||||
})
|
||||
|
||||
it('retries 408 (request timeout) and 429 (too many requests)', () => {
|
||||
expect(isRetriableError(axiosErrorWithStatus(408))).toBe(true)
|
||||
expect(isRetriableError(axiosErrorWithStatus(429))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not retry other 4xx responses', () => {
|
||||
expect(isRetriableError(axiosErrorWithStatus(400))).toBe(false)
|
||||
expect(isRetriableError(axiosErrorWithStatus(401))).toBe(false)
|
||||
expect(isRetriableError(axiosErrorWithStatus(403))).toBe(false)
|
||||
expect(isRetriableError(axiosErrorWithStatus(404))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeError', () => {
|
||||
it('extracts message, code and status from an axios error', () => {
|
||||
const err = new AxiosError(
|
||||
'Request failed',
|
||||
'ERR_BAD_RESPONSE',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 500,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: null
|
||||
}
|
||||
)
|
||||
expect(summarizeError(err)).toEqual({
|
||||
message: 'Request failed',
|
||||
code: 'ERR_BAD_RESPONSE',
|
||||
status: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('does not include axios config, headers, request or response data', () => {
|
||||
const authedConfig = {
|
||||
url: '/voices',
|
||||
method: 'get',
|
||||
headers: new AxiosHeaders({ Authorization: 'Bearer SECRET-TOKEN-123' })
|
||||
}
|
||||
const err = new AxiosError(
|
||||
'Request failed',
|
||||
'ERR_BAD_RESPONSE',
|
||||
authedConfig,
|
||||
undefined,
|
||||
{
|
||||
status: 500,
|
||||
statusText: '',
|
||||
headers: { 'set-cookie': ['session=PRIVATE'] },
|
||||
config: authedConfig,
|
||||
data: { user_email: 'private@example.com' }
|
||||
}
|
||||
)
|
||||
const summary = summarizeError(err)
|
||||
|
||||
expect(JSON.stringify(summary)).not.toContain('SECRET-TOKEN-123')
|
||||
expect(JSON.stringify(summary)).not.toContain('PRIVATE')
|
||||
expect(JSON.stringify(summary)).not.toContain('private@example.com')
|
||||
expect(summary).not.toHaveProperty('config')
|
||||
expect(summary).not.toHaveProperty('request')
|
||||
expect(summary).not.toHaveProperty('response')
|
||||
})
|
||||
|
||||
it('reports an axios network error with no response as undefined status', () => {
|
||||
const err = new AxiosError('Network Error', 'ERR_NETWORK')
|
||||
expect(summarizeError(err)).toEqual({
|
||||
message: 'Network Error',
|
||||
code: 'ERR_NETWORK',
|
||||
status: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes a plain Error using its name and message', () => {
|
||||
expect(summarizeError(new TypeError('boom'))).toEqual({
|
||||
message: 'boom',
|
||||
name: 'TypeError'
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces non-Error throwables to a message string', () => {
|
||||
expect(summarizeError('oops')).toEqual({ message: 'oops' })
|
||||
expect(summarizeError(42)).toEqual({ message: '42' })
|
||||
expect(summarizeError(null)).toEqual({ message: 'null' })
|
||||
expect(summarizeError(undefined)).toEqual({ message: 'undefined' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizePayload', () => {
|
||||
it('reports array length without exposing values', () => {
|
||||
expect(
|
||||
summarizePayload([{ secret: 'a' }, { secret: 'b' }, { secret: 'c' }])
|
||||
).toEqual({
|
||||
type: 'array',
|
||||
length: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('reports object keys without exposing values', () => {
|
||||
expect(
|
||||
summarizePayload({ user_email: 'private@example.com', voices: ['x'] })
|
||||
).toEqual({
|
||||
type: 'object',
|
||||
keys: ['user_email', 'voices'],
|
||||
keyCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('caps the keys sample at 10 but reports the full key count', () => {
|
||||
const big: Record<string, number> = {}
|
||||
for (let i = 0; i < 25; i++) big[`k${i}`] = i
|
||||
const summary = summarizePayload(big) as {
|
||||
type: string
|
||||
keys: string[]
|
||||
keyCount: number
|
||||
}
|
||||
expect(summary.type).toBe('object')
|
||||
expect(summary.keys).toHaveLength(10)
|
||||
expect(summary.keyCount).toBe(25)
|
||||
})
|
||||
|
||||
it('distinguishes null and undefined', () => {
|
||||
expect(summarizePayload(null)).toEqual({ type: 'null' })
|
||||
expect(summarizePayload(undefined)).toEqual({ type: 'undefined' })
|
||||
})
|
||||
|
||||
it('reports primitive types without their value', () => {
|
||||
expect(summarizePayload('hello')).toEqual({ type: 'string' })
|
||||
expect(summarizePayload(123)).toEqual({ type: 'number' })
|
||||
expect(summarizePayload(true)).toEqual({ type: 'boolean' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -2700,19 +2700,6 @@
|
||||
"placeholderUnknown": "Select media...",
|
||||
"maxSelectionReached": "Maximum selection limit reached"
|
||||
},
|
||||
"remoteCombo": {
|
||||
"loading": "Loading...",
|
||||
"loadFailed": "Failed to load options",
|
||||
"noResults": "No results found",
|
||||
"refresh": "Refresh options",
|
||||
"selectAriaLabel": "Select {field}",
|
||||
"searchAriaLabel": "Search {field}",
|
||||
"layoutSwitcherAriaLabel": "Layout switcher",
|
||||
"layoutList": "List view",
|
||||
"layoutGrid": "Grid view",
|
||||
"playAudioPreview": "Play audio preview",
|
||||
"pauseAudioPreview": "Pause audio preview"
|
||||
},
|
||||
"valueControl": {
|
||||
"header": {
|
||||
"prefix": "Automatically update the value",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { definePreset } from '@primevue/themes'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
@@ -12,8 +11,6 @@ import Tooltip from 'primevue/tooltip'
|
||||
import { createApp } from 'vue'
|
||||
import { VueFire, VueFireAuth } from 'vuefire'
|
||||
|
||||
import { createAppQueryClient } from '@/platform/remote/queryClient'
|
||||
|
||||
import { getFirebaseConfig } from '@/config/firebase'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
@@ -85,9 +82,7 @@ Sentry.init({
|
||||
})
|
||||
})
|
||||
app.directive('tooltip', Tooltip)
|
||||
const queryClient = createAppQueryClient()
|
||||
app
|
||||
.use(VueQueryPlugin, { queryClient })
|
||||
.use(router)
|
||||
.use(PrimeVue, {
|
||||
theme: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
|
||||
@@ -21,9 +22,9 @@ const zAsset = z.object({
|
||||
})
|
||||
|
||||
const zAssetResponse = z.object({
|
||||
assets: z.array(zAsset).optional(),
|
||||
total: z.number().optional(),
|
||||
has_more: z.boolean().optional()
|
||||
assets: z.array(zAsset),
|
||||
total: zListAssetsResponse.shape.total,
|
||||
has_more: zListAssetsResponse.shape.has_more
|
||||
})
|
||||
|
||||
const zModelFolder = z.object({
|
||||
|
||||
@@ -64,6 +64,16 @@ function buildResponse(
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function buildAssetListResponse(
|
||||
assets: AssetItem[],
|
||||
{
|
||||
hasMore = false,
|
||||
total = assets.length
|
||||
}: { hasMore?: boolean; total?: number } = {}
|
||||
): Response {
|
||||
return buildResponse({ assets, total, has_more: hasMore })
|
||||
}
|
||||
|
||||
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
@@ -218,7 +228,7 @@ describe(assetService.uploadAssetFromUrl, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -240,7 +250,7 @@ describe(assetService.uploadAssetFromUrl, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
|
||||
)
|
||||
@@ -301,7 +311,7 @@ describe(assetService.uploadAssetFromBase64, () => {
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -327,7 +337,7 @@ describe(assetService.uploadAssetFromBase64, () => {
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
|
||||
@@ -423,15 +433,13 @@ describe(assetService.getAssetModelFolders, () => {
|
||||
|
||||
it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'a', tags: ['models', 'loras'] }),
|
||||
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
|
||||
validAsset({ id: 'c', tags: ['models', 'configs'] }),
|
||||
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
|
||||
validAsset({ id: 'e', tags: ['models', 'loras'] })
|
||||
]
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'a', tags: ['models', 'loras'] }),
|
||||
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
|
||||
validAsset({ id: 'c', tags: ['models', 'configs'] }),
|
||||
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
|
||||
validAsset({ id: 'e', tags: ['models', 'loras'] })
|
||||
])
|
||||
)
|
||||
|
||||
const folders = await assetService.getAssetModelFolders()
|
||||
@@ -492,12 +500,10 @@ describe(assetService.getAssetsByTag, () => {
|
||||
|
||||
it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
|
||||
]
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
|
||||
])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAssetsByTag('input')
|
||||
@@ -518,17 +524,16 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
it('paginates tagged asset requests with include_public=true', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
],
|
||||
{ hasMore: true }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'c', tags: ['input'] })]
|
||||
})
|
||||
buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -553,17 +558,18 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
it('paginates from raw response size before filtering missing-tagged assets', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
|
||||
]
|
||||
})
|
||||
],
|
||||
{ hasMore: true }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'later-public', tags: ['input'] })
|
||||
])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -584,23 +590,22 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
it('honors has_more when walking tagged asset pages', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'first', tags: ['input'] }),
|
||||
validAsset({ id: 'second', tags: ['input'] })
|
||||
],
|
||||
has_more: true
|
||||
})
|
||||
{ hasMore: true }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
|
||||
has_more: false
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'later-public', tags: ['input'] })
|
||||
])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 3
|
||||
limit: 2
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public'])
|
||||
@@ -614,12 +619,23 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('rejects tagged asset pages without has_more', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })],
|
||||
total: 1
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
assetService.getAllAssetsByTag('input', true, { limit: 2 })
|
||||
).rejects.toThrow(/Invalid asset response/)
|
||||
})
|
||||
|
||||
it('passes abort signals through paginated requests', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })]
|
||||
})
|
||||
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
|
||||
)
|
||||
|
||||
await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -636,12 +652,13 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return buildResponse({
|
||||
assets: [
|
||||
return buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
],
|
||||
{ hasMore: true }
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -666,7 +683,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
validAsset({ id: 'user-input', tags: ['input'] }),
|
||||
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
|
||||
]
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
|
||||
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
|
||||
|
||||
const first = await assetService.getInputAssetsIncludingPublic()
|
||||
const second = await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -685,8 +702,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
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 }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
@@ -720,7 +737,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(serviceSignal).toBeUndefined()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
resolveResponse(buildAssetListResponse(assets))
|
||||
|
||||
await expect(second).resolves.toEqual(assets)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
@@ -750,7 +767,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
resolveResponse(buildAssetListResponse(assets))
|
||||
await Promise.resolve()
|
||||
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
@@ -770,12 +787,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
const inFlight = assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
resolveResponse(buildAssetListResponse(assets))
|
||||
|
||||
await expect(inFlight).resolves.toEqual(assets)
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
@@ -788,9 +805,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(null))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.deleteAsset('stale-input')
|
||||
@@ -809,9 +826,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
|
||||
const freshAssets = [uploadedAsset]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(uploadedAsset))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
@@ -827,7 +844,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
it('does not invalidate cached input assets for pending async input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(
|
||||
{ task_id: 'task-1', status: 'running' },
|
||||
@@ -849,7 +866,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
it('does not invalidate cached input assets for non-input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
@@ -473,11 +473,8 @@ function createAssetService() {
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
// Validate the single asset response against our schema
|
||||
const result = assetResponseSchema.safeParse({ assets: [data] })
|
||||
if (result.success && result.data.assets?.[0]) {
|
||||
return result.data.assets[0]
|
||||
}
|
||||
const result = assetItemSchema.safeParse(data)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = result.error
|
||||
? fromZodError(result.error)
|
||||
@@ -548,10 +545,7 @@ function createAssetService() {
|
||||
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) {
|
||||
if (batch.length === 0 || !data.has_more) {
|
||||
return assets
|
||||
}
|
||||
|
||||
|
||||
140
src/platform/assets/utils/assetHashVerification.test.ts
Normal file
140
src/platform/assets/utils/assetHashVerification.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { verifyCandidatesByAssetHash } from './assetHashVerification'
|
||||
|
||||
interface Candidate {
|
||||
id: string
|
||||
hash: string | null
|
||||
}
|
||||
|
||||
function candidate(id: string, hash: string | null): Candidate {
|
||||
return { id, hash }
|
||||
}
|
||||
|
||||
describe(verifyCandidatesByAssetHash, () => {
|
||||
it('deduplicates hash checks and groups existing and missing candidates', async () => {
|
||||
const existingHash = 'blake3:existing'
|
||||
const missingHash = 'blake3:missing'
|
||||
const candidates = [
|
||||
candidate('a', existingHash),
|
||||
candidate('b', existingHash),
|
||||
candidate('c', missingHash)
|
||||
]
|
||||
const checkAssetHash = vi.fn(async (hash: string) =>
|
||||
hash === existingHash ? ('exists' as const) : ('missing' as const)
|
||||
)
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash
|
||||
})
|
||||
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(result.existing.map((candidate) => candidate.id)).toEqual(['a', 'b'])
|
||||
expect(result.missing.map((candidate) => candidate.id)).toEqual(['c'])
|
||||
expect(result.fallback).toEqual([])
|
||||
expect(checkAssetHash).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('routes candidates without hashes and invalid hashes to fallback', async () => {
|
||||
const invalidHash = 'blake3:invalid'
|
||||
const candidates = [candidate('a', null), candidate('b', invalidHash)]
|
||||
const checkAssetHash = vi.fn(async () => 'invalid' as const)
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash
|
||||
})
|
||||
|
||||
expect(result.existing).toEqual([])
|
||||
expect(result.missing).toEqual([])
|
||||
expect(result.fallback.map((candidate) => candidate.id)).toEqual(['a', 'b'])
|
||||
expect(checkAssetHash).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('routes non-abort verification failures to fallback', async () => {
|
||||
const candidates = [candidate('a', 'blake3:network-failure')]
|
||||
const error = new Error('network failed')
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash: async () => {
|
||||
throw error
|
||||
},
|
||||
onError
|
||||
})
|
||||
|
||||
expect(result.fallback).toEqual(candidates)
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(onError).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it('returns aborted without resolving candidates when the signal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const candidates = [candidate('a', 'blake3:aborted')]
|
||||
const checkAssetHash = vi.fn(async () => 'exists' as const)
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
existing: [],
|
||||
missing: [],
|
||||
fallback: [],
|
||||
aborted: true
|
||||
})
|
||||
expect(checkAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silences abort errors from hash verification', async () => {
|
||||
const candidates = [candidate('a', 'blake3:aborted')]
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash: async () => {
|
||||
throw new DOMException('aborted', 'AbortError')
|
||||
},
|
||||
onError
|
||||
})
|
||||
|
||||
expect(result.aborted).toBe(true)
|
||||
expect(result.existing).toEqual([])
|
||||
expect(result.missing).toEqual([])
|
||||
expect(result.fallback).toEqual([])
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps concurrent hash checks', async () => {
|
||||
let activeChecks = 0
|
||||
let maxActiveChecks = 0
|
||||
const candidates = Array.from({ length: 6 }, (_, index) =>
|
||||
candidate(String(index), `blake3:${index}`)
|
||||
)
|
||||
|
||||
await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
maxConcurrent: 2,
|
||||
checkAssetHash: async () => {
|
||||
activeChecks++
|
||||
maxActiveChecks = Math.max(maxActiveChecks, activeChecks)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
activeChecks--
|
||||
return 'missing'
|
||||
}
|
||||
})
|
||||
|
||||
expect(maxActiveChecks).toBeLessThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
111
src/platform/assets/utils/assetHashVerification.ts
Normal file
111
src/platform/assets/utils/assetHashVerification.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
export type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
interface AssetHashVerificationResult<T> {
|
||||
existing: T[]
|
||||
missing: T[]
|
||||
fallback: T[]
|
||||
aborted: boolean
|
||||
}
|
||||
|
||||
interface VerifyCandidatesByAssetHashOptions<T> {
|
||||
candidates: readonly T[]
|
||||
getAssetHash: (candidate: T) => string | null
|
||||
checkAssetHash: AssetHashVerifier
|
||||
signal?: AbortSignal
|
||||
maxConcurrent?: number
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_CONCURRENT_HASH_CHECKS = 12
|
||||
|
||||
export async function verifyCandidatesByAssetHash<T>({
|
||||
candidates,
|
||||
getAssetHash,
|
||||
checkAssetHash,
|
||||
signal,
|
||||
maxConcurrent = DEFAULT_MAX_CONCURRENT_HASH_CHECKS,
|
||||
onError
|
||||
}: VerifyCandidatesByAssetHashOptions<T>): Promise<
|
||||
AssetHashVerificationResult<T>
|
||||
> {
|
||||
const result: AssetHashVerificationResult<T> = {
|
||||
existing: [],
|
||||
missing: [],
|
||||
fallback: [],
|
||||
aborted: false
|
||||
}
|
||||
|
||||
if (signal?.aborted) return { ...result, aborted: true }
|
||||
|
||||
const candidatesByHash = new Map<string, T[]>()
|
||||
for (const candidate of candidates) {
|
||||
const assetHash = getAssetHash(candidate)
|
||||
if (!assetHash) {
|
||||
result.fallback.push(candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(assetHash)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(assetHash, [candidate])
|
||||
}
|
||||
|
||||
const entries = [...candidatesByHash.entries()]
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.min(
|
||||
entries.length,
|
||||
Math.max(1, Math.floor(maxConcurrent))
|
||||
)
|
||||
|
||||
async function verifyNextHash(): Promise<void> {
|
||||
while (!result.aborted && nextIndex < entries.length) {
|
||||
const entry = entries[nextIndex++]
|
||||
if (!entry) return
|
||||
|
||||
const [assetHash, hashCandidates] = entry
|
||||
if (signal?.aborted) {
|
||||
result.aborted = true
|
||||
return
|
||||
}
|
||||
|
||||
let status: AssetHashStatus
|
||||
try {
|
||||
status = await checkAssetHash(assetHash, signal)
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isAbortError(error)) {
|
||||
result.aborted = true
|
||||
return
|
||||
}
|
||||
|
||||
onError?.(error)
|
||||
result.fallback.push(...hashCandidates)
|
||||
continue
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
result.aborted = true
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'exists') {
|
||||
result.existing.push(...hashCandidates)
|
||||
} else if (status === 'missing') {
|
||||
result.missing.push(...hashCandidates)
|
||||
} else {
|
||||
result.fallback.push(...hashCandidates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => await verifyNextHash())
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -403,8 +403,7 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
})
|
||||
|
||||
it('silences aborts while loading legacy fallback input assets', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const abortError = new DOMException('aborted', 'AbortError')
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
@@ -427,8 +426,7 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
})
|
||||
|
||||
it('silences aborts from the default legacy fallback input asset store path', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const abortError = new DOMException('aborted', 'AbortError')
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
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'
|
||||
import { verifyCandidatesByAssetHash } from '@/platform/assets/utils/assetHashVerification'
|
||||
import type { AssetHashVerifier } from '@/platform/assets/utils/assetHashVerification'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
/** Map of node types to their media widget name and media type. */
|
||||
const MEDIA_NODE_WIDGETS: Record<
|
||||
@@ -112,70 +114,8 @@ 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'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cloud media candidates by probing the asset hash endpoint first.
|
||||
* Invalid hash values fall back to the legacy input asset list check.
|
||||
@@ -191,16 +131,26 @@ export async function verifyCloudMediaCandidates(
|
||||
const pending = candidates.filter((c) => c.isMissing === undefined)
|
||||
if (pending.length === 0) return
|
||||
|
||||
const { candidatesByHash, legacyCandidates } =
|
||||
groupCandidatesForHashLookup(pending)
|
||||
await verifyCandidatesByHash(
|
||||
candidatesByHash,
|
||||
legacyCandidates,
|
||||
const verification = await verifyCandidatesByAssetHash({
|
||||
candidates: pending,
|
||||
getAssetHash: (candidate) =>
|
||||
isBlake3AssetHash(candidate.name) ? candidate.name : null,
|
||||
signal,
|
||||
checkAssetHash
|
||||
)
|
||||
checkAssetHash,
|
||||
onError: (err) => {
|
||||
console.warn('[Missing Media Pipeline] Failed to verify asset hash:', err)
|
||||
}
|
||||
})
|
||||
if (verification.aborted) return
|
||||
|
||||
if (signal?.aborted || legacyCandidates.length === 0) return
|
||||
for (const candidate of verification.existing) {
|
||||
candidate.isMissing = false
|
||||
}
|
||||
for (const candidate of verification.missing) {
|
||||
candidate.isMissing = true
|
||||
}
|
||||
|
||||
if (signal?.aborted || verification.fallback.length === 0) return
|
||||
|
||||
let inputAssets: AssetItem[]
|
||||
try {
|
||||
@@ -216,7 +166,7 @@ export async function verifyCloudMediaCandidates(
|
||||
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
)
|
||||
|
||||
for (const candidate of legacyCandidates) {
|
||||
for (const candidate of verification.fallback) {
|
||||
candidate.isMissing = !assetHashes.has(candidate.name)
|
||||
}
|
||||
}
|
||||
@@ -227,15 +177,6 @@ async function fetchMissingInputAssets(
|
||||
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[]
|
||||
|
||||
@@ -1557,8 +1557,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
|
||||
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 abortError = new DOMException('aborted', 'AbortError')
|
||||
const hash =
|
||||
'4444444444444444444444444444444444444444444444444444444444444444'
|
||||
const candidates = [
|
||||
|
||||
@@ -24,11 +24,12 @@ 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'
|
||||
import { verifyCandidatesByAssetHash } from '@/platform/assets/utils/assetHashVerification'
|
||||
import type { AssetHashVerifier } from '@/platform/assets/utils/assetHashVerification'
|
||||
|
||||
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
|
||||
models?: ModelFile[]
|
||||
@@ -450,11 +451,6 @@ interface AssetVerifier {
|
||||
getAssets: (nodeType: string) => AssetItem[] | undefined
|
||||
}
|
||||
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
export async function verifyAssetSupportedCandidates(
|
||||
candidates: MissingModelCandidate[],
|
||||
signal?: AbortSignal,
|
||||
@@ -468,47 +464,25 @@ export async function verifyAssetSupportedCandidates(
|
||||
)
|
||||
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
|
||||
const verification = await verifyCandidatesByAssetHash({
|
||||
candidates: pendingCandidates,
|
||||
getAssetHash: getBlake3AssetHash,
|
||||
signal,
|
||||
checkAssetHash,
|
||||
onError: (err) => {
|
||||
console.warn('[Missing Model Pipeline] Failed to verify asset hash:', err)
|
||||
}
|
||||
})
|
||||
if (verification.aborted) return
|
||||
|
||||
const hashCandidates = candidatesByHash.get(assetHash)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(assetHash, [candidate])
|
||||
for (const candidate of verification.existing) {
|
||||
candidate.isMissing = false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
const pendingNodeTypes = new Set(
|
||||
[...verification.missing, ...verification.fallback].map(
|
||||
(candidate) => candidate.nodeType
|
||||
)
|
||||
)
|
||||
|
||||
if (signal?.aborted) return
|
||||
@@ -549,15 +523,6 @@ function getBlake3AssetHash(candidate: MissingModelCandidate): string | 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, '/')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -273,10 +274,6 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
fileSizes.value = {}
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
async function refreshMissingModels() {
|
||||
if (isRefreshingMissingModels.value) return
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import type * as AxiosModule from 'axios'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, effectScope, h } from 'vue'
|
||||
|
||||
import { useRemoteOptions } from '@/platform/remote/composables/useRemoteOptions'
|
||||
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
|
||||
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
|
||||
|
||||
vi.mock('axios', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AxiosModule>()
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual.default, get: vi.fn() }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
userId: 'u1',
|
||||
getAuthHeader: vi.fn(() => Promise.resolve(null))
|
||||
})
|
||||
}))
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } }
|
||||
})
|
||||
}
|
||||
|
||||
function withSetup<T>(setup: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T
|
||||
const queryClient = createTestQueryClient()
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = setup()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.use(createTestingPinia({ createSpy: vi.fn }))
|
||||
app.use(VueQueryPlugin, { queryClient })
|
||||
const container = document.createElement('div')
|
||||
app.mount(container)
|
||||
return {
|
||||
result,
|
||||
cleanup: () => {
|
||||
app.unmount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const desc: RemoteRequestDescriptor = {
|
||||
client: 'comfyApi',
|
||||
route: '/test'
|
||||
}
|
||||
|
||||
describe('useRemoteOptions', () => {
|
||||
it('builds a stable, scope-aware query key', () => {
|
||||
const key = remoteOptionKeys.byRoute(desc, {
|
||||
userId: 'u1',
|
||||
workspaceId: 'w1'
|
||||
})
|
||||
expect(key).toContain('comfyApi')
|
||||
expect(key).toContain('/test')
|
||||
expect(key).toContain('u1')
|
||||
expect(key).toContain('w1')
|
||||
})
|
||||
|
||||
it('partitions by route', () => {
|
||||
const a = remoteOptionKeys.byRoute(
|
||||
{ client: 'comfyApi', route: '/a' },
|
||||
{ userId: 'u1', workspaceId: null }
|
||||
)
|
||||
const b = remoteOptionKeys.byRoute(
|
||||
{ client: 'comfyApi', route: '/b' },
|
||||
{ userId: 'u1', workspaceId: null }
|
||||
)
|
||||
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b))
|
||||
})
|
||||
|
||||
it('partitions by workspaceId', () => {
|
||||
const a = remoteOptionKeys.byRoute(desc, {
|
||||
userId: 'u1',
|
||||
workspaceId: 'w1'
|
||||
})
|
||||
const b = remoteOptionKeys.byRoute(desc, {
|
||||
userId: 'u1',
|
||||
workspaceId: 'w2'
|
||||
})
|
||||
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b))
|
||||
})
|
||||
|
||||
it('partitions anonymous from api-key sessions even when userId/workspaceId match', () => {
|
||||
const anon = remoteOptionKeys.byRoute(desc, {
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
apiKeyBucket: 'anon'
|
||||
})
|
||||
const apikey = remoteOptionKeys.byRoute(desc, {
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
apiKeyBucket: 'apikey'
|
||||
})
|
||||
expect(JSON.stringify(anon)).not.toBe(JSON.stringify(apikey))
|
||||
})
|
||||
|
||||
it('returns disabled state when descriptor is null', async () => {
|
||||
const scope = effectScope()
|
||||
let result!: ReturnType<typeof useRemoteOptions>
|
||||
let cleanup = () => {}
|
||||
scope.run(() => {
|
||||
const mounted = withSetup(() =>
|
||||
useRemoteOptions({
|
||||
descriptor: null
|
||||
})
|
||||
)
|
||||
result = mounted.result
|
||||
cleanup = mounted.cleanup
|
||||
})
|
||||
expect(result.isLoading.value).toBe(false)
|
||||
cleanup()
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import axios from 'axios'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { isRetriableError } from '@/base/remote/retry'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
|
||||
import type {
|
||||
RemoteAuthScope,
|
||||
RemoteRequestDescriptor
|
||||
} from '@/platform/remote/schema/remoteRequestSchema'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
const DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
function resolveUrl(
|
||||
descriptor: RemoteRequestDescriptor,
|
||||
baseUrl: string
|
||||
): string {
|
||||
if (descriptor.client === 'comfyApi') {
|
||||
return baseUrl + descriptor.route
|
||||
}
|
||||
return descriptor.route
|
||||
}
|
||||
|
||||
async function executeRemoteRequest(
|
||||
descriptor: RemoteRequestDescriptor,
|
||||
signal: AbortSignal
|
||||
): Promise<unknown> {
|
||||
let headers: Record<string, string> | undefined
|
||||
if (descriptor.client === 'comfyApi') {
|
||||
const authStore = useAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
headers = authHeader ? { ...authHeader } : undefined
|
||||
}
|
||||
const url = resolveUrl(descriptor, getComfyApiBaseUrl())
|
||||
const response = await axios.get(url, {
|
||||
params: descriptor.params,
|
||||
timeout: descriptor.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||
signal,
|
||||
...(headers ? { headers } : {})
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
interface UseRemoteOptionsResult<T> {
|
||||
data: ComputedRef<T | undefined>
|
||||
rawData: ComputedRef<unknown>
|
||||
isLoading: ComputedRef<boolean>
|
||||
isFetching: ComputedRef<boolean>
|
||||
error: ComputedRef<Error | null>
|
||||
refetch: () => Promise<unknown>
|
||||
invalidate: () => Promise<void>
|
||||
}
|
||||
|
||||
interface UseRemoteOptionsArgs<T> {
|
||||
descriptor: MaybeRefOrGetter<RemoteRequestDescriptor | null | undefined>
|
||||
enabled?: MaybeRefOrGetter<boolean>
|
||||
select?: (raw: unknown) => T
|
||||
}
|
||||
|
||||
export function useRemoteOptions<T = unknown>(
|
||||
args: UseRemoteOptionsArgs<T>
|
||||
): UseRemoteOptionsResult<T> {
|
||||
const queryClient = useQueryClient()
|
||||
const authStore = useAuthStore()
|
||||
const workspaceStore = useWorkspaceAuthStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
|
||||
const scope = computed<RemoteAuthScope>(() => ({
|
||||
userId: authStore.userId ?? null,
|
||||
workspaceId: workspaceStore.currentWorkspace?.id ?? null,
|
||||
apiKeyBucket: apiKeyStore.getApiKey() ? 'apikey' : 'anon'
|
||||
}))
|
||||
|
||||
const queryKey = computed(() => {
|
||||
const descriptor = toValue(args.descriptor)
|
||||
if (!descriptor) {
|
||||
return [...remoteOptionKeys.all(), 'disabled'] as const
|
||||
}
|
||||
return remoteOptionKeys.byRoute(descriptor, scope.value)
|
||||
})
|
||||
|
||||
const enabled = computed(() => {
|
||||
const userEnabled = toValue(args.enabled)
|
||||
const hasDescriptor = !!toValue(args.descriptor)
|
||||
return hasDescriptor && (userEnabled === undefined || userEnabled)
|
||||
})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled,
|
||||
queryFn: async ({ signal }) => {
|
||||
const descriptor = toValue(args.descriptor)
|
||||
if (!descriptor) {
|
||||
throw new Error('useRemoteOptions: descriptor is required')
|
||||
}
|
||||
return executeRemoteRequest(descriptor, signal)
|
||||
},
|
||||
retry: (failureCount, error) => {
|
||||
const descriptor = toValue(args.descriptor)
|
||||
const max = descriptor?.maxRetries ?? DEFAULT_MAX_RETRIES
|
||||
return failureCount < max && isRetriableError(error)
|
||||
},
|
||||
staleTime: computed(() => toValue(args.descriptor)?.ttl ?? 0)
|
||||
})
|
||||
|
||||
const data = computed<T | undefined>(() => {
|
||||
const raw = query.data.value
|
||||
if (raw === undefined) return undefined
|
||||
if (args.select) return args.select(raw)
|
||||
return raw as T
|
||||
})
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
rawData: computed(() => query.data.value),
|
||||
isLoading: computed(() => query.isLoading.value),
|
||||
isFetching: computed(() => query.isFetching.value),
|
||||
error: computed(() => query.error.value),
|
||||
refetch: () => query.refetch(),
|
||||
invalidate
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import { isRetriableError } from '@/base/remote/retry'
|
||||
|
||||
const DEFAULT_GC_TIME_MS = 5 * 60_000
|
||||
const DEFAULT_RETRY_COUNT = 3
|
||||
|
||||
let appQueryClient: QueryClient | undefined
|
||||
|
||||
/**
|
||||
* Create the application-wide TanStack Query client.
|
||||
*
|
||||
* Defaults are tuned for remote-option dropdowns and similar widget data:
|
||||
* - `staleTime: 0` so refresh buttons always re-fetch
|
||||
* - `gcTime` bounded so a session's footprint stays small (no LRU yet)
|
||||
* - `retry` driven by {@link isRetriableError} from `base/remote/retry`
|
||||
* - `refetchOnWindowFocus: false` to avoid surprise re-fetches mid-edit
|
||||
*
|
||||
* QueryClient lifetime is bound to the Vue app instance; auth-state changes
|
||||
* tear down the authenticated layout subtree (see master plan §8), so the
|
||||
* cache is naturally evicted without manual `queryClient.clear()` calls.
|
||||
*/
|
||||
export function createAppQueryClient(): QueryClient {
|
||||
appQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0,
|
||||
gcTime: DEFAULT_GC_TIME_MS,
|
||||
retry: (failureCount, error) =>
|
||||
failureCount < DEFAULT_RETRY_COUNT && isRetriableError(error),
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
})
|
||||
return appQueryClient
|
||||
}
|
||||
|
||||
export function getAppQueryClient(): QueryClient {
|
||||
if (!appQueryClient) {
|
||||
appQueryClient = createAppQueryClient()
|
||||
}
|
||||
return appQueryClient
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type {
|
||||
RemoteAuthScope,
|
||||
RemoteRequestDescriptor
|
||||
} from '@/platform/remote/schema/remoteRequestSchema'
|
||||
|
||||
function sortedParams(
|
||||
params?: Record<string, string>
|
||||
): Array<[string, string]> {
|
||||
if (!params) return []
|
||||
return Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
export const remoteOptionKeys = {
|
||||
all: () => ['remote-options'] as const,
|
||||
byRoute: (descriptor: RemoteRequestDescriptor, scope: RemoteAuthScope) =>
|
||||
[
|
||||
...remoteOptionKeys.all(),
|
||||
descriptor.client,
|
||||
descriptor.route,
|
||||
descriptor.responseKey ?? '',
|
||||
sortedParams(descriptor.params),
|
||||
scope.workspaceId ?? null,
|
||||
scope.userId ?? null,
|
||||
scope.apiKeyBucket ?? null
|
||||
] as const
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type RemoteRequestClient = 'comfyApi'
|
||||
|
||||
export interface RemoteRequestDescriptor {
|
||||
client: RemoteRequestClient
|
||||
route: string
|
||||
params?: Record<string, string>
|
||||
responseKey?: string
|
||||
ttl?: number
|
||||
timeout?: number
|
||||
maxRetries?: number
|
||||
}
|
||||
|
||||
export type RemoteAuthBucket = 'apikey' | 'anon'
|
||||
|
||||
export interface RemoteAuthScope {
|
||||
userId?: string | null
|
||||
workspaceId?: string | null
|
||||
apiKeyBucket?: RemoteAuthBucket | null
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { comboAdapter } from '@/renderer/extensions/vueNodes/widgets/adapters/comboAdapter'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
function makeSpec(overrides: Partial<ComboInputSpec> = {}): ComboInputSpec {
|
||||
return {
|
||||
name: 'field',
|
||||
type: 'COMBO',
|
||||
isOptional: false,
|
||||
...overrides
|
||||
} as ComboInputSpec
|
||||
}
|
||||
|
||||
describe('comboAdapter.canHandle', () => {
|
||||
it('returns true for combo input specs', () => {
|
||||
expect(comboAdapter.canHandle(makeSpec())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('comboAdapter.extractProps', () => {
|
||||
it('returns kind=unknown when no upload flags set', () => {
|
||||
expect(comboAdapter.extractProps(makeSpec()).assetKind).toBe('unknown')
|
||||
})
|
||||
|
||||
it('detects video', () => {
|
||||
expect(
|
||||
comboAdapter.extractProps(makeSpec({ video_upload: true })).assetKind
|
||||
).toBe('video')
|
||||
})
|
||||
|
||||
it('detects image (image_upload)', () => {
|
||||
expect(
|
||||
comboAdapter.extractProps(makeSpec({ image_upload: true })).assetKind
|
||||
).toBe('image')
|
||||
})
|
||||
|
||||
it('detects image (animated_image_upload)', () => {
|
||||
expect(
|
||||
comboAdapter.extractProps(makeSpec({ animated_image_upload: true }))
|
||||
.assetKind
|
||||
).toBe('image')
|
||||
})
|
||||
|
||||
it('detects audio', () => {
|
||||
expect(
|
||||
comboAdapter.extractProps(makeSpec({ audio_upload: true })).assetKind
|
||||
).toBe('audio')
|
||||
})
|
||||
|
||||
it('detects mesh and forces uploadFolder=input', () => {
|
||||
const props = comboAdapter.extractProps(makeSpec({ mesh_upload: true }))
|
||||
expect(props.assetKind).toBe('mesh')
|
||||
expect(props.uploadFolder).toBe('input')
|
||||
})
|
||||
|
||||
it('respects image_folder for non-mesh', () => {
|
||||
const props = comboAdapter.extractProps(
|
||||
makeSpec({ image_upload: true, image_folder: 'output' })
|
||||
)
|
||||
expect(props.uploadFolder).toBe('output')
|
||||
})
|
||||
|
||||
it('flags allowUpload when any *_upload is true', () => {
|
||||
expect(
|
||||
comboAdapter.extractProps(makeSpec({ image_upload: true })).allowUpload
|
||||
).toBe(true)
|
||||
expect(comboAdapter.extractProps(makeSpec()).allowUpload).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
import type { SpecAdapter, SpecAdapterProps } from './specAdapter'
|
||||
|
||||
function deriveAssetKind(spec: ComboInputSpec): AssetKind {
|
||||
if (spec.video_upload) return 'video'
|
||||
if (spec.image_upload || spec.animated_image_upload) return 'image'
|
||||
if (spec.audio_upload) return 'audio'
|
||||
if (spec.mesh_upload) return 'mesh'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export const comboAdapter: SpecAdapter<ComboInputSpec> = {
|
||||
canHandle: isComboInputSpec,
|
||||
extractProps: (spec): SpecAdapterProps => {
|
||||
const allowUpload =
|
||||
spec.image_upload === true ||
|
||||
spec.animated_image_upload === true ||
|
||||
spec.video_upload === true ||
|
||||
spec.audio_upload === true ||
|
||||
spec.mesh_upload === true
|
||||
return {
|
||||
assetKind: deriveAssetKind(spec),
|
||||
allowUpload,
|
||||
uploadFolder: spec.mesh_upload ? 'input' : spec.image_folder,
|
||||
uploadSubfolder: spec.upload_subfolder
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
export interface SpecAdapterProps {
|
||||
assetKind?: AssetKind
|
||||
allowUpload?: boolean
|
||||
uploadFolder?: ResultItemType
|
||||
uploadSubfolder?: string
|
||||
}
|
||||
|
||||
export interface SpecAdapter<T extends InputSpec> {
|
||||
canHandle: (spec: InputSpec) => spec is T
|
||||
extractProps: (spec: T) => SpecAdapterProps
|
||||
component?: Component
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ComboboxContent, ComboboxPortal } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { contentVariants } from './remoteCombo.variants'
|
||||
|
||||
defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
align="start"
|
||||
:class="cn(contentVariants(), $props.class)"
|
||||
data-testid="remote-combo-content"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ComboboxEmpty } from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxEmpty
|
||||
class="p-3 text-center text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
data-testid="remote-combo-empty"
|
||||
>
|
||||
<slot>
|
||||
{{ t('widgets.remoteCombo.noResults') }}
|
||||
</slot>
|
||||
</ComboboxEmpty>
|
||||
</template>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>()
|
||||
|
||||
import { RemoteComboKey } from './state'
|
||||
|
||||
const ctx = inject(RemoteComboKey)
|
||||
if (!ctx) {
|
||||
throw new Error('RemoteCombo.Error must be used inside RemoteCombo.Root')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-sm bg-destructive-background/10 px-3 py-2 text-xs text-base-foreground"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
data-testid="remote-combo-error"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--alert-circle] size-4 shrink-0 text-destructive-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1">{{ message ?? ctx.errorMessage.value }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,93 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ComboboxRoot } from 'reka-ui'
|
||||
import { computed, defineComponent, h, provide, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { DropdownItemShape } from '@/base/remote/itemSchema'
|
||||
|
||||
import Item from './Item.vue'
|
||||
import { RemoteComboKey } from './state'
|
||||
import type { RemoteComboContext, RemoteComboPreviewType } from './state'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
widgets: {
|
||||
remoteCombo: {
|
||||
playAudioPreview: 'Play audio preview',
|
||||
pauseAudioPreview: 'Pause audio preview'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeCtx(previewType: RemoteComboPreviewType): RemoteComboContext {
|
||||
return {
|
||||
isOpen: ref(true),
|
||||
searchQuery: ref(''),
|
||||
selectedValue: ref<string | undefined>(undefined),
|
||||
items: computed(() => []),
|
||||
filteredItems: computed(() => []),
|
||||
isLoading: computed(() => false),
|
||||
isFetching: computed(() => false),
|
||||
errorMessage: computed(() => null),
|
||||
refresh: async () => {},
|
||||
select: () => {},
|
||||
fieldLabel: computed(() => 'field'),
|
||||
previewType: computed(() => previewType)
|
||||
}
|
||||
}
|
||||
|
||||
function renderItemInOpenCombobox(
|
||||
item: DropdownItemShape,
|
||||
previewType: RemoteComboPreviewType
|
||||
) {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
provide(RemoteComboKey, makeCtx(previewType))
|
||||
return () =>
|
||||
h(
|
||||
ComboboxRoot,
|
||||
{ open: true, modelValue: undefined },
|
||||
{
|
||||
default: () => h(Item, { item, index: 0 })
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
return render(Host, { global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
describe('RemoteCombo.Item preview rendering', () => {
|
||||
it('renders an <img> for image preview_type with preview_url', () => {
|
||||
renderItemInOpenCombobox(
|
||||
{
|
||||
id: '1',
|
||||
name: 'Picture',
|
||||
preview_url: 'https://cdn.example.com/p.png'
|
||||
},
|
||||
'image'
|
||||
)
|
||||
const img = screen.getByRole('img', { name: /picture/i })
|
||||
expect(img).toHaveAttribute('src', 'https://cdn.example.com/p.png')
|
||||
})
|
||||
|
||||
it('renders an audio play button for audio preview_type with preview_url', () => {
|
||||
renderItemInOpenCombobox(
|
||||
{ id: '1', name: 'Voice', preview_url: 'https://cdn.example.com/a.mp3' },
|
||||
'audio'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: /play audio preview/i })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits preview element when preview_url is missing', () => {
|
||||
renderItemInOpenCombobox({ id: '1', name: 'NoPreview' }, 'image')
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ComboboxItem, ComboboxItemIndicator } from 'reka-ui'
|
||||
import { computed, inject, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { displayName } from '@/base/remote/itemSchema'
|
||||
import type { DropdownItemShape } from '@/base/remote/itemSchema'
|
||||
|
||||
import { itemVariants } from './remoteCombo.variants'
|
||||
import type { ItemVariants } from './remoteCombo.variants'
|
||||
import { RemoteComboKey } from './state'
|
||||
|
||||
const props = defineProps<{
|
||||
item: DropdownItemShape
|
||||
index: number
|
||||
layout?: ItemVariants['layout']
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const ctx = inject(RemoteComboKey)
|
||||
if (!ctx) {
|
||||
throw new Error('RemoteCombo.Item must be used inside RemoteCombo.Root')
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSelected = computed(() => ctx.selectedValue.value === props.item.id)
|
||||
const hasPreview = computed(() => !!props.item.preview_url)
|
||||
const label = computed(() => displayName(props.item))
|
||||
|
||||
const audioEl = useTemplateRef<HTMLAudioElement>('audioEl')
|
||||
const isPlaying = ref(false)
|
||||
|
||||
function toggleAudio() {
|
||||
const el = audioEl.value
|
||||
if (!el) return
|
||||
if (el.paused) {
|
||||
void el.play().then(() => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
} else {
|
||||
el.pause()
|
||||
isPlaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
isPlaying.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxItem
|
||||
:value="item.id"
|
||||
:class="cn(itemVariants({ layout: props.layout }), props.class)"
|
||||
:data-testid="`remote-combo-item-${index}`"
|
||||
@select="ctx.select(item.id)"
|
||||
>
|
||||
<slot :item="item" :index="index" :is-selected="isSelected">
|
||||
<template v-if="hasPreview && ctx.previewType.value === 'image'">
|
||||
<img
|
||||
:src="item.preview_url"
|
||||
:alt="label"
|
||||
class="size-10 shrink-0 rounded-sm object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="hasPreview && ctx.previewType.value === 'video'">
|
||||
<video
|
||||
:src="item.preview_url"
|
||||
:aria-label="label"
|
||||
class="size-10 shrink-0 rounded-sm object-cover"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="hasPreview && ctx.previewType.value === 'audio'">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background-hover text-base-foreground hover:bg-secondary-background-selected focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="
|
||||
isPlaying
|
||||
? t('widgets.remoteCombo.pauseAudioPreview')
|
||||
: t('widgets.remoteCombo.playAudioPreview')
|
||||
"
|
||||
:aria-pressed="isPlaying"
|
||||
@click.stop="toggleAudio"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
isPlaying ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
:src="item.preview_url"
|
||||
preload="none"
|
||||
class="sr-only"
|
||||
@ended="handleAudioEnded"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<div class="flex flex-1 flex-col gap-0.5 overflow-hidden">
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<span
|
||||
v-if="item.description"
|
||||
class="truncate text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ item.description }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<ComboboxItemIndicator>
|
||||
<i
|
||||
class="icon-[lucide--check] size-4 text-primary-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ComboboxItemIndicator>
|
||||
</ComboboxItem>
|
||||
</template>
|
||||
@@ -1,54 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
|
||||
const props = defineProps<{
|
||||
layout?: LayoutMode
|
||||
}>()
|
||||
|
||||
const layoutMode = defineModel<LayoutMode>('layout', { default: 'list' })
|
||||
|
||||
void props
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function setLayout(mode: LayoutMode) {
|
||||
layoutMode.value = mode
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
role="group"
|
||||
:aria-label="t('widgets.remoteCombo.layoutSwitcherAriaLabel')"
|
||||
data-testid="remote-combo-layout-switcher"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
:aria-label="t('widgets.remoteCombo.layoutList')"
|
||||
:aria-pressed="layoutMode === 'list'"
|
||||
:class="cn(layoutMode === 'list' && 'bg-secondary-background-selected')"
|
||||
@click.stop="setLayout('list')"
|
||||
>
|
||||
<i class="icon-[lucide--list] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
:aria-label="t('widgets.remoteCombo.layoutGrid')"
|
||||
:aria-pressed="layoutMode === 'grid'"
|
||||
:class="cn(layoutMode === 'grid' && 'bg-secondary-background-selected')"
|
||||
@click.stop="setLayout('grid')"
|
||||
>
|
||||
<i class="icon-[lucide--grid-2x2] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ComboboxViewport } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { listVariants } from './remoteCombo.variants'
|
||||
|
||||
defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxViewport
|
||||
:class="cn(listVariants(), $props.class)"
|
||||
data-testid="remote-combo-list"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxViewport>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center gap-2 p-3 text-xs text-muted-foreground"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
data-testid="remote-combo-loading"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-primary-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ t('widgets.remoteCombo.loading') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import { RemoteComboKey } from './state'
|
||||
import type { RemoteComboContext } from './state'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
context?: RemoteComboContext
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const injected = inject(RemoteComboKey, null)
|
||||
const resolved = props.context ?? injected
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
'RemoteCombo.Refresh requires a RemoteComboContext (provide via Root or pass as prop)'
|
||||
)
|
||||
}
|
||||
const ctx = resolved
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
async function handleClick() {
|
||||
await ctx.refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
type="button"
|
||||
:disabled="props.disabled"
|
||||
:aria-label="t('widgets.remoteCombo.refresh')"
|
||||
:title="t('widgets.remoteCombo.refresh')"
|
||||
:class="cn('shrink-0', props.class)"
|
||||
data-testid="remote-combo-refresh"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--rotate-cw] size-4',
|
||||
ctx.isFetching.value && 'animate-spin'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -1,164 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { DropdownItemShape } from '@/base/remote/itemSchema'
|
||||
|
||||
import Content from './Content.vue'
|
||||
import Empty from './Empty.vue'
|
||||
import ErrorAtom from './Error.vue'
|
||||
import Item from './Item.vue'
|
||||
import LayoutSwitcher from './LayoutSwitcher.vue'
|
||||
import List from './List.vue'
|
||||
import Loading from './Loading.vue'
|
||||
import Refresh from './Refresh.vue'
|
||||
import Root from './Root.vue'
|
||||
import Search from './Search.vue'
|
||||
import Trigger from './Trigger.vue'
|
||||
import type { RemoteComboContext } from './state'
|
||||
|
||||
const sampleItems: DropdownItemShape[] = [
|
||||
{ id: 'voice-1', name: 'Aria', description: 'Soft, warm female voice' },
|
||||
{ id: 'voice-2', name: 'Roger', description: 'Deep, narrator male voice' },
|
||||
{ id: 'voice-3', name: 'Sarah', description: 'Bright, youthful' },
|
||||
{ id: 'voice-4', name: 'Charlie', description: 'Calm, professional' },
|
||||
{ id: 'voice-5', name: 'George', description: 'Casual, friendly' }
|
||||
]
|
||||
|
||||
interface StoryArgs {
|
||||
isLoading: boolean
|
||||
hasError: boolean
|
||||
items: DropdownItemShape[]
|
||||
selected?: string
|
||||
}
|
||||
|
||||
function makeContext(args: StoryArgs): RemoteComboContext {
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const selectedValue = ref(args.selected) as Ref<string | undefined>
|
||||
const items = computed(() => args.items)
|
||||
const filteredItems = computed(() =>
|
||||
searchQuery.value
|
||||
? items.value.filter((it) =>
|
||||
it.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
: items.value
|
||||
)
|
||||
return {
|
||||
isOpen,
|
||||
searchQuery,
|
||||
selectedValue,
|
||||
items,
|
||||
filteredItems,
|
||||
isLoading: computed(() => args.isLoading),
|
||||
isFetching: computed(() => args.isLoading),
|
||||
errorMessage: computed(() =>
|
||||
args.hasError ? 'Failed to load options' : null
|
||||
),
|
||||
refresh: async () => {},
|
||||
select: (id) => {
|
||||
selectedValue.value = id
|
||||
isOpen.value = false
|
||||
},
|
||||
fieldLabel: computed(() => 'voice'),
|
||||
previewType: computed(() => 'image' as const)
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Widgets/RemoteCombo',
|
||||
argTypes: {
|
||||
isLoading: { control: 'boolean' },
|
||||
hasError: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
items: sampleItems,
|
||||
selected: undefined
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Atomized remote-populated combo widget. Compose Root → Trigger + Content (Search, List/Item, Loading, Empty, Error) and an optional Refresh sibling.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<StoryArgs>
|
||||
|
||||
const renderTemplate = (args: StoryArgs) => ({
|
||||
components: {
|
||||
Root,
|
||||
Trigger,
|
||||
Content,
|
||||
Search,
|
||||
List,
|
||||
Item,
|
||||
Empty,
|
||||
Loading,
|
||||
ErrorAtom,
|
||||
Refresh,
|
||||
LayoutSwitcher
|
||||
},
|
||||
setup() {
|
||||
const ctx = makeContext(args)
|
||||
return { ctx, args }
|
||||
},
|
||||
template: `
|
||||
<div class="flex w-72 items-center gap-1">
|
||||
<Root :context="ctx" class="min-w-0 flex-1">
|
||||
<Trigger class="min-w-0 flex-1" />
|
||||
<Content>
|
||||
<Search />
|
||||
<Loading v-if="args.isLoading" />
|
||||
<ErrorAtom v-else-if="args.hasError" />
|
||||
<List v-else>
|
||||
<Item v-for="(item, index) in ctx.filteredItems.value" :key="item.id" :item="item" :index="index" />
|
||||
<Empty v-if="ctx.filteredItems.value.length === 0" />
|
||||
</List>
|
||||
</Content>
|
||||
</Root>
|
||||
<Refresh :context="ctx" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
export const Default: Story = {
|
||||
render: renderTemplate
|
||||
}
|
||||
|
||||
export const LoadingState: Story = {
|
||||
args: { isLoading: true, items: [] },
|
||||
render: renderTemplate
|
||||
}
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: { hasError: true, items: [] },
|
||||
render: renderTemplate
|
||||
}
|
||||
|
||||
export const EmptyState: Story = {
|
||||
args: { items: [] },
|
||||
render: renderTemplate
|
||||
}
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: { selected: 'voice-2' },
|
||||
render: renderTemplate
|
||||
}
|
||||
|
||||
export const KeyboardA11y: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Tab to focus trigger; Enter/Space opens; Arrow keys navigate; Enter selects; Escape closes. Demonstrates the reka-ui Combobox keyboard contract.'
|
||||
}
|
||||
}
|
||||
},
|
||||
render: renderTemplate
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ComboboxRoot } from 'reka-ui'
|
||||
import { provide } from 'vue'
|
||||
|
||||
import { RemoteComboKey } from './state'
|
||||
import type { RemoteComboContext } from './state'
|
||||
|
||||
const props = defineProps<{
|
||||
context: RemoteComboContext
|
||||
multiple?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const ctx = props.context
|
||||
provide(RemoteComboKey, ctx)
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
ctx.isOpen.value = value
|
||||
}
|
||||
|
||||
function onSearchChange(value: string) {
|
||||
ctx.searchQuery.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
:open="ctx.isOpen.value"
|
||||
:search-term="ctx.searchQuery.value"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
ignore-filter
|
||||
:reset-search-term-on-select="false"
|
||||
data-testid="remote-combo-root"
|
||||
@update:open="onOpenChange"
|
||||
@update:search-term="onSearchChange"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ComboboxInput } from 'reka-ui'
|
||||
import { inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { searchVariants } from './remoteCombo.variants'
|
||||
import { RemoteComboKey } from './state'
|
||||
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const ctx = inject(RemoteComboKey)
|
||||
if (!ctx) {
|
||||
throw new Error('RemoteCombo.Search must be used inside RemoteCombo.Root')
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emptyDisplayValue = () => ''
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(searchVariants())">
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ComboboxInput
|
||||
v-model="ctx.searchQuery.value"
|
||||
:display-value="emptyDisplayValue"
|
||||
:placeholder="placeholder ?? t('g.search')"
|
||||
class="w-full border-none bg-transparent text-xs text-base-foreground outline-none placeholder:text-muted-foreground"
|
||||
:aria-label="
|
||||
t('widgets.remoteCombo.searchAriaLabel', {
|
||||
field: ctx.fieldLabel.value
|
||||
})
|
||||
"
|
||||
data-testid="remote-combo-search-input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ComboboxAnchor, ComboboxTrigger } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { displayName } from '@/base/remote/itemSchema'
|
||||
|
||||
import { triggerVariants } from './remoteCombo.variants'
|
||||
import type { TriggerVariants } from './remoteCombo.variants'
|
||||
import { RemoteComboKey } from './state'
|
||||
|
||||
const props = defineProps<{
|
||||
size?: TriggerVariants['size']
|
||||
variant?: TriggerVariants['variant']
|
||||
border?: TriggerVariants['border']
|
||||
class?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const ctx = inject(RemoteComboKey)
|
||||
if (!ctx) {
|
||||
throw new Error('RemoteCombo.Trigger must be used inside RemoteCombo.Root')
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (ctx.isLoading.value) return t('widgets.remoteCombo.loading')
|
||||
if (ctx.errorMessage.value) return ctx.errorMessage.value
|
||||
const id = ctx.selectedValue.value
|
||||
if (!id) return props.placeholder ?? t('widgets.uploadSelect.placeholder')
|
||||
const item = ctx.items.value.find((i) => i.id === id)
|
||||
return item ? displayName(item) : id
|
||||
})
|
||||
|
||||
const computedBorder = computed<TriggerVariants['border']>(() => {
|
||||
if (props.border) return props.border
|
||||
if (ctx.errorMessage.value) return 'invalid'
|
||||
if (ctx.isOpen.value) return 'active'
|
||||
return 'none'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxTrigger
|
||||
:class="
|
||||
cn(
|
||||
triggerVariants({
|
||||
size: props.size,
|
||||
variant: props.variant,
|
||||
border: computedBorder
|
||||
}),
|
||||
props.class
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('widgets.remoteCombo.selectAriaLabel', {
|
||||
field: ctx.fieldLabel.value
|
||||
})
|
||||
"
|
||||
:disabled="
|
||||
props.disabled || ctx.isLoading.value || !!ctx.errorMessage.value
|
||||
"
|
||||
:aria-disabled="
|
||||
props.disabled || ctx.isLoading.value || !!ctx.errorMessage.value
|
||||
"
|
||||
data-testid="remote-combo-trigger"
|
||||
>
|
||||
<span class="truncate">{{ displayLabel }}</span>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
</template>
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const triggerVariants = cva({
|
||||
base: 'relative inline-flex w-full items-center justify-between gap-2 cursor-pointer select-none rounded-md border border-border-default bg-secondary-background text-base-foreground transition-colors hover:bg-secondary-background-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-6 px-2 text-xs',
|
||||
md: 'h-8 px-3 text-xs',
|
||||
lg: 'h-10 px-4 text-sm'
|
||||
},
|
||||
variant: {
|
||||
secondary: 'bg-secondary-background hover:bg-secondary-background-hover',
|
||||
primary:
|
||||
'bg-primary-background text-base-foreground hover:bg-primary-background-hover',
|
||||
destructive:
|
||||
'bg-destructive-background text-base-foreground hover:bg-destructive-background-hover',
|
||||
textonly:
|
||||
'border-transparent bg-transparent hover:bg-secondary-background-hover'
|
||||
},
|
||||
border: {
|
||||
none: '',
|
||||
active: 'border-node-component-border',
|
||||
invalid: 'border-destructive-background'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
variant: 'secondary',
|
||||
border: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
export type TriggerVariants = VariantProps<typeof triggerVariants>
|
||||
|
||||
export const contentVariants = cva({
|
||||
base: 'z-50 min-w-(--reka-combobox-trigger-width) overflow-hidden rounded-md border border-border-default bg-base-background text-base-foreground shadow-md data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2'
|
||||
})
|
||||
|
||||
export const itemVariants = cva({
|
||||
base: 'relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-xs text-base-foreground outline-none transition-colors hover:bg-secondary-background-hover data-highlighted:bg-secondary-background-selected data-[state=checked]:bg-secondary-background-selected data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
variants: {
|
||||
layout: {
|
||||
single: 'rounded-sm',
|
||||
multi: 'gap-2 rounded-sm'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
layout: 'single'
|
||||
}
|
||||
})
|
||||
|
||||
export type ItemVariants = VariantProps<typeof itemVariants>
|
||||
|
||||
export const searchVariants = cva({
|
||||
base: 'flex w-full items-center gap-2 border-b border-border-default px-3 py-1.5'
|
||||
})
|
||||
|
||||
export const listVariants = cva({
|
||||
base: 'flex max-h-[16rem] flex-col gap-0 overflow-y-auto p-1 text-xs scrollbar-custom'
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ComputedRef, InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { DropdownItemShape } from '@/base/remote/itemSchema'
|
||||
|
||||
export type RemoteComboPreviewType = 'image' | 'video' | 'audio'
|
||||
|
||||
export interface RemoteComboContext {
|
||||
isOpen: Ref<boolean>
|
||||
searchQuery: Ref<string>
|
||||
selectedValue: Ref<string | undefined>
|
||||
items: ComputedRef<DropdownItemShape[]>
|
||||
filteredItems: ComputedRef<DropdownItemShape[]>
|
||||
isLoading: ComputedRef<boolean>
|
||||
isFetching: ComputedRef<boolean>
|
||||
errorMessage: ComputedRef<string | null>
|
||||
refresh: () => Promise<void>
|
||||
select: (id: string) => void
|
||||
fieldLabel: ComputedRef<string>
|
||||
previewType: ComputedRef<RemoteComboPreviewType>
|
||||
}
|
||||
|
||||
export const RemoteComboKey: InjectionKey<RemoteComboContext> =
|
||||
Symbol('RemoteComboContext')
|
||||
@@ -1,187 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import axios from 'axios'
|
||||
import type * as AxiosModule from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type {
|
||||
RemoteComboConfig,
|
||||
RemoteItemSchema
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
vi.mock('axios', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AxiosModule>()
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual.default, get: vi.fn() }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
userId: undefined,
|
||||
getAuthHeader: vi.fn(() => Promise.resolve(null))
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
widgets: {
|
||||
remoteCombo: {
|
||||
loading: 'Loading...',
|
||||
loadFailed: 'Failed to load options',
|
||||
noResults: 'No results found',
|
||||
refresh: 'Refresh options',
|
||||
selectAriaLabel: 'Select {field}',
|
||||
searchAriaLabel: 'Search {field}',
|
||||
layoutSwitcherAriaLabel: 'Layout switcher',
|
||||
layoutList: 'List view',
|
||||
layoutGrid: 'Grid view'
|
||||
},
|
||||
uploadSelect: { placeholder: 'Select...' }
|
||||
},
|
||||
g: { search: 'Search' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeWidget(
|
||||
spec: ComboInputSpec,
|
||||
value: string | undefined = undefined
|
||||
): SimplifiedWidget<string | undefined> {
|
||||
return createMockWidget({
|
||||
name: 'remote_field',
|
||||
type: 'combo',
|
||||
value,
|
||||
spec
|
||||
}) as SimplifiedWidget<string | undefined>
|
||||
}
|
||||
|
||||
const itemSchema: RemoteItemSchema = {
|
||||
value_field: 'id',
|
||||
label_field: 'name',
|
||||
preview_type: 'image'
|
||||
}
|
||||
|
||||
function makeRemoteCombo(
|
||||
overrides: Partial<RemoteComboConfig> = {}
|
||||
): ComboInputSpec {
|
||||
return {
|
||||
name: 'remote_field',
|
||||
type: 'COMBO',
|
||||
isOptional: false,
|
||||
remote_combo: {
|
||||
route: '/test/options',
|
||||
item_schema: itemSchema,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderWithProviders(
|
||||
component: typeof RichComboWidget,
|
||||
props: { widget: SimplifiedWidget<string | undefined> }
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } }
|
||||
})
|
||||
return render(component, {
|
||||
global: {
|
||||
plugins: [
|
||||
i18n,
|
||||
createTestingPinia({ createSpy: vi.fn }),
|
||||
[VueQueryPlugin, { queryClient }]
|
||||
]
|
||||
},
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(axios.get).mockReset()
|
||||
})
|
||||
|
||||
describe('RichComboWidget', () => {
|
||||
it('renders trigger with placeholder when no selection and no items loaded', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
|
||||
const widget = makeWidget(makeRemoteCombo())
|
||||
renderWithProviders(RichComboWidget, { widget })
|
||||
expect(screen.getByTestId('remote-combo-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading state while fetching', async () => {
|
||||
let resolveResp: (value: unknown) => void = () => {}
|
||||
vi.mocked(axios.get).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveResp = (data) => resolve({ data, status: 200 } as never)
|
||||
})
|
||||
)
|
||||
const widget = makeWidget(makeRemoteCombo())
|
||||
renderWithProviders(RichComboWidget, { widget })
|
||||
const trigger = await screen.findByTestId('remote-combo-trigger')
|
||||
expect(trigger).toHaveTextContent(/loading/i)
|
||||
expect(trigger).toHaveAttribute('aria-disabled', 'true')
|
||||
resolveResp([])
|
||||
})
|
||||
|
||||
it('auto_select="first" selects first item when value is empty', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({
|
||||
data: [
|
||||
{ id: 'one', name: 'One' },
|
||||
{ id: 'two', name: 'Two' }
|
||||
],
|
||||
status: 200
|
||||
})
|
||||
const widget = makeWidget(makeRemoteCombo({ auto_select: 'first' }))
|
||||
const { emitted } = renderWithProviders(RichComboWidget, { widget })
|
||||
await waitFor(() => {
|
||||
const events = emitted<unknown[]>('update:modelValue')
|
||||
expect(events?.[0]?.[0]).toBe('one')
|
||||
})
|
||||
})
|
||||
|
||||
it('auto_select="last" selects last item when value is empty', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({
|
||||
data: [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
],
|
||||
status: 200
|
||||
})
|
||||
const widget = makeWidget(makeRemoteCombo({ auto_select: 'last' }))
|
||||
const { emitted } = renderWithProviders(RichComboWidget, { widget })
|
||||
await waitFor(() => {
|
||||
const events = emitted<unknown[]>('update:modelValue')
|
||||
expect(events?.[0]?.[0]).toBe('c')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders refresh button when refresh_button is undefined', () => {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
|
||||
const widget = makeWidget(makeRemoteCombo())
|
||||
renderWithProviders(RichComboWidget, { widget })
|
||||
expect(screen.getByTestId('remote-combo-refresh')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides refresh button when refresh_button is false', () => {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
|
||||
const widget = makeWidget(makeRemoteCombo({ refresh_button: false }))
|
||||
renderWithProviders(RichComboWidget, { widget })
|
||||
expect(screen.queryByTestId('remote-combo-refresh')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import RemoteComboContent from './RemoteCombo/Content.vue'
|
||||
import RemoteComboEmpty from './RemoteCombo/Empty.vue'
|
||||
import RemoteComboError from './RemoteCombo/Error.vue'
|
||||
import RemoteComboItem from './RemoteCombo/Item.vue'
|
||||
import RemoteComboList from './RemoteCombo/List.vue'
|
||||
import RemoteComboLoading from './RemoteCombo/Loading.vue'
|
||||
import RemoteComboRefresh from './RemoteCombo/Refresh.vue'
|
||||
import RemoteComboRoot from './RemoteCombo/Root.vue'
|
||||
import RemoteComboSearch from './RemoteCombo/Search.vue'
|
||||
import RemoteComboTrigger from './RemoteCombo/Trigger.vue'
|
||||
import type { RemoteComboContext } from './RemoteCombo/state'
|
||||
import { useRemoteCombo } from '../composables/useRemoteCombo'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
|
||||
const comboSpec = computed(() => {
|
||||
if (widget.spec && isComboInputSpec(widget.spec)) {
|
||||
return widget.spec
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const remoteConfig = computed<RemoteComboConfig | undefined>(
|
||||
() => comboSpec.value?.remote_combo
|
||||
)
|
||||
|
||||
const fieldLabel = computed(() => widget.label ?? widget.name)
|
||||
|
||||
const combo = useRemoteCombo({
|
||||
config: remoteConfig,
|
||||
modelValue,
|
||||
fieldLabel
|
||||
})
|
||||
|
||||
const context: RemoteComboContext = {
|
||||
isOpen: combo.isOpen,
|
||||
searchQuery: combo.searchQuery,
|
||||
selectedValue: combo.selectedValue,
|
||||
items: combo.items,
|
||||
filteredItems: combo.filteredItems,
|
||||
isLoading: combo.isLoading,
|
||||
isFetching: combo.isFetching,
|
||||
errorMessage: combo.errorMessage,
|
||||
refresh: combo.refresh,
|
||||
select: combo.select,
|
||||
fieldLabel: combo.fieldLabel,
|
||||
previewType: combo.previewType
|
||||
}
|
||||
|
||||
const showRefreshButton = computed(
|
||||
() => !!remoteConfig.value && remoteConfig.value.refresh_button !== false
|
||||
)
|
||||
|
||||
const isDisabled = computed(() => widget.options?.disabled === true)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full min-w-0 items-center gap-1"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<RemoteComboRoot
|
||||
:context="context"
|
||||
:disabled="isDisabled"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<RemoteComboTrigger :disabled="isDisabled" class="min-w-0 flex-1" />
|
||||
<RemoteComboContent>
|
||||
<RemoteComboSearch />
|
||||
<RemoteComboLoading v-if="combo.isLoading.value" />
|
||||
<RemoteComboError v-else-if="combo.errorMessage.value" />
|
||||
<RemoteComboList v-else>
|
||||
<RemoteComboItem
|
||||
v-for="(item, index) in combo.filteredItems.value"
|
||||
:key="item.id"
|
||||
:item
|
||||
:index
|
||||
/>
|
||||
<RemoteComboEmpty v-if="combo.filteredItems.value.length === 0" />
|
||||
</RemoteComboList>
|
||||
</RemoteComboContent>
|
||||
</RemoteComboRoot>
|
||||
<RemoteComboRefresh
|
||||
v-if="showRefreshButton"
|
||||
:context="context"
|
||||
:disabled="isDisabled"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<RichComboWidget v-if="hasRemoteCombo" v-model="modelValue" :widget />
|
||||
<WidgetSelectDropdown
|
||||
v-else-if="isDropdownUIWidget"
|
||||
v-if="isDropdownUIWidget"
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
:node-type="widget.nodeType ?? nodeType"
|
||||
@@ -25,7 +24,6 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||
@@ -55,8 +53,6 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const hasRemoteCombo = computed(() => !!comboSpec.value?.remote_combo)
|
||||
|
||||
const specDescriptor = computed<{
|
||||
kind: AssetKind
|
||||
allowUpload: boolean
|
||||
|
||||
@@ -33,8 +33,6 @@ interface Props {
|
||||
accept?: string
|
||||
filterOptions?: FilterOption[]
|
||||
sortOptions?: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -61,8 +59,6 @@ const {
|
||||
accept,
|
||||
filterOptions = [],
|
||||
sortOptions = getDefaultSortOptions(),
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -233,8 +229,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:filter-options
|
||||
:sort-options
|
||||
:show-sort
|
||||
:show-layout-switcher
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
|
||||
@@ -68,11 +68,7 @@ const theButtonStyle = computed(() =>
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
selectedItems
|
||||
.map((item) => item.label || item.name || item.id)
|
||||
.join(', ')
|
||||
}}
|
||||
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
|
||||
</span>
|
||||
</span>
|
||||
<i
|
||||
|
||||
@@ -20,8 +20,6 @@ interface Props {
|
||||
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -33,8 +31,6 @@ const {
|
||||
isSelected,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -116,8 +112,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:sort-options
|
||||
:show-sort
|
||||
:show-layout-switcher
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
@@ -151,7 +145,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
:preview-url="item.preview_url ?? ''"
|
||||
:name="item.name"
|
||||
:label="item.label"
|
||||
:description="item.description"
|
||||
:layout="layoutMode"
|
||||
@click="emit('item-click', item, index)"
|
||||
/>
|
||||
|
||||
@@ -16,10 +16,8 @@ import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { showSort = true, showLayoutSwitcher = true } = defineProps<{
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -114,7 +112,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="showSort"
|
||||
ref="sortTriggerRef"
|
||||
:aria-label="t('assetBrowser.sortBy')"
|
||||
:title="t('assetBrowser.sortBy')"
|
||||
@@ -135,7 +132,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="showSort"
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
@@ -310,7 +306,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
</Popover>
|
||||
|
||||
<div
|
||||
v-if="showLayoutSwitcher"
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
|
||||
@@ -28,15 +28,11 @@ const assetKind = inject(AssetKindKey)
|
||||
|
||||
const isVideo = computed(() => assetKind?.value === 'video')
|
||||
const isMesh = computed(() => assetKind?.value === 'mesh')
|
||||
const isAudio = computed(() => assetKind?.value === 'audio')
|
||||
|
||||
const mediaContainerRef = ref<HTMLElement>()
|
||||
const resolvedMeshPreview = ref<string | null>(null)
|
||||
const meshPreviewAttempted = ref(false)
|
||||
|
||||
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||
const isPlayingAudio = ref(false)
|
||||
|
||||
function toLookupName(name: string): string {
|
||||
const stripped = name.replace(/ \[output\]$/, '')
|
||||
const slash = stripped.lastIndexOf('/')
|
||||
@@ -72,17 +68,6 @@ function handleClick() {
|
||||
emit('click', props.index)
|
||||
}
|
||||
|
||||
function toggleAudioPreview(event: Event) {
|
||||
event.stopPropagation()
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
if (audio.paused) {
|
||||
void audio.play().catch(() => {})
|
||||
} else {
|
||||
audio.pause()
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
emit('mediaLoad', event)
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
@@ -163,35 +148,6 @@ function handleVideoLoad(event: Event) {
|
||||
muted
|
||||
@loadeddata="handleVideoLoad"
|
||||
/>
|
||||
<button
|
||||
v-else-if="previewUrl && isAudio"
|
||||
type="button"
|
||||
:aria-label="
|
||||
isPlayingAudio
|
||||
? t('widgets.remoteCombo.pauseAudioPreview')
|
||||
: t('widgets.remoteCombo.playAudioPreview')
|
||||
"
|
||||
:aria-pressed="isPlayingAudio"
|
||||
class="flex size-full cursor-pointer items-center justify-center bg-component-node-widget-background hover:bg-component-node-widget-background-hovered"
|
||||
@click.stop="toggleAudioPreview"
|
||||
>
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="previewUrl"
|
||||
preload="none"
|
||||
@play="isPlayingAudio = true"
|
||||
@pause="isPlayingAudio = false"
|
||||
@ended="isPlayingAudio = false"
|
||||
/>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'text-secondary size-5',
|
||||
isPlayingAudio ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<img
|
||||
v-else-if="displayedPreviewUrl"
|
||||
:src="displayedPreviewUrl"
|
||||
@@ -237,13 +193,6 @@ function handleVideoLoad(event: Event) {
|
||||
>
|
||||
{{ label ?? name }}
|
||||
</span>
|
||||
<!-- Description -->
|
||||
<span
|
||||
v-if="description && layout !== 'grid'"
|
||||
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
<!-- Meta Data -->
|
||||
<span v-if="actualDimensions" class="text-secondary block text-xs">
|
||||
{{ actualDimensions }}
|
||||
|
||||
@@ -12,9 +12,7 @@ export interface FormDropdownItem {
|
||||
name: string
|
||||
/** Original/alternate label (e.g., original filename) */
|
||||
label?: string
|
||||
/** Short description shown below the name in list view */
|
||||
description?: string
|
||||
/** Preview image/video/audio URL */
|
||||
/** Preview image/video URL */
|
||||
preview_url?: string
|
||||
/** Whether the item is immutable (public model) - used for ownership filtering */
|
||||
is_immutable?: boolean
|
||||
@@ -49,7 +47,6 @@ export interface FormDropdownMenuItemProps {
|
||||
previewUrl: string
|
||||
name: string
|
||||
label?: string
|
||||
description?: string
|
||||
layout?: LayoutMode
|
||||
}
|
||||
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { computed, ref, toValue, watch } from 'vue'
|
||||
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
buildSearchText,
|
||||
extractItems,
|
||||
getByPath,
|
||||
mapToDropdownItem
|
||||
} from '@/base/remote/itemSchema'
|
||||
import type { DropdownItemShape } from '@/base/remote/itemSchema'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { useRemoteOptions } from '@/platform/remote/composables/useRemoteOptions'
|
||||
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
|
||||
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import type { RemoteComboPreviewType } from '../components/RemoteCombo/state'
|
||||
|
||||
interface UseRemoteComboArgs {
|
||||
config: MaybeRefOrGetter<RemoteComboConfig | undefined | null>
|
||||
modelValue: Ref<string | undefined>
|
||||
fieldLabel?: MaybeRefOrGetter<string>
|
||||
enabled?: MaybeRefOrGetter<boolean>
|
||||
}
|
||||
|
||||
interface UseRemoteComboResult {
|
||||
isOpen: Ref<boolean>
|
||||
searchQuery: Ref<string>
|
||||
items: ComputedRef<DropdownItemShape[]>
|
||||
filteredItems: ComputedRef<DropdownItemShape[]>
|
||||
isLoading: ComputedRef<boolean>
|
||||
isFetching: ComputedRef<boolean>
|
||||
errorMessage: ComputedRef<string | null>
|
||||
refresh: () => Promise<void>
|
||||
select: (id: string) => void
|
||||
selectedValue: Ref<string | undefined>
|
||||
fieldLabel: ComputedRef<string>
|
||||
previewType: ComputedRef<RemoteComboPreviewType>
|
||||
}
|
||||
|
||||
export function useRemoteCombo(args: UseRemoteComboArgs): UseRemoteComboResult {
|
||||
const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const descriptor = computed<RemoteRequestDescriptor | null>(() => {
|
||||
const config = toValue(args.config)
|
||||
if (!config) return null
|
||||
return {
|
||||
client: 'comfyApi',
|
||||
route: config.route,
|
||||
responseKey: config.response_key,
|
||||
ttl: config.refresh,
|
||||
timeout: config.timeout,
|
||||
maxRetries: config.max_retries
|
||||
}
|
||||
})
|
||||
|
||||
const { rawData, isLoading, isFetching, error, refetch } = useRemoteOptions({
|
||||
descriptor,
|
||||
enabled: args.enabled
|
||||
})
|
||||
|
||||
const rawItems = computed<unknown[]>(() => {
|
||||
const data = rawData.value
|
||||
const config = toValue(args.config)
|
||||
if (data === undefined) return []
|
||||
const items = extractItems(data, config?.response_key)
|
||||
return items ?? []
|
||||
})
|
||||
|
||||
const items = computed<DropdownItemShape[]>(() => {
|
||||
const config = toValue(args.config)
|
||||
const schema = config?.item_schema
|
||||
if (schema) {
|
||||
const previewBaseUrl = getComfyApiBaseUrl()
|
||||
return rawItems.value.map((raw) =>
|
||||
mapToDropdownItem(raw, schema, { previewBaseUrl })
|
||||
)
|
||||
}
|
||||
return rawItems.value.map((raw) => {
|
||||
const val = String(raw ?? '')
|
||||
return { id: val, name: val }
|
||||
})
|
||||
})
|
||||
|
||||
const searchIndex = computed(() => {
|
||||
const config = toValue(args.config)
|
||||
const schema = config?.item_schema
|
||||
const fields = schema?.search_fields
|
||||
if (!schema || !fields?.length) return new Map<string, string>()
|
||||
const index = new Map<string, string>()
|
||||
for (const raw of rawItems.value) {
|
||||
const id = String(getByPath(raw, schema.value_field) ?? '')
|
||||
const text = buildSearchText(raw, fields)
|
||||
if (text) index.set(id, text)
|
||||
}
|
||||
return index
|
||||
})
|
||||
|
||||
const filteredItems = computed<DropdownItemShape[]>(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return items.value
|
||||
return items.value.filter((item) => {
|
||||
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
|
||||
return text.includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
const errorMessage = computed<string | null>(() => {
|
||||
if (!error.value) return null
|
||||
return t('widgets.remoteCombo.loadFailed')
|
||||
})
|
||||
|
||||
const fieldLabel = computed(() => toValue(args.fieldLabel) ?? '')
|
||||
const previewType = computed<RemoteComboPreviewType>(
|
||||
() => toValue(args.config)?.item_schema?.preview_type ?? 'image'
|
||||
)
|
||||
|
||||
function applyAutoSelect(config: RemoteComboConfig) {
|
||||
if (args.modelValue.value) return
|
||||
const list = items.value
|
||||
if (list.length === 0) return
|
||||
if (config.auto_select === 'first') {
|
||||
args.modelValue.value = list[0].id
|
||||
} else if (config.auto_select === 'last') {
|
||||
args.modelValue.value = list[list.length - 1].id
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
items,
|
||||
() => {
|
||||
const config = toValue(args.config)
|
||||
if (config) applyAutoSelect(config)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function refresh() {
|
||||
await refetch()
|
||||
}
|
||||
|
||||
function select(id: string) {
|
||||
args.modelValue.value = id
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
searchQuery,
|
||||
items,
|
||||
filteredItems,
|
||||
isLoading,
|
||||
isFetching,
|
||||
errorMessage,
|
||||
refresh,
|
||||
select,
|
||||
selectedValue: args.modelValue,
|
||||
fieldLabel,
|
||||
previewType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
import axios from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
function createMockWidget(overrides: Partial<IWidget> = {}): IWidget {
|
||||
return {
|
||||
name: 'test_widget',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
...overrides
|
||||
} as Partial<IWidget> as IWidget
|
||||
}
|
||||
|
||||
const mockCloudAuth = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
authHeader: null as { Authorization: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('axios', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof axios>()
|
||||
return {
|
||||
default: {
|
||||
...actual,
|
||||
get: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockCloudAuth.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', async () => {
|
||||
return {
|
||||
useAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
return {
|
||||
useSettingStore: () => ({
|
||||
settings: {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
|
||||
const DEFAULT_VALUE = 'Loading...'
|
||||
|
||||
function createMockConfig(overrides = {}): RemoteWidgetConfig {
|
||||
return {
|
||||
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
|
||||
refresh: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const createMockOptions = (inputOverrides = {}) => ({
|
||||
remoteConfig: createMockConfig(inputOverrides),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: createMockLGraphNode({
|
||||
addWidget: vi.fn(() => createMockWidget()),
|
||||
onRemoved: undefined
|
||||
}),
|
||||
widget: createMockWidget()
|
||||
})
|
||||
|
||||
function mockAxiosResponse(data: unknown, status = 200) {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({ data, status })
|
||||
}
|
||||
|
||||
function mockAxiosError(error: Error | string) {
|
||||
const err = error instanceof Error ? error : new Error(error)
|
||||
vi.mocked(axios.get).mockRejectedValueOnce(err)
|
||||
}
|
||||
|
||||
function createHookWithData(data: unknown, inputOverrides = {}) {
|
||||
mockAxiosResponse(data)
|
||||
const hook = useRemoteWidget(createMockOptions(inputOverrides))
|
||||
return hook
|
||||
}
|
||||
|
||||
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
|
||||
const hook = createHookWithData(data, inputOverrides)
|
||||
const result = await getResolvedValue(hook)
|
||||
return { hook, result }
|
||||
}
|
||||
|
||||
async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
|
||||
// Create a promise that resolves when the fetch is complete
|
||||
const responsePromise = new Promise<void>((resolve) => {
|
||||
hook.getValue(() => resolve())
|
||||
})
|
||||
await responsePromise
|
||||
return hook.getCachedValue()
|
||||
}
|
||||
|
||||
describe('useRemoteWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mocks
|
||||
vi.mocked(axios.get).mockReset()
|
||||
// Reset cache between tests
|
||||
vi.spyOn(Map.prototype, 'get').mockClear()
|
||||
vi.spyOn(Map.prototype, 'set').mockClear()
|
||||
vi.spyOn(Map.prototype, 'delete').mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create hook with default values', () => {
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
expect(hook.getCachedValue()).toBeUndefined()
|
||||
expect(hook.getValue()).toBe('Loading...')
|
||||
})
|
||||
|
||||
it('should generate consistent cache keys', () => {
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
expect(hook1.cacheKey).toBe(hook2.cacheKey)
|
||||
})
|
||||
|
||||
it('should handle query params in cache key', () => {
|
||||
const hook1 = useRemoteWidget(
|
||||
createMockOptions({ query_params: { a: 1 } })
|
||||
)
|
||||
const hook2 = useRemoteWidget(
|
||||
createMockOptions({ query_params: { a: 2 } })
|
||||
)
|
||||
expect(hook1.cacheKey).not.toBe(hook2.cacheKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchOptions', () => {
|
||||
it('should fetch data successfully', async () => {
|
||||
const mockData = ['optionA', 'optionB']
|
||||
const { hook, result } = await setupHookWithResponse(mockData)
|
||||
expect(result).toEqual(mockData)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
|
||||
hook.cacheKey.split(';')[0], // Get the route part from cache key
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should use response_key if provided', async () => {
|
||||
const mockResponse = { items: ['optionB', 'optionA', 'optionC'] }
|
||||
const { result } = await setupHookWithResponse(mockResponse, {
|
||||
response_key: 'items'
|
||||
})
|
||||
expect(result).toEqual(mockResponse.items)
|
||||
})
|
||||
|
||||
it('should cache successful responses', async () => {
|
||||
const mockData = ['optionA', 'optionB', 'optionC', 'optionD']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
const entry = hook.getCacheEntry()
|
||||
|
||||
expect(entry?.data).toEqual(mockData)
|
||||
expect(entry?.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const error = new Error('Network error')
|
||||
mockAxiosError(error)
|
||||
|
||||
const { hook } = await setupHookWithResponse([])
|
||||
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error).toBeTruthy()
|
||||
expect(entry?.lastErrorTime).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle empty array responses', async () => {
|
||||
const { result } = await setupHookWithResponse([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle malformed response data', async () => {
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
|
||||
mockAxiosResponse(null)
|
||||
const data1 = hook.getValue()
|
||||
|
||||
mockAxiosResponse(undefined)
|
||||
const data2 = hook.getValue()
|
||||
|
||||
expect(data1).toBe(DEFAULT_VALUE)
|
||||
expect(data2).toBe(DEFAULT_VALUE)
|
||||
})
|
||||
|
||||
it('should handle non-200 status codes', async () => {
|
||||
mockAxiosError('Request failed with status code 404')
|
||||
|
||||
const { hook } = await setupHookWithResponse([])
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error?.message).toBe('Request failed with status code 404')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('permanent widgets (no refresh)', () => {
|
||||
it('permanent widgets should not attempt fetch after initialization', async () => {
|
||||
const mockData = ['data that is permanent after initialization']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
|
||||
await getResolvedValue(hook)
|
||||
await getResolvedValue(hook)
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('permanent widgets should re-fetch if refreshValue is called', async () => {
|
||||
const mockData = ['data that is permanent after initialization']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
|
||||
await getResolvedValue(hook)
|
||||
expect(hook.getCachedValue()).toEqual(mockData)
|
||||
|
||||
const refreshedData = ['data that user forced to be fetched']
|
||||
mockAxiosResponse(refreshedData)
|
||||
|
||||
hook.refreshValue()
|
||||
|
||||
// Wait for cache to update with refreshed data
|
||||
await vi.waitFor(() => {
|
||||
expect(hook.getCachedValue()).toEqual(refreshedData)
|
||||
})
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('permanent widgets should still retry if request fails', async () => {
|
||||
mockAxiosError('Network error')
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
expect(secondData).toBe('Loading...')
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should treat empty refresh field as permanent', async () => {
|
||||
const { hook } = await setupHookWithResponse(['data that is permanent'])
|
||||
|
||||
await getResolvedValue(hook)
|
||||
await getResolvedValue(hook)
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should refresh when data is stale', async () => {
|
||||
const refresh = 256
|
||||
const mockData1 = ['option1']
|
||||
const mockData2 = ['option2']
|
||||
|
||||
const { hook } = await setupHookWithResponse(mockData1, { refresh })
|
||||
mockAxiosResponse(mockData2)
|
||||
|
||||
vi.setSystemTime(Date.now() + refresh)
|
||||
const newData = await getResolvedValue(hook)
|
||||
|
||||
expect(newData).toEqual(mockData2)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should not refresh when data is not stale', async () => {
|
||||
const { hook } = await setupHookWithResponse(['option1'], {
|
||||
refresh: 512
|
||||
})
|
||||
|
||||
vi.setSystemTime(Date.now() + 128)
|
||||
await getResolvedValue(hook)
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use backoff instead of refresh after error', async () => {
|
||||
const refresh = 4096
|
||||
const { hook } = await setupHookWithResponse(['first success'], {
|
||||
refresh
|
||||
})
|
||||
|
||||
mockAxiosError('Network error')
|
||||
vi.setSystemTime(Date.now() + refresh)
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
|
||||
mockAxiosResponse(['second success'])
|
||||
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
|
||||
const thirdData = await getResolvedValue(hook)
|
||||
expect(thirdData).toEqual(['second success'])
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should use last valid value after error', async () => {
|
||||
const refresh = 4096
|
||||
const { hook } = await setupHookWithResponse(['a valid value'], {
|
||||
refresh
|
||||
})
|
||||
|
||||
mockAxiosError('Network error')
|
||||
vi.setSystemTime(Date.now() + refresh)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
|
||||
expect(secondData).toEqual(['a valid value'])
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling and backoff', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should implement exponential backoff on errors', async () => {
|
||||
mockAxiosError('Network error')
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + 500)
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
|
||||
|
||||
vi.setSystemTime(Date.now() + 3000)
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
expect(entry1?.data).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reset error state on successful fetch', async () => {
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
const firstData = await getResolvedValue(hook)
|
||||
expect(firstData).toBe('Loading...')
|
||||
|
||||
vi.setSystemTime(Date.now() + 3000)
|
||||
mockAxiosResponse(['option1'])
|
||||
const secondData = await getResolvedValue(hook)
|
||||
expect(secondData).toEqual(['option1'])
|
||||
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error).toBeNull()
|
||||
expect(entry?.retryCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should save successful data after backoff', async () => {
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
vi.setSystemTime(Date.now() + 3000)
|
||||
mockAxiosResponse(['success after backoff'])
|
||||
const secondData = await getResolvedValue(hook)
|
||||
expect(secondData).toEqual(['success after backoff'])
|
||||
|
||||
const entry2 = hook.getCacheEntry()
|
||||
expect(entry2?.error).toBeNull()
|
||||
expect(entry2?.retryCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should save successful data after multiple backoffs', async () => {
|
||||
mockAxiosError('Network error')
|
||||
mockAxiosError('Network error')
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
vi.setSystemTime(Date.now() + 3000)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
expect(secondData).toBe('Loading...')
|
||||
expect(entry1?.error).toBeDefined()
|
||||
|
||||
vi.setSystemTime(Date.now() + 9000)
|
||||
const thirdData = await getResolvedValue(hook)
|
||||
expect(thirdData).toBe('Loading...')
|
||||
expect(entry1?.error).toBeDefined()
|
||||
|
||||
vi.setSystemTime(Date.now() + 120_000)
|
||||
mockAxiosResponse(['success after multiple backoffs'])
|
||||
const fourthData = await getResolvedValue(hook)
|
||||
expect(fourthData).toEqual(['success after multiple backoffs'])
|
||||
|
||||
const entry2 = hook.getCacheEntry()
|
||||
expect(entry2?.error).toBeNull()
|
||||
expect(entry2?.retryCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache management', () => {
|
||||
it('should clear cache entries', async () => {
|
||||
const { hook } = await setupHookWithResponse(['to be cleared'])
|
||||
expect(hook.getCachedValue()).toBeDefined()
|
||||
|
||||
hook.refreshValue()
|
||||
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
|
||||
})
|
||||
|
||||
it('should prevent duplicate in-flight requests', async () => {
|
||||
const mockData = ['non-duplicate']
|
||||
mockAxiosResponse(mockData)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
|
||||
// Start two concurrent getValue calls
|
||||
const promise1 = new Promise<void>((resolve) => {
|
||||
hook.getValue(() => resolve())
|
||||
})
|
||||
const promise2 = new Promise<void>((resolve) => {
|
||||
hook.getValue(() => resolve())
|
||||
})
|
||||
|
||||
// Wait for both e
|
||||
await Promise.all([promise1, promise2])
|
||||
|
||||
// Both should see the same cached data
|
||||
expect(hook.getCachedValue()).toEqual(mockData)
|
||||
// Only one axios call should have been made
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent access and multiple instances', () => {
|
||||
it('should handle concurrent hook instances with same route', async () => {
|
||||
mockAxiosResponse(['shared data'])
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
|
||||
// Since they have the same route, only one request will be made
|
||||
await Promise.race([getResolvedValue(hook1), getResolvedValue(hook2)])
|
||||
|
||||
const data1 = hook1.getValue()
|
||||
const data2 = hook2.getValue()
|
||||
|
||||
expect(data1).toEqual(['shared data'])
|
||||
expect(data2).toEqual(['shared data'])
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
|
||||
})
|
||||
|
||||
it('should use shared cache across multiple hooks', async () => {
|
||||
mockAxiosResponse(['shared data'])
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
const hook3 = useRemoteWidget(options)
|
||||
const hook4 = useRemoteWidget(options)
|
||||
|
||||
const data1 = await getResolvedValue(hook1)
|
||||
const data2 = await getResolvedValue(hook2)
|
||||
const data3 = await getResolvedValue(hook3)
|
||||
const data4 = await getResolvedValue(hook4)
|
||||
|
||||
expect(data1).toEqual(['shared data'])
|
||||
expect(data2).toBe(data1)
|
||||
expect(data3).toBe(data1)
|
||||
expect(data4).toBe(data1)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
|
||||
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
|
||||
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
|
||||
})
|
||||
|
||||
it('should handle rapid cache clearing during fetch', async () => {
|
||||
let resolvePromise: (value: { data: unknown; status?: number }) => void
|
||||
const delayedPromise = new Promise<{ data: unknown; status?: number }>(
|
||||
(resolve) => {
|
||||
resolvePromise = resolve
|
||||
}
|
||||
)
|
||||
|
||||
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
hook.getValue()
|
||||
hook.refreshValue()
|
||||
|
||||
resolvePromise!({ data: ['delayed data'] })
|
||||
const data = await getResolvedValue(hook)
|
||||
|
||||
// The value should be the default value because the refreshValue
|
||||
// clears the cache and the fetch is aborted
|
||||
expect(data).toEqual(DEFAULT_VALUE)
|
||||
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
|
||||
})
|
||||
|
||||
it('should handle widget destroyed during fetch', async () => {
|
||||
let resolvePromise: (value: { data: unknown; status?: number }) => void
|
||||
const delayedPromise = new Promise<{ data: unknown; status?: number }>(
|
||||
(resolve) => {
|
||||
resolvePromise = resolve
|
||||
}
|
||||
)
|
||||
|
||||
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
|
||||
|
||||
let hook: ReturnType<typeof useRemoteWidget> | null =
|
||||
useRemoteWidget(createMockOptions())
|
||||
const fetchPromise = hook.getValue()
|
||||
|
||||
hook = null
|
||||
|
||||
resolvePromise!({ data: ['delayed data'] })
|
||||
await fetchPromise
|
||||
|
||||
expect(hook).toBeNull()
|
||||
hook = useRemoteWidget(createMockOptions())
|
||||
|
||||
const data2 = await getResolvedValue(hook)
|
||||
expect(data2).toEqual(DEFAULT_VALUE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud distribution authentication', () => {
|
||||
describe('when distribution is cloud', () => {
|
||||
describe('when authenticated', () => {
|
||||
it('passes Firebase authentication token in request headers', async () => {
|
||||
const mockData = ['authenticated data']
|
||||
mockCloudAuth.authHeader = null
|
||||
mockCloudAuth.isCloud = true
|
||||
mockCloudAuth.authHeader = { Authorization: 'Bearer test-token' }
|
||||
mockAxiosResponse(mockData)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer test-token' }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when distribution is not cloud', () => {
|
||||
it('bypasses authentication for non-cloud environments', async () => {
|
||||
const mockData = ['non-cloud data']
|
||||
mockCloudAuth.isCloud = false
|
||||
mockAxiosResponse(mockData)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
|
||||
const axiosCall = vi.mocked(axios.get).mock.calls[0][1]
|
||||
expect(axiosCall).not.toHaveProperty('headers')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-refresh on task completion', () => {
|
||||
it('should add auto-refresh toggle widget', () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
})
|
||||
const mockWidget = createMockWidget({
|
||||
refresh: vi.fn()
|
||||
})
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Should add auto-refresh toggle widget
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
expect.any(Function),
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should register event listener when enabled', async () => {
|
||||
const addEventListenerSpy = vi.spyOn(api, 'addEventListener')
|
||||
|
||||
const mockNode = createMockLGraphNode({
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
})
|
||||
const mockWidget = createMockWidget({
|
||||
refresh: vi.fn()
|
||||
})
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Event listener should be registered immediately
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should refresh widget when workflow completes successfully', async () => {
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = createMockLGraphNode({
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
})
|
||||
const mockWidget = createMockWidget({})
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Get the toggle callback and enable auto-refresh
|
||||
const addWidgetMock = mockNode.addWidget as ReturnType<typeof vi.fn>
|
||||
const toggleCallback = addWidgetMock.mock.calls.find(
|
||||
(call: unknown[]) => call[0] === 'toggle'
|
||||
)?.[3]
|
||||
toggleCallback?.(true)
|
||||
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not refresh when toggle is disabled', async () => {
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = createMockLGraphNode({
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
})
|
||||
const mockWidget = createMockWidget({})
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Toggle is disabled by default
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should cleanup event listener on node removal', async () => {
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(api, 'removeEventListener')
|
||||
|
||||
const mockNode = createMockLGraphNode({
|
||||
addWidget: vi.fn(),
|
||||
widgets: [],
|
||||
onRemoved: undefined
|
||||
})
|
||||
const mockWidget = createMockWidget({
|
||||
refresh: vi.fn()
|
||||
})
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Simulate node removal
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
executionSuccessHandler
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,26 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { isRetriableError } from '@/base/remote/retry'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { getAppQueryClient } from '@/platform/remote/queryClient'
|
||||
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
|
||||
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T
|
||||
timestamp?: number
|
||||
error?: Error | null
|
||||
fetchPromise?: Promise<T>
|
||||
controller?: AbortController
|
||||
lastErrorTime?: number
|
||||
retryCount?: number
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
async function getAuthHeaders() {
|
||||
if (isCloud) {
|
||||
const authStore = useAuthStore()
|
||||
@@ -26,32 +32,57 @@ async function getAuthHeaders() {
|
||||
return {}
|
||||
}
|
||||
|
||||
const createDescriptor = (
|
||||
config: RemoteWidgetConfig
|
||||
): RemoteRequestDescriptor => ({
|
||||
client: 'comfyApi',
|
||||
route: config.route,
|
||||
params: config.query_params,
|
||||
responseKey: config.response_key,
|
||||
ttl: config.refresh,
|
||||
timeout: config.timeout ?? TIMEOUT,
|
||||
maxRetries: config.max_retries ?? MAX_RETRIES
|
||||
})
|
||||
const dataCache = new Map<string, CacheEntry<unknown>>()
|
||||
|
||||
const createCacheKey = (config: RemoteWidgetConfig): string => {
|
||||
const { route, query_params = {}, refresh = 0 } = config
|
||||
|
||||
const paramsKey = Object.entries(query_params)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&')
|
||||
|
||||
return [route, `r=${refresh}`, paramsKey].join(';')
|
||||
}
|
||||
|
||||
const getBackoff = (retryCount: number) =>
|
||||
Math.min(1000 * Math.pow(2, retryCount), 512)
|
||||
|
||||
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.data !== undefined &&
|
||||
entry?.timestamp !== undefined &&
|
||||
entry.timestamp > 0
|
||||
|
||||
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
|
||||
entry?.timestamp && Date.now() - entry.timestamp >= ttl
|
||||
|
||||
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.fetchPromise !== undefined
|
||||
|
||||
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.failed === true
|
||||
|
||||
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.error &&
|
||||
entry?.lastErrorTime &&
|
||||
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
|
||||
|
||||
const fetchData = async (
|
||||
config: RemoteWidgetConfig,
|
||||
controller: AbortController
|
||||
) => {
|
||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||
|
||||
async function fetchRemoteWidgetData(
|
||||
descriptor: RemoteRequestDescriptor,
|
||||
signal: AbortSignal
|
||||
): Promise<unknown> {
|
||||
const authHeaders = await getAuthHeaders()
|
||||
const res = await axios.get(descriptor.route, {
|
||||
params: descriptor.params,
|
||||
signal,
|
||||
timeout: descriptor.timeout,
|
||||
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
timeout,
|
||||
...authHeaders
|
||||
})
|
||||
return descriptor.responseKey
|
||||
? (res.data as Record<string, unknown>)[descriptor.responseKey]
|
||||
: res.data
|
||||
|
||||
return response_key ? res.data[response_key] : res.data
|
||||
}
|
||||
|
||||
export function useRemoteWidget<
|
||||
@@ -63,39 +94,42 @@ export function useRemoteWidget<
|
||||
widget: IWidget
|
||||
}) {
|
||||
const { remoteConfig, defaultValue, node, widget } = options
|
||||
const descriptor = createDescriptor(remoteConfig)
|
||||
const queryClient = getAppQueryClient()
|
||||
const getQueryKey = () =>
|
||||
remoteOptionKeys.byRoute(descriptor, {
|
||||
userId: useAuthStore().userId ?? null,
|
||||
workspaceId: null,
|
||||
apiKeyBucket: useApiKeyAuthStore().getApiKey() ? 'apikey' : 'anon'
|
||||
})
|
||||
|
||||
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
|
||||
const isPermanent = refresh <= 0
|
||||
const cacheKey = createCacheKey(remoteConfig)
|
||||
let isLoaded = false
|
||||
let refreshQueued = false
|
||||
let cachedValue: T | undefined
|
||||
|
||||
const fetchValue = async (): Promise<T> => {
|
||||
try {
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: getQueryKey(),
|
||||
queryFn: ({ signal }) => fetchRemoteWidgetData(descriptor, signal),
|
||||
staleTime: remoteConfig.refresh,
|
||||
retry: (failureCount, error) =>
|
||||
failureCount < (remoteConfig.max_retries ?? MAX_RETRIES) &&
|
||||
isRetriableError(error)
|
||||
})
|
||||
cachedValue = (data ?? defaultValue) as T
|
||||
return cachedValue
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.warn('Remote widget fetch failed:', message)
|
||||
cachedValue = (cachedValue ?? defaultValue) as T
|
||||
return cachedValue
|
||||
const setSuccess = (entry: CacheEntry<T>, data: T) => {
|
||||
entry.retryCount = 0
|
||||
entry.lastErrorTime = 0
|
||||
entry.error = null
|
||||
entry.timestamp = Date.now()
|
||||
entry.data = data ?? defaultValue
|
||||
}
|
||||
|
||||
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
|
||||
entry.retryCount = (entry.retryCount || 0) + 1
|
||||
entry.lastErrorTime = Date.now()
|
||||
entry.error = error instanceof Error ? error : new Error(String(error))
|
||||
entry.data ??= defaultValue
|
||||
entry.fetchPromise = undefined
|
||||
if (entry.retryCount >= max_retries) {
|
||||
setFailed(entry)
|
||||
}
|
||||
}
|
||||
|
||||
const setFailed = (entry: CacheEntry<T>) => {
|
||||
dataCache.set(cacheKey, {
|
||||
data: entry.data ?? defaultValue,
|
||||
failed: true
|
||||
})
|
||||
}
|
||||
|
||||
const isFirstLoad = () => {
|
||||
return !isLoaded && isInitialized(dataCache.get(cacheKey))
|
||||
}
|
||||
|
||||
const onFirstLoad = (data: T | T[]) => {
|
||||
isLoaded = true
|
||||
const nextValue =
|
||||
@@ -105,37 +139,85 @@ export function useRemoteWidget<
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
const fetchValue = async () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
|
||||
if (isFailed(entry)) return entry!.data as T
|
||||
|
||||
const isValid =
|
||||
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
|
||||
if (isValid || isBackingOff(entry) || isFetching(entry))
|
||||
return entry!.data as T
|
||||
|
||||
const currentEntry: CacheEntry<T> = (entry as
|
||||
| CacheEntry<T>
|
||||
| undefined) || { data: defaultValue }
|
||||
dataCache.set(cacheKey, currentEntry)
|
||||
|
||||
try {
|
||||
currentEntry.controller = new AbortController()
|
||||
currentEntry.fetchPromise = fetchData(
|
||||
remoteConfig,
|
||||
currentEntry.controller
|
||||
)
|
||||
const data = await currentEntry.fetchPromise
|
||||
|
||||
setSuccess(currentEntry, data)
|
||||
return currentEntry.data
|
||||
} catch (err) {
|
||||
setError(currentEntry, err)
|
||||
return currentEntry.data
|
||||
} finally {
|
||||
currentEntry.fetchPromise = undefined
|
||||
currentEntry.controller = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
if (!remoteConfig.control_after_refresh) return
|
||||
const data = cachedValue
|
||||
if (!Array.isArray(data)) return
|
||||
if (remoteConfig.control_after_refresh) {
|
||||
const data = getCachedValue()
|
||||
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
|
||||
|
||||
switch (remoteConfig.control_after_refresh) {
|
||||
case 'first':
|
||||
widget.value = data[0] ?? defaultValue
|
||||
break
|
||||
case 'last':
|
||||
widget.value = data.at(-1) ?? defaultValue
|
||||
break
|
||||
switch (remoteConfig.control_after_refresh) {
|
||||
case 'first':
|
||||
widget.value = data[0] ?? defaultValue
|
||||
break
|
||||
case 'last':
|
||||
widget.value = data.at(-1) ?? defaultValue
|
||||
break
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
function getCachedValue(): T {
|
||||
if (cachedValue !== undefined) return cachedValue
|
||||
const fromQuery = queryClient.getQueryData<T>(getQueryKey())
|
||||
if (fromQuery !== undefined) {
|
||||
cachedValue = fromQuery
|
||||
return fromQuery
|
||||
}
|
||||
return defaultValue
|
||||
/**
|
||||
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
|
||||
*/
|
||||
const clearCachedValue = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
if (!entry) return
|
||||
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
|
||||
dataCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached value of the widget without starting a new fetch.
|
||||
* @returns the most recently computed value of the widget.
|
||||
*/
|
||||
function getCachedValue() {
|
||||
return dataCache.get(cacheKey)?.data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
|
||||
* Starts the fetch process then returns the cached value immediately.
|
||||
* @returns the most recent value of the widget.
|
||||
*/
|
||||
function getValue(onFulfilled?: () => void) {
|
||||
void fetchValue()
|
||||
.then((data) => {
|
||||
if (!isLoaded) onFirstLoad(data)
|
||||
if (isFirstLoad()) onFirstLoad(data)
|
||||
if (refreshQueued && data !== defaultValue) {
|
||||
onRefresh()
|
||||
refreshQueued = false
|
||||
@@ -148,26 +230,36 @@ export function useRemoteWidget<
|
||||
return getCachedValue() ?? defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the widget to refresh its value
|
||||
*/
|
||||
widget.refresh = function () {
|
||||
refreshQueued = true
|
||||
void queryClient.invalidateQueries({ queryKey: getQueryKey() }).then(() => {
|
||||
getValue()
|
||||
})
|
||||
clearCachedValue()
|
||||
getValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a refresh button to the node that, when clicked, will force the widget to refresh
|
||||
*/
|
||||
function addRefreshButton() {
|
||||
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add auto-refresh toggle widget and execution success listener
|
||||
*/
|
||||
function addAutoRefreshToggle() {
|
||||
let autoRefreshEnabled = false
|
||||
|
||||
// Handler for execution success
|
||||
const handleExecutionSuccess = () => {
|
||||
if (autoRefreshEnabled && widget.refresh) {
|
||||
widget.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Add toggle widget
|
||||
const autoRefreshWidget = node.addWidget(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
@@ -180,8 +272,10 @@ export function useRemoteWidget<
|
||||
}
|
||||
)
|
||||
|
||||
// Register event listener
|
||||
api.addEventListener('execution_success', handleExecutionSuccess)
|
||||
|
||||
// Cleanup on node removal
|
||||
node.onRemoved = useChainCallback(node.onRemoved, function () {
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess)
|
||||
})
|
||||
@@ -189,6 +283,7 @@ export function useRemoteWidget<
|
||||
return autoRefreshWidget
|
||||
}
|
||||
|
||||
// Always add auto-refresh toggle for remote widgets
|
||||
addAutoRefreshToggle()
|
||||
|
||||
return {
|
||||
@@ -196,6 +291,8 @@ export function useRemoteWidget<
|
||||
getValue,
|
||||
refreshValue: widget.refresh,
|
||||
addRefreshButton,
|
||||
getQueryKey
|
||||
getCacheEntry: () => dataCache.get(cacheKey),
|
||||
|
||||
cacheKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { zComboInputOptionsValidated } from '@/schemas/nodeDefSchema'
|
||||
|
||||
describe('zComboInputOptionsValidated XOR enforcement', () => {
|
||||
const remote = {
|
||||
route: '/legacy'
|
||||
}
|
||||
const remote_combo = {
|
||||
route: '/rich',
|
||||
item_schema: { value_field: 'id', label_field: 'name' }
|
||||
}
|
||||
|
||||
it('accepts options without remote or remote_combo', () => {
|
||||
const result = zComboInputOptionsValidated.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts options with only remote', () => {
|
||||
const result = zComboInputOptionsValidated.safeParse({ remote })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts options with only remote_combo', () => {
|
||||
const result = zComboInputOptionsValidated.safeParse({ remote_combo })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects options with both remote and remote_combo', () => {
|
||||
const result = zComboInputOptionsValidated.safeParse({
|
||||
remote,
|
||||
remote_combo
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toContain(
|
||||
'Combo input cannot specify both'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,11 +5,6 @@ import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
|
||||
/**
|
||||
* Plain remote combo config — feeds a standard combo dropdown from a remote endpoint.
|
||||
* Handled by `useRemoteWidget` + `WidgetSelectDropdown`.
|
||||
*/
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
@@ -20,32 +15,6 @@ const zRemoteWidgetConfig = z.object({
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
|
||||
const zRemoteItemSchema = z.object({
|
||||
value_field: z.string(),
|
||||
label_field: z.string(),
|
||||
preview_url_field: z.string().optional(),
|
||||
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
|
||||
description_field: z.string().optional(),
|
||||
search_fields: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Rich remote combo config — feeds `RichComboWidget` with item previews, search, and filtering.
|
||||
* Requires `item_schema`. Vue-nodes only. Routes are always relative paths and resolve against
|
||||
* the comfy-api base URL with auth headers injected. The endpoint returns the full items array
|
||||
* in a single response.
|
||||
*/
|
||||
const zRemoteComboConfig = z.object({
|
||||
route: z.string().startsWith('/'),
|
||||
item_schema: zRemoteItemSchema,
|
||||
refresh_button: z.boolean().optional(),
|
||||
auto_select: z.enum(['first', 'last']).optional(),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
const zMultiSelectOption = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
chip: z.boolean().optional()
|
||||
@@ -127,20 +96,10 @@ export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
animated_image_upload: z.boolean().optional(),
|
||||
options: z.array(zComboOption).optional(),
|
||||
remote: zRemoteWidgetConfig.optional(),
|
||||
remote_combo: zRemoteComboConfig.optional(),
|
||||
/** Whether the widget is a multi-select widget. */
|
||||
multi_select: zMultiSelectOption.optional()
|
||||
})
|
||||
|
||||
export const zComboInputOptionsValidated = zComboInputOptions.refine(
|
||||
(opts) => !(opts.remote && opts.remote_combo),
|
||||
{
|
||||
message:
|
||||
'Combo input cannot specify both `remote` and `remote_combo`; pick one.',
|
||||
path: ['remote_combo']
|
||||
}
|
||||
)
|
||||
|
||||
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
|
||||
const zFloatInputSpec = z.tuple([
|
||||
z.literal('FLOAT'),
|
||||
@@ -393,9 +352,7 @@ export const zMatchTypeOptions = z.object({
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
export type RemoteComboConfig = z.infer<typeof zRemoteComboConfig>
|
||||
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
|
||||
|
||||
@@ -71,66 +71,4 @@ describe('validateNodeDef', () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
describe('remote_combo route validation', () => {
|
||||
const buildNodeDef = (remoteCombo: object): unknown => ({
|
||||
...EXAMPLE_NODE_DEF,
|
||||
input: {
|
||||
required: {
|
||||
voice: ['COMBO', { remote_combo: remoteCombo }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const baseRemoteCombo = {
|
||||
item_schema: { value_field: 'id', label_field: 'name' }
|
||||
}
|
||||
|
||||
it('accepts a relative route', () => {
|
||||
expect(
|
||||
validateComfyNodeDef(
|
||||
buildNodeDef({
|
||||
...baseRemoteCombo,
|
||||
route: '/voices'
|
||||
})
|
||||
)
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an absolute http URL', () => {
|
||||
expect(
|
||||
validateComfyNodeDef(
|
||||
buildNodeDef({
|
||||
...baseRemoteCombo,
|
||||
route: 'http://api.example.com/voices'
|
||||
}),
|
||||
() => {}
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an absolute https URL', () => {
|
||||
expect(
|
||||
validateComfyNodeDef(
|
||||
buildNodeDef({
|
||||
...baseRemoteCombo,
|
||||
route: 'https://api.example.com/voices'
|
||||
}),
|
||||
() => {}
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a route with no leading slash', () => {
|
||||
expect(
|
||||
validateComfyNodeDef(
|
||||
buildNodeDef({
|
||||
...baseRemoteCombo,
|
||||
route: 'voices'
|
||||
}),
|
||||
() => {}
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user