Compare commits

..

3 Commits

Author SHA1 Message Date
Alexander Brown
b212563a9b Merge branch 'main' into feat/website-prefetch 2026-04-24 11:46:49 -07:00
Alexander Brown
824910787a Merge branch 'main' into feat/website-prefetch 2026-04-23 23:32:31 -07:00
DrJKL
e3431d25ec feat: enable prefetch for website
Amp-Thread-ID: https://ampcode.com/threads/T-019dbdb1-a449-770b-acb2-58c0cc47ad32
Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 21:42:47 -07:00
115 changed files with 645 additions and 10983 deletions

View File

@@ -46,9 +46,3 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
# Ashby (apps/website careers page build).
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
# WEBSITE_ASHBY_API_KEY=
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org

View File

@@ -2,7 +2,6 @@ dist/
.astro/
test-results/
playwright-report/
results.json
# Platform-specific Playwright snapshots (CI runs Linux)
*-win32.png

View File

@@ -1,123 +0,0 @@
# @comfyorg/website
Marketing/brand website built with Astro + Vue.
## Ashby careers integration
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
API at build time. Data flow:
1. `src/pages/careers.astro` awaits `fetchRolesForBuild()` during the
Astro build.
2. `src/utils/ashby.ts` calls
`GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false`,
validates the envelope and each posting with Zod
(`src/utils/ashby.schema.ts`), and maps to the domain type in
`src/data/roles.ts`.
3. On any failure (network, HTTP 4xx/5xx, envelope schema drift),
the fetcher falls back to the committed JSON snapshot at
`src/data/ashby-roles.snapshot.json`.
4. `src/utils/ashby.ci.ts` emits GitHub Actions annotations and a
`$GITHUB_STEP_SUMMARY` block so stale fetches are visible on green
builds.
### Required environment variables
Both are build-time only. Never prefix with `PUBLIC_` (Astro would
inline that into the client bundle).
| Name | Purpose | Default (when unset) |
| ------------------------------ | --------------------------- | --------------------------------- |
| `WEBSITE_ASHBY_API_KEY` | Ashby API key (Basic auth) | Build uses the committed snapshot |
| `WEBSITE_ASHBY_JOB_BOARD_NAME` | Ashby public job board slug | Build uses the committed snapshot |
### CI wiring (manual step — required)
This repo's `.github/workflows/*.yaml` changes cannot be pushed by a
GitHub App. A maintainer must apply the following edits **once**:
**`.github/workflows/ci-website-build.yaml`** — pass the env into the
build step and run the unit tests before it:
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run website unit tests
run: pnpm --filter @comfyorg/website test:unit
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
run: pnpm --filter @comfyorg/website build
- name: Verify API key is not leaked into build output
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
run: |
set +x
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
echo "Secret not available in this run; skipping leak check."
exit 0
fi
# grep -rlF prints only file paths (never match content).
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
| tr '\0' '\n' || true)
if [ -n "$MATCHES" ]; then
echo "::error title=Ashby API key leaked into build output::$MATCHES"
exit 1
fi
```
**`.github/workflows/ci-vercel-website-preview.yaml`** — add the
two env vars to the top-level `env:` block so `vercel build` (both
`deploy-preview` and `deploy-production` jobs) sees them:
```yaml
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
```
The secret must also be added to the Vercel project environment
(`vercel env add WEBSITE_ASHBY_API_KEY …` or via the Vercel UI) so
that `vercel build` in the preview job has access to it.
Fork PRs do not exercise this path: `ci-vercel-website-preview.yaml`
receives an empty `VERCEL_TOKEN` for forks and fails at `vercel pull`
before the build runs. Fork-safe PR interactions (the preview-URL
comment) are handled by `pr-vercel-website-preview.yaml`.
### Refreshing the snapshot
When a maintainer wants to update the committed snapshot (e.g. after
onboarding/offboarding roles):
```bash
WEBSITE_ASHBY_API_KEY=WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
pnpm --filter @comfyorg/website ashby:refresh-snapshot
git commit apps/website/src/data/ashby-roles.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## Scripts
- `pnpm dev` — Astro dev server
- `pnpm build` — production build to `dist/`
- `pnpm typecheck``astro check`
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot

View File

@@ -7,12 +7,6 @@ export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
},
build: {
assets: '_website'
},

View File

@@ -1,57 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Careers page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/careers')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers — Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Roles', level: 2 })
).toBeVisible()
})
test('renders at least one role from the snapshot', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
await expect(roles.first()).toBeVisible()
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 — Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()
await expect(page.getByTestId('careers-role-link').first()).toBeVisible()
})
})

View File

@@ -9,13 +9,10 @@
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
"test:visual:update": "playwright test --project visual --update-snapshots"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
@@ -26,8 +23,7 @@
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
"vue": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
@@ -36,9 +32,7 @@
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
"typescript": "catalog:"
},
"nx": {
"tags": [
@@ -95,22 +89,6 @@
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [

View File

@@ -1,33 +0,0 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchRolesForBuild } from '../src/utils/ashby'
const snapshotPath = fileURLToPath(
new URL('../src/data/ashby-roles.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchRolesForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
const totalRoles = outcome.snapshot.departments.reduce(
(n, d) => n + d.roles.length,
0
)
process.stdout.write(
`Wrote snapshot with ${totalRoles} role(s) to ${snapshotPath}\n`
)

View File

@@ -1,42 +1,121 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
const { locale = 'en', departments = [] } = defineProps<{
locale?: Locale
departments?: readonly Department[]
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeCategory = ref('all')
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
interface Role {
title: string
department: string
location: string
id: string
}
interface Department {
name: string
key: string
roles: Role[]
}
const departments: Department[] = [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
id: 'abc787b9-ad85-421c-8218-debd23bea096'
},
{
title: 'Software Engineer',
department: 'Engineering',
location: 'San Francisco',
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
},
{
title: 'Product Manager',
department: 'Engineering',
location: 'London, UK',
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
},
{
title: 'Tech Lead Manager, Frontend',
department: 'Engineering',
location: 'San Francisco',
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
}
]
},
{
name: 'DESIGN',
key: 'design',
roles: [
{
title: 'Creative Director',
department: 'Design',
location: 'San Francisco',
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
},
{
title: 'Graphic Designer',
department: 'Design',
location: 'London, UK',
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
},
{
title: 'Freelance Motion Designer',
department: 'Design',
location: 'Remote',
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
}
]
},
{
name: 'MARKETING',
key: 'marketing',
roles: [
{
title: 'Lifecycle Growth Marketer',
department: 'Marketing',
location: 'San Francisco',
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
},
{
title: 'Graphic Designer',
department: 'Marketing',
location: 'London, UK',
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
}
]
}
]
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
...departments.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
? departments
: departments.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
</script>
<template>
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
<section class="px-6 py-20 md:px-20 md:py-32">
<div class="mx-auto max-w-6xl">
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
<!-- Left sidebar -->
<div class="shrink-0 md:w-48">
<div
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
@@ -47,7 +126,6 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
{{ t('careers.roles.heading', locale) }}
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
class="mt-4"
@@ -55,15 +133,8 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
</div>
</div>
<!-- Role listings -->
<div class="min-w-0 flex-1">
<p
v-if="!hasRoles"
class="text-primary-warm-gray text-base md:text-lg"
data-testid="careers-roles-empty"
>
{{ t('careers.roles.empty', locale) }}
</p>
<div
v-for="dept in filteredDepartments"
:key="dept.key"
@@ -76,11 +147,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
<a
v-for="role in dept.roles"
:key="role.id"
:href="role.applyUrl"
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
data-testid="careers-role-link"
>
<div class="min-w-0">
<span

View File

@@ -13,13 +13,13 @@ const steps = [
number: '01',
titleKey: 'api.steps.step1.title' as const,
descriptionKey: 'api.steps.step1.description' as const,
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
image: 'https://media.comfy.org/website/api/logo-purple.webp'
},
{
number: '02',
titleKey: 'api.steps.step2.title' as const,
descriptionKey: 'api.steps.step2.description' as const,
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
},
{
number: '03',

View File

@@ -10,12 +10,12 @@ const cards = [
{
titleKey: 'enterprise.byoKey.card1.title' as const,
descriptionKey: 'enterprise.byoKey.card1.description' as const,
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
image: 'https://media.comfy.org/website/api/logo-purple.webp'
},
{
titleKey: 'enterprise.byoKey.card2.title' as const,
descriptionKey: 'enterprise.byoKey.card2.description' as const,
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
}
]
</script>

View File

@@ -16,11 +16,9 @@ const midRightRef = ref<HTMLElement>()
const bottomLeftRef = ref<HTMLElement>()
const bottomRightRef = ref<HTMLElement>()
const parallaxOpts = { trigger: sectionRef, mediaQuery: '(min-width: 1024px)' }
useParallax([topLeftRef, topRightRef], { ...parallaxOpts, y: 200 })
useParallax([midLeftRef, midRightRef], { ...parallaxOpts, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { ...parallaxOpts, y: 400 })
useParallax([topLeftRef, topRightRef], { trigger: sectionRef, y: 200 })
useParallax([midLeftRef, midRightRef], { trigger: sectionRef, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { trigger: sectionRef, y: 400 })
</script>
<template>

View File

@@ -19,8 +19,6 @@ interface ParallaxOptions {
start?: string
/** ScrollTrigger end value (default: 'bottom top') */
end?: string
/** Media query string — animation only runs when matched (responsive) */
mediaQuery?: string
}
export function useParallax(
@@ -28,27 +26,24 @@ export function useParallax(
options: ParallaxOptions = {}
) {
const { fromY = 0, y = 200 } = options
let ctx: gsap.Context | gsap.MatchMedia | undefined
let ctx: gsap.Context | undefined
onMounted(() => {
if (prefersReducedMotion()) return
const triggerEl = options.trigger?.value
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length || prefersReducedMotion()) return
const createAnimations = () => {
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length) return
const trigger = triggerEl ?? els[0]
const scrollTrigger = {
trigger,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
}
const trigger = triggerEl ?? els[0]
const scrollTrigger = {
trigger,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
}
ctx = gsap.context(() => {
els.forEach((el) => {
gsap.fromTo(
el,
@@ -56,15 +51,7 @@ export function useParallax(
{ y: resolve(y, el, trigger), ease: 'none', scrollTrigger }
)
})
}
if (options.mediaQuery) {
const mm = gsap.matchMedia()
mm.add(options.mediaQuery, createAnimations)
ctx = mm
} else {
ctx = gsap.context(createAnimations)
}
})
})
onUnmounted(() => {

View File

@@ -1,169 +0,0 @@
{
"fetchedAt": "2026-04-24T18:59:03.989Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "e915f2c78b17f93b",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
},
{
"id": "b9f9a23219be7cd4",
"title": "Design Engineer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "547b6ba622c800a5",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
},
{
"id": "7bb02634a24763bc",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
}
]
},
{
"name": "ENGINEERING",
"key": "engineering",
"roles": [
{
"id": "102d58e35a8a9817",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
},
{
"id": "d01d69fba7743905",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
},
{
"id": "f36f60cfd5bb5910",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
},
{
"id": "9d8ec4c65e20b19e",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
},
{
"id": "be94b193d1f4d482",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
},
{
"id": "ab48f5db6bd1783c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
},
{
"id": "c5dff4ee628bdcd1",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
},
{
"id": "4302a7aaa87e16e3",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
}
]
},
{
"name": "MARKETING",
"key": "marketing",
"roles": [
{
"id": "b5803a0d4785d406",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
},
{
"id": "130d7218d7895bdb",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
}
]
},
{
"name": "OPERATIONS",
"key": "operations",
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Senior Technical Recruiter",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
},
{
"id": "16f556001ce1cef4",
"title": "BizOps Strategist",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
},
{
"id": "8e773a72c1b8e099",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
export interface Role {
id: string
title: string
department: string
location: string
applyUrl: string
}
export interface Department {
name: string
key: string
roles: Role[]
}
export interface RolesSnapshot {
fetchedAt: string
departments: Department[]
}

View File

@@ -1505,10 +1505,6 @@ const translations = {
// CareersRolesSection
'careers.roles.heading': { en: 'Roles', 'zh-CN': '职位' },
'careers.roles.empty': {
en: 'No open roles right now. Check back soon.',
'zh-CN': '目前暂无开放职位,请稍后再来查看。'
},
// CareersFAQSection
'careers.faq.heading': { en: 'Q&A', 'zh-CN': 'Q&A' },

View File

@@ -5,20 +5,6 @@ import RolesSection from '../components/careers/RolesSection.vue'
import WhyJoinSection from '../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -26,7 +12,7 @@ const departments = outcome.snapshot.departments
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
>
<HeroSection />
<RolesSection departments={departments} client:visible />
<RolesSection client:visible />
<WhyJoinSection client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -5,20 +5,6 @@ import RolesSection from '../../components/careers/RolesSection.vue'
import WhyJoinSection from '../../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -26,7 +12,7 @@ const departments = outcome.snapshot.departments
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" departments={departments} client:visible />
<RolesSection locale="zh-CN" client:visible />
<WhyJoinSection locale="zh-CN" client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -1,130 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './ashby'
import type { RolesSnapshot } from '../data/roles'
import { reportAshbyOutcome, resetAshbyReporterForTests } from './ashby.ci'
function baseSnapshot(): RolesSnapshot {
return {
fetchedAt: new Date().toISOString(),
departments: [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
id: 'x',
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
]
}
}
function freshOutcome(droppedCount = 0): FetchOutcome {
return {
status: 'fresh',
droppedCount,
droppedRoles:
droppedCount === 0
? []
: [{ title: 'Bad Role', reason: 'jobUrl: Invalid url' }],
snapshot: {
fetchedAt: new Date().toISOString(),
departments: [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
id: 'x',
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
]
}
}
}
describe('reportAshbyOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetAshbyReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'ashby-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a clean fresh outcome', () => {
reportAshbyOutcome(freshOutcome(0))
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one set of annotations across repeated calls', () => {
reportAshbyOutcome(freshOutcome(1))
reportAshbyOutcome(freshOutcome(1))
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby: dropped 1 invalid')
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
})
it('emits ::error for auth failures in a stale outcome', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 401 Unauthorized',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby authentication failed')
})
it('emits ::warning for missing-env stale outcomes', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby integration')
})
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
reportAshbyOutcome({ status: 'failed', reason: 'HTTP 500 Server Error' })
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportAshbyOutcome(freshOutcome(0))).not.toThrow()
})
})

View File

@@ -1,113 +0,0 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './ashby'
let hasReported = false
export function resetAshbyReporterForTests(): void {
hasReported = false
}
export function reportAshbyOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
const drops = outcome.droppedRoles
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
.join('%0A')
return [
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
]
}
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Ashby fetch failed and no snapshot is available::Cannot build careers page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website ashby:refresh-snapshot\` locally with a valid WEBSITE_ASHBY_API_KEY.%0A 2. Commit apps/website/src/data/ashby-roles.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('missing ')) {
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
}
if (reason.startsWith('envelope')) {
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
}
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## 💼 Careers (Ashby)\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from Ashby)'])
rows.push([
'Roles',
String(
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
)
])
rows.push(['Dropped', String(outcome.droppedCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — Ashby fetch failed)'])
rows.push([
'Roles',
String(
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
)
])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -1,17 +0,0 @@
import { z } from 'zod'
export const AshbyJobPostingSchema = z.object({
title: z.string().min(1),
department: z.string().optional(),
location: z.string().optional(),
isListed: z.boolean(),
jobUrl: z.string().url(),
applyUrl: z.string().url().optional()
})
export const AshbyJobBoardResponseSchema = z.object({
apiVersion: z.literal('1'),
jobs: z.array(z.unknown())
})
export type AshbyJobPosting = z.infer<typeof AshbyJobPostingSchema>

View File

@@ -1,328 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AshbyJobPosting } from './ashby.schema'
import type { RolesSnapshot } from '../data/roles'
import { fetchRolesForBuild, resetAshbyFetcherForTests } from './ashby'
const BASE_URL = 'https://ashby.test'
const BOARD = 'comfy-org'
const KEY = 'abc-123-secret'
function validJob(overrides: Partial<AshbyJobPosting> = {}): unknown {
return {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
isListed: true,
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer/apply',
...overrides
}
}
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
const base: ResponseInit = {
status: 200,
headers: { 'content-type': 'application/json' }
}
return new Response(JSON.stringify(body), { ...base, ...init })
}
function makeSnapshot(roleCount = 2): RolesSnapshot {
const roles = Array.from({ length: roleCount }, (_, i) => ({
id: `snapshot-role-${i}`,
title: `Snapshot Role ${i}`,
department: 'Engineering',
location: 'San Francisco',
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
departments: [{ name: 'ENGINEERING', key: 'engineering', roles }]
}
}
function withSnapshotDir(snapshot: RolesSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'ashby-test-'))
const file = join(dir, 'ashby-roles.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchRolesForBuild', () => {
const savedApiKey = process.env.WEBSITE_ASHBY_API_KEY
const savedBoardName = process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
beforeEach(() => {
resetAshbyFetcherForTests()
delete process.env.WEBSITE_ASHBY_API_KEY
delete process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
})
afterEach(() => {
vi.restoreAllMocks()
process.env.WEBSITE_ASHBY_API_KEY = savedApiKey
process.env.WEBSITE_ASHBY_JOB_BOARD_NAME = savedBoardName
})
it('returns fresh when the API succeeds', async () => {
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(0)
expect(outcome.snapshot.departments).toHaveLength(1)
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
/design-engineer\/apply$/
)
})
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
const job = validJob()
delete (job as Record<string, unknown>).applyUrl
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
)
})
it('drops invalid roles individually and keeps the valid ones', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Bad Role', jobUrl: 'not-a-url' })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(1)
expect(outcome.droppedRoles[0]!.title).toBe('Bad Role')
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('renders an empty-but-fresh outcome when hiring is paused', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '1', jobs: [] }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments).toEqual([])
expect(outcome.droppedCount).toBe(0)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('normalizes missing department and location to safe defaults', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const job = validJob()
delete (job as Record<string, unknown>).department
delete (job as Record<string, unknown>).location
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const [department] = outcome.snapshot.departments
expect(department?.name).toBe('OTHER')
expect(department?.roles[0]?.location).toBe('Remote')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('filters out roles with isListed=false', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Hidden', isListed: false })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const titles = outcome.snapshot.departments.flatMap((d) =>
d.roles.map((r) => r.title)
)
expect(titles).not.toContain('Hidden')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns stale with missing env when the snapshot is present', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const fetchImpl = vi.fn()
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^missing /)
expect(fetchImpl).not.toHaveBeenCalled()
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when both env and snapshot are missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on HTTP 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('falls back to snapshot on envelope schema mismatch', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '2', jobs: [] }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^envelope schema/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const opts = {
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
}
const [a, b] = await Promise.all([
fetchRolesForBuild(opts),
fetchRolesForBuild(opts)
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('never writes to the snapshot file on success', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const before = new URL(snapshotUrl.href)
const fs = await import('node:fs')
const initial = fs.readFileSync(before).toString()
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
const after = fs.readFileSync(before).toString()
expect(after).toBe(initial)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on 4xx auth failures for 403', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 403 }))
await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
})

View File

@@ -1,299 +0,0 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import type { AshbyJobPosting } from './ashby.schema'
import type { Department, Role, RolesSnapshot } from '../data/roles'
import bundledSnapshot from '../data/ashby-roles.snapshot.json' with { type: 'json' }
import {
AshbyJobBoardResponseSchema,
AshbyJobPostingSchema
} from './ashby.schema'
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
title: string
reason: string
}
export type FetchOutcome =
| {
status: 'fresh'
snapshot: RolesSnapshot
droppedCount: number
droppedRoles: DroppedRole[]
}
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchRolesOptions {
apiKey?: string
boardName?: string
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
export function resetAshbyFetcherForTests(): void {
inflight = undefined
}
export function fetchRolesForBuild(
options: FetchRolesOptions = {}
): Promise<FetchOutcome> {
inflight ??= doFetchRolesForBuild(options)
return inflight
}
async function doFetchRolesForBuild(
options: FetchRolesOptions
): Promise<FetchOutcome> {
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
const boardName =
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
if (!apiKey || !boardName) {
return fallback(
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
options.snapshotUrl
)
}
const result = await tryFetchAndParse(apiKey, boardName, options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
departments: result.departments
},
droppedCount: result.droppedRoles.length,
droppedRoles: result.droppedRoles
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
departments: Department[]
droppedRoles: DroppedRole[]
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
apiKey: string,
boardName: string,
options: FetchRolesOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl}/posting-api/job-board/${encodeURIComponent(
boardName
)}?includeCompensation=false`
const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, authHeader, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const envelope = AshbyJobBoardResponseSchema.safeParse(response.body)
if (!envelope.success) {
return {
kind: 'err',
reason: `envelope schema validation failed: ${envelope.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
return parseRoles(envelope.data.jobs)
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
authHeader: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: {
Authorization: authHeader,
Accept: 'application/json; version=1'
},
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
function parseRoles(jobs: readonly unknown[]): FetchOk {
const valid: AshbyJobPosting[] = []
const droppedRoles: DroppedRole[] = []
for (const raw of jobs) {
const parsed = AshbyJobPostingSchema.safeParse(raw)
if (!parsed.success) {
droppedRoles.push({
title: extractTitle(raw),
reason: parsed.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')
})
continue
}
if (!parsed.data.isListed) continue
valid.push(parsed.data)
}
return { kind: 'ok', departments: groupByDepartment(valid), droppedRoles }
}
function extractTitle(raw: unknown): string {
if (
raw !== null &&
typeof raw === 'object' &&
'title' in raw &&
typeof (raw as { title: unknown }).title === 'string'
) {
return (raw as { title: string }).title
}
return ''
}
const DEFAULT_DEPARTMENT = 'Other'
const DEFAULT_LOCATION = 'Remote'
function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
const byKey = new Map<string, Department>()
for (const job of jobs) {
const displayDepartment = normalizeDepartment(job.department)
const name = displayDepartment.toUpperCase()
const key = slugify(name)
const existing = byKey.get(key)
const role = toDomainRole(job, displayDepartment)
if (existing) {
existing.roles.push(role)
} else {
byKey.set(key, { name, key, roles: [role] })
}
}
return [...byKey.values()].sort((a, b) => a.name.localeCompare(b.name))
}
function toDomainRole(job: AshbyJobPosting, department: string): Role {
const applyUrl = job.applyUrl ?? job.jobUrl
return {
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
title: job.title,
department: capitalize(department),
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
applyUrl
}
}
function normalizeDepartment(raw: string | undefined): string {
const trimmed = (raw ?? '').trim()
return trimmed.length > 0 ? trimmed : DEFAULT_DEPARTMENT
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<RolesSnapshot | null> {
if (!snapshotUrl) {
return isRolesSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isRolesSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
function isRolesSnapshot(value: unknown): value is RolesSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; departments?: unknown }
return (
typeof candidate.fetchedAt === 'string' &&
Array.isArray(candidate.departments)
)
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -8,10 +8,8 @@
"include": [
"src/**/*",
"e2e/**/*",
"scripts/**/*",
"astro.config.ts",
"playwright.config.ts",
"vitest.config.ts"
"playwright.config.ts"
],
"exclude": ["src/**/*.stories.ts"],
"references": [{ "path": "./tsconfig.stories.json" }]

View File

@@ -1,9 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.{test,spec}.ts'],
globals: false
}
})

View File

@@ -250,26 +250,6 @@ export class ModelLibrarySidebarTab extends SidebarTab {
}
}
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
type MediaFilterLabel = 'Image' | 'Video' | 'Audio' | '3D'
function getMediaFilterLabel(
filter: MediaFilterKind | MediaFilterLabel
): MediaFilterLabel {
switch (filter) {
case 'image':
return 'Image'
case 'video':
return 'Video'
case 'audio':
return 'Audio'
case '3d':
return '3D'
default:
return filter
}
}
export class AssetsSidebarTab extends SidebarTab {
// --- Tab navigation ---
public readonly generatedTab: Locator
@@ -281,13 +261,6 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Search & filter ---
public readonly searchInput: Locator
public readonly settingsButton: Locator
public readonly filterButton: Locator
// --- Filter menu checkboxes (cloud-only, shown inside filter popover) ---
public readonly filterImageCheckbox: Locator
public readonly filterVideoCheckbox: Locator
public readonly filterAudioCheckbox: Locator
public readonly filter3DCheckbox: Locator
// --- View mode ---
public readonly listViewOption: Locator
@@ -296,8 +269,6 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Sort options (cloud-only, shown inside settings popover) ---
public readonly sortNewestFirst: Locator
public readonly sortOldestFirst: Locator
public readonly sortLongestFirst: Locator
public readonly sortFastestFirst: Locator
// --- Asset cards ---
public readonly assetCards: Locator
@@ -328,17 +299,10 @@ export class AssetsSidebarTab extends SidebarTab {
)
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.filterButton = page.getByRole('button', { name: 'Filter by' })
this.filterImageCheckbox = page.getByRole('checkbox', { name: 'Image' })
this.filterVideoCheckbox = page.getByRole('checkbox', { name: 'Video' })
this.filterAudioCheckbox = page.getByRole('checkbox', { name: 'Audio' })
this.filter3DCheckbox = page.getByRole('checkbox', { name: '3D' })
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.sortNewestFirst = page.getByText('Newest first')
this.sortOldestFirst = page.getByText('Oldest first')
this.sortLongestFirst = page.getByText('Generation time (longest first)')
this.sortFastestFirst = page.getByText('Generation time (fastest first)')
this.assetCards = page
.getByRole('button')
.and(page.locator('[data-selected]'))
@@ -370,12 +334,6 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByText(title)
}
filterCheckbox(filter: MediaFilterKind | MediaFilterLabel) {
return this.page.getByRole('checkbox', {
name: getMediaFilterLabel(filter)
})
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
}
@@ -425,29 +383,6 @@ export class AssetsSidebarTab extends SidebarTab {
.waitFor({ state: 'visible', timeout: 3000 })
}
async openFilterMenu() {
await this.dismissToasts()
await this.filterButton.click()
await this.filterCheckbox('Image').waitFor({
state: 'visible',
timeout: 3000
})
}
async toggleMediaTypeFilter(
filter: MediaFilterKind | MediaFilterLabel
): Promise<void> {
const checkbox = this.filterCheckbox(filter)
const before = await checkbox.getAttribute('aria-checked')
await checkbox.click()
const expected = before === 'true' ? 'false' : 'true'
await expect(checkbox).toHaveAttribute('aria-checked', expected)
}
async getAssetCardOrder(): Promise<string[]> {
return await this.assetCards.allInnerTexts()
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })

View File

@@ -10,8 +10,6 @@ export class SubgraphBreadcrumbPanel {
readonly activeItem: Locator
readonly missingNodesIcon: Locator
readonly blueprintTag: Locator
readonly rootItem: Locator
readonly rootBlueprintTag: Locator
constructor(public readonly page: Page) {
this.root = page.getByTestId(TestIds.breadcrumb.subgraph)
@@ -25,10 +23,10 @@ export class SubgraphBreadcrumbPanel {
TestIds.breadcrumb.missingNodesIcon
)
this.blueprintTag = this.root.getByTestId(TestIds.breadcrumb.blueprintTag)
this.rootItem = page.getByTestId(TestIds.breadcrumb.item('root'))
this.rootBlueprintTag = this.rootItem.getByTestId(
TestIds.breadcrumb.blueprintTag
)
}
rootItem(): Locator {
return this.page.getByTestId(TestIds.breadcrumb.item('root'))
}
subgraphItem(subgraphId: string): Locator {

View File

@@ -7,56 +7,26 @@ const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
/**
* Media kinds supported by the assets sidebar filter UI. The string values
* match what the backend stores on `preview_output.mediaType` (`images` is
* intentionally plural to match existing API conventions; the others are
* singular as emitted by `useMediaAssetGalleryStore`).
*
* The sidebar filter ultimately matches on the filename extension, so the
* fixture also picks an extension-appropriate filename for each kind.
*/
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
images: 'png',
video: 'mp4',
audio: 'wav',
'3D': 'glb'
}
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & {
id: string
/**
* Optional shorthand to set both `preview_output.mediaType` and an
* extension-appropriate filename. Ignored when `preview_output` is also
* supplied via `overrides`.
*/
mediaKind?: MediaKindFixture
}
overrides: Partial<RawJobListItem> & { id: string }
): RawJobListItem {
const { mediaKind, ...rest } = overrides
const now = Date.now()
const extension = mediaKind ? DEFAULT_EXTENSION[mediaKind] : 'png'
const mediaType = mediaKind ?? 'images'
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5000,
preview_output: {
filename: `output_${rest.id}.${extension}`,
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType
mediaType: 'images'
},
outputs_count: 1,
priority: 0,
...rest
...overrides
}
}
@@ -84,46 +54,6 @@ export function createMockJobs(
)
}
/**
* Create one job per requested media kind, in the order supplied. Jobs share
* a stable timestamp ordering (newer first) so callers can rely on the result
* order when mediaType filters are inactive.
*/
export function createMixedMediaJobs(
kinds: MediaKindFixture[]
): RawJobListItem[] {
const now = Date.now()
return kinds.map((kind, i) =>
createMockJob({
id: `${kind}-${String(i + 1).padStart(3, '0')}`,
mediaKind: kind,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000
})
)
}
/**
* Create jobs with explicit `(create_time, execution duration)` pairs so that
* sort assertions for newest/oldest and longest/fastest are unambiguous.
*
* Each spec entry yields a job whose `execution_end_time - execution_start_time`
* equals `durationMs`. The first spec becomes id `job-001`, etc.
*/
export function createJobsWithExecutionTimes(
specs: ReadonlyArray<{ createTime: number; durationMs: number }>
): RawJobListItem[] {
return specs.map((spec, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: spec.createTime,
execution_start_time: spec.createTime,
execution_end_time: spec.createTime + spec.durationMs
})
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']

View File

@@ -1,232 +0,0 @@
import { expect } from '@playwright/test'
import type {
Asset,
JobsListResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
// The assets sidebar's media-type filter menu only renders in cloud mode
// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`).
// We tag tests `@cloud` so they run against the cloud Playwright project,
// and register both `/api/assets` and `/api/jobs` route handlers as auto
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
// internal `setup()`, so the page first-loads with mocks already in place.
// See cloud-asset-default.spec.ts for the same pattern.
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
// MediaAssetCard renders the filename *without* extension via
// getFilenameDetails(...).filename, so card-text matching uses the basename.
function expectCardText(index: number): string {
const filename = MIXED_JOBS[index]?.preview_output?.filename
if (!filename) {
throw new Error(
`MIXED_JOBS[${index}].preview_output.filename is missing — ` +
'createMixedMediaJobs contract changed.'
)
}
return filename.replace(/\.[^.]+$/, '')
}
const imageCardName = expectCardText(0)
const videoCardName = expectCardText(1)
const audioCardName = expectCardText(2)
const threeDCardName = expectCardText(3)
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
function makeJobsResponseBody() {
return {
jobs: MIXED_JOBS,
pagination: {
offset: 0,
limit: MIXED_JOBS.length,
total: MIXED_JOBS.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
}
const test = comfyPageFixture.extend<{
stubCloudAssets: void
stubJobs: void
stubInputFiles: void
}>({
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse([]))
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
],
stubJobs: [
async ({ page }, use) => {
const pattern = /\/api\/jobs(?:\?.*)?$/
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeJobsResponseBody())
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
],
stubInputFiles: [
async ({ page }, use) => {
const pattern = /\/internal\/files\/input(?:\?.*)?$/
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
test('Filter menu opens and exposes all four media-type checkboxes', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await expect(tab.filterImageCheckbox).toBeVisible()
await expect(tab.filterVideoCheckbox).toBeVisible()
await expect(tab.filterAudioCheckbox).toBeVisible()
await expect(tab.filter3DCheckbox).toBeVisible()
for (const cb of [
tab.filterImageCheckbox,
tab.filterVideoCheckbox,
tab.filterAudioCheckbox,
tab.filter3DCheckbox
]) {
await expect(cb).toHaveAttribute('aria-checked', 'false')
}
})
test('Selecting only "Image" hides non-image assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
await expect(tab.assetCards).toHaveCount(1)
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
await expect(tab.getAssetCardByName(videoCardName)).toHaveCount(0)
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
})
test('Selecting only "Video" hides non-video assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('video')
await expect(tab.assetCards).toHaveCount(1)
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
})
test('Selecting only "Audio" hides non-audio assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('audio')
await expect(tab.assetCards).toHaveCount(1)
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
})
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('3d')
await expect(tab.assetCards).toHaveCount(1)
await expect(tab.getAssetCardByName(threeDCardName)).toBeVisible()
})
test('Multiple filters combine via OR (image + video)', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
await tab.toggleMediaTypeFilter('video')
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
})
test('Unchecking the active filter restores previously hidden cards', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
await expect(tab.assetCards).toHaveCount(1)
await tab.toggleMediaTypeFilter('image')
// TODO(#11635): the 3D preview card does not remount after a filter
// toggle restores it (only image/video/audio reappear). Image, video,
// and audio cover the restoration path; once #11635 is fixed, add the
// 3D card back to this assertion list.
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible({
timeout: 10_000
})
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
})
})

View File

@@ -1,206 +0,0 @@
import { expect } from '@playwright/test'
import type {
Asset,
JobsListResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
// The assets sidebar's sort options live inside the settings popover and are
// only rendered in cloud mode (`MediaAssetFilterBar.vue`:
// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against
// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and
// `/internal/files/input` route handlers as auto fixtures — Playwright runs
// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the
// page first-loads with mocks already in place.
// Three jobs whose `(create_time, duration)` axes are intentionally
// misaligned so newest/oldest and longest/fastest sorts produce *different*
// orderings — preventing false-pass tests where one ordering accidentally
// satisfies another.
//
// spec create_time duration (ms)
// ----------------------------------------
// job-001 1000 5000 (oldest, mid duration)
// job-002 2000 10000 (mid age, longest)
// job-003 3000 3000 (newest, shortest)
const SORT_JOBS = createJobsWithExecutionTimes([
{ createTime: 1000, durationMs: 5000 },
{ createTime: 2000, durationMs: 10000 },
{ createTime: 3000, durationMs: 3000 }
])
// MediaAssetCard renders the filename *without* extension via
// getFilenameDetails(...).filename, so card-text matching uses the basename.
const NAME_BY_ID: Record<string, string> = {
'job-001': 'output_job-001',
'job-002': 'output_job-002',
'job-003': 'output_job-003'
}
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
function makeJobsResponseBody() {
return {
jobs: SORT_JOBS,
pagination: {
offset: 0,
limit: SORT_JOBS.length,
total: SORT_JOBS.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
}
const test = comfyPageFixture.extend<{
stubCloudAssets: void
stubJobs: void
stubInputFiles: void
}>({
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse([]))
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
],
stubJobs: [
async ({ page }, use) => {
const pattern = /\/api\/jobs(?:\?.*)?$/
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeJobsResponseBody())
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
],
stubInputFiles: [
async ({ page }, use) => {
const pattern = /\/internal\/files\/input(?:\?.*)?$/
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
test.describe('Assets sidebar - sort options', { tag: '@cloud' }, () => {
test('Settings menu exposes all four sort options in cloud mode', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await expect(tab.sortNewestFirst).toBeVisible()
await expect(tab.sortOldestFirst).toBeVisible()
await expect(tab.sortLongestFirst).toBeVisible()
await expect(tab.sortFastestFirst).toBeVisible()
})
test('Default order is newest first (descending create_time)', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
// Cards should appear in the order: job-003, job-002, job-001
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-001'])
})
test('"Oldest first" reverses the order', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await tab.sortOldestFirst.click()
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
})
test('"Longest first" puts the slowest job at the top', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await tab.sortLongestFirst.click()
// Expected: job-002 (10s), job-001 (5s), job-003 (3s)
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-002'])
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
})
test('"Fastest first" puts the quickest job at the top', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await tab.sortFastestFirst.click()
// Expected: job-003 (3s), job-001 (5s), job-002 (10s)
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-002'])
})
test('Sort persists when the search input is edited', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await tab.sortOldestFirst.click()
// Type a query that matches all three jobs, then clear it; sort order
// must remain "oldest first".
await tab.searchInput.fill('output_job')
await tab.searchInput.fill('')
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
})
})

View File

@@ -772,119 +772,3 @@ test.describe('Assets sidebar - delete confirmation', () => {
await expect(tab.assetCards).toHaveCount(initialCount)
})
})
// ==========================================================================
// 12. Media type filter (cloud-only)
// ==========================================================================
const MIXED_MEDIA_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-image',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'photo.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-video',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2010,
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'video'
},
outputs_count: 1
}),
createMockJob({
id: 'job-audio',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3010,
preview_output: {
filename: 'track.mp3',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'audio'
},
outputs_count: 1
})
]
// Filter button is guarded by isCloud (compile-time). The cloud CI project
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
// supports authenticated comfyPage setup.
test.describe('Assets sidebar - media type filter', () => {
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MIXED_MEDIA_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Filter menu shows media type options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openFilterMenu()
await expect(tab.filterCheckbox('Image')).toBeVisible()
await expect(tab.filterCheckbox('Video')).toBeVisible()
await expect(tab.filterCheckbox('Audio')).toBeVisible()
await expect(tab.filterCheckbox('3D')).toBeVisible()
})
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = tab.assetCards
await expect(
initialCount,
'All three mixed-media jobs should render'
).toHaveCount(3)
// Open filter menu and enable only image filter (selecting a filter
// restricts to that type only, hiding unselected types)
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
// Only the image asset should remain
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
})
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Enable image filter to restrict to images only
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
// Uncheck image filter to remove all filters (restores all assets)
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
})

View File

@@ -251,7 +251,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(NESTED_WORKFLOW)
await enterNestedSubgraphs(comfyPage)
await expect(subgraphBreadcrumb.panel.rootItem).toBeAttached()
await expect(subgraphBreadcrumb.panel.rootItem()).toBeAttached()
await expect(
subgraphBreadcrumb.panel.subgraphItem(SUBGRAPH_2_ID)
).toContainText(SUBGRAPH_2_NAME)
@@ -265,65 +265,12 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
)
})
test(
'shows Blueprint tag only when editing a published blueprint',
{
tag: ['@vue-nodes']
},
async ({ comfyPage, subgraphBreadcrumb }) => {
const { panel } = subgraphBreadcrumb
const blueprintName = `bp-breadcrumb-tag-${Date.now()}`
await test.step('Tag is not shown on a normal workflow', async () => {
await expect(panel.blueprintTag).toHaveCount(0)
})
await test.step('Convert to Subgraph', async () => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.contextMenu.openForVueNode(ksampler.header)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
})
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await test.step('Unpublished subgraph does not show the blueprint tag', async () => {
await subgraphNode.centerOnNode()
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(panel.blueprintTag).toHaveCount(0)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await test.step('Publish the subgraph as a blueprint', async () => {
await subgraphNode.click('title')
await comfyPage.subgraph.publishSubgraph(blueprintName)
})
await test.step('Open the blueprint from the node library', async () => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.open()
await tab.getFolder('My Blueprints').click()
await tab.getFolder('User').click()
const blueprintNode = tab.getNode(blueprintName)
await expect(blueprintNode).toBeVisible()
await blueprintNode.getByRole('button', { name: 'Edit' }).click()
})
await test.step('Blueprint tag renders on the root breadcrumb', async () => {
await expect(panel.rootBlueprintTag).toBeVisible()
})
}
)
test('collapses when overflowing and expands when there is room', async ({
comfyPage,
subgraphBreadcrumb
}) => {
const { panel } = subgraphBreadcrumb
const rootItem = panel.rootItem
const rootItem = panel.rootItem()
const subgraph2Item = panel.subgraphItem(SUBGRAPH_2_ID)
const subgraph3Item = panel.subgraphItem(SUBGRAPH_3_ID)
const originalViewport = comfyPage.page.viewportSize()

View File

@@ -196,48 +196,6 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
})
.toStrictEqual(before)
})
test('Selecting a ratio preset auto-enables aspect ratio lock', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' }),
'lock button should start in unlocked state'
).toBeVisible()
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '4:3', exact: true })
.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' }),
'selecting a preset should auto-lock the ratio'
).toBeVisible()
})
test('Unlocking after a preset selection shows Custom in ratio dropdown', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '1:1', exact: true })
.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node.getByRole('combobox'),
'dropdown should revert to Custom after unlock'
).toContainText('Custom')
})
})
test.describe(
@@ -855,339 +813,6 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
await comfyPage.page.unroute('**/api/view**')
}
})
test('Selecting 16:9 ratio adjusts crop height proportionally', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 50,
width: 360,
height: 360
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const expectedHeight = Math.round(before.width / (16 / 9))
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '16:9', exact: true })
.click()
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v || v.width !== before.width) return false
return Math.abs(v.height - expectedHeight) <= 2
},
{ message: '16:9 ratio should adjust crop height proportionally' }
)
.toBe(true)
})
test('Selecting Custom from ratio dropdown unlocks aspect ratio', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 50,
width: 200,
height: 200
})
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '1:1', exact: true })
.click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'preset selection should lock ratio and show 4 handles'
).toHaveCount(4)
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: 'Custom', exact: true })
.click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'selecting Custom should unlock ratio and restore 8 handles'
).toHaveCount(8)
})
test('Unlock button releases locked ratio and restores all 8 resize handles', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'lock should reduce handles to 4'
).toHaveCount(4)
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' }),
'lock button aria-label should update after locking'
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'unlock should restore all 8 handles'
).toHaveCount(8)
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' }),
'lock button aria-label should revert after unlocking'
).toBeVisible()
})
test('Constrained resize from NW corner adjusts origin and dimensions proportionally', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 150,
y: 140,
width: 200,
height: 150
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const ratio = before.width / before.height
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-nw'),
-45,
-35
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.x < before.x &&
v.y < before.y &&
v.width > before.width &&
v.height > before.height &&
Math.abs(v.width / v.height - ratio) < 0.06
)
},
{
message:
'constrained NW resize should grow box and maintain aspect ratio'
}
)
.toBe(true)
})
test('Constrained resize clamps to image boundary while maintaining ratio', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await waitForImageNaturalSize(img)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
await setCropBounds(comfyPage, 2, {
x: nw - 100,
y: nh - 75,
width: 80,
height: 60
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const initialRatio = before.width / before.height
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-se'),
600,
400
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
const withinBounds = v.x + v.width <= nw && v.y + v.height <= nh
const ratio = v.width / v.height
const ratioPreserved = Math.abs(ratio - initialRatio) < 0.05
return withinBounds && ratioPreserved
},
{
message:
'constrained resize should stay within image boundaries and preserve aspect ratio'
}
)
.toBe(true)
})
test('Constrained NW corner resize clamps at top-left image boundary', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 30,
y: 25,
width: 160,
height: 120
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-nw'),
-400,
-300
)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.x ?? -1, {
message: 'constrained NW resize should clamp x to image boundary'
})
.toBeGreaterThanOrEqual(0)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.y ?? -1, {
message: 'constrained NW resize should clamp y to image boundary'
})
.toBeGreaterThanOrEqual(0)
})
test('Constrained resize enforces minimum crop size', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 100,
width: 60,
height: 60
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-se'),
-300,
-300
)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.width ?? 0, {
message: 'constrained resize should respect minimum width'
})
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.height ?? 0, {
message: 'constrained resize should respect minimum height'
})
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
})
test('Incrementing X in BoundingBox moves crop box right', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 80,
width: 200,
height: 150
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await node
.getByTestId('bounding-box-x')
.getByTestId('increment')
.click()
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.x, {
message: 'incrementing X should move crop right by 1'
})
.toBe(before.x + 1)
})
test('Incrementing Width in BoundingBox increases crop width', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 80,
width: 200,
height: 150
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await node
.getByTestId('bounding-box-width')
.getByTestId('increment')
.click()
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.width, {
message: 'incrementing Width should increase crop width by 1'
})
.toBe(before.width + 1)
})
test('BoundingBox numeric inputs reflect crop position after drag', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 60,
width: 200,
height: 150
})
const xInput = node.getByTestId('bounding-box-x').locator('input')
await expect
.poll(async () => Number(await xInput.inputValue()), {
message: 'X input should show initial crop x value'
})
.toBe(50)
await dragOnLocator(comfyPage, node.getByTestId('crop-overlay'), 40, 20)
await expect
.poll(async () => Number(await xInput.inputValue()), {
message: 'X input should update after crop drag'
})
.toBeGreaterThan(50)
})
}
)

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.11",
"version": "1.44.9",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

61
pnpm-lock.yaml generated
View File

@@ -952,9 +952,6 @@ importers:
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
zod:
specifier: 'catalog:'
version: 3.25.76
devDependencies:
'@astrojs/check':
specifier: 'catalog:'
@@ -974,15 +971,9 @@ importers:
tailwindcss:
specifier: 'catalog:'
version: 4.2.0
tsx:
specifier: 'catalog:'
version: 4.19.4
typescript:
specifier: 'catalog:'
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)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
packages/design-system:
dependencies:
@@ -14114,14 +14105,6 @@ snapshots:
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/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:
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':
dependencies:
tinyrainbow: 2.0.0
@@ -14156,7 +14139,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)(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:
@@ -20337,48 +20320,6 @@ 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)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@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
'@vitest/spy': 4.0.16
'@vitest/utils': 4.0.16
es-module-lexer: 1.7.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.0.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)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.0.3
'@vitest/ui': 4.0.16(vitest@4.0.16)
happy-dom: 20.0.11
jsdom: 27.4.0
transitivePeerDependencies:
- '@vitejs/devtools'
- esbuild
- jiti
- less
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
void-elements@3.1.0: {}
volar-service-css@0.0.70(@volar/language-service@2.4.28):

View File

@@ -3,43 +3,19 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<ScrubableNumberInput
v-model="x"
:min="0"
:step="1"
:disabled
data-testid="bounding-box-x"
/>
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<ScrubableNumberInput
v-model="y"
:min="0"
:step="1"
:disabled
data-testid="bounding-box-y"
/>
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<ScrubableNumberInput
v-model="width"
:min="1"
:step="1"
:disabled
data-testid="bounding-box-width"
/>
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<ScrubableNumberInput
v-model="height"
:min="1"
:step="1"
:disabled
data-testid="bounding-box-height"
/>
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
</div>
</template>

View File

@@ -26,7 +26,7 @@
<Tag
v-if="item.isBlueprint"
data-testid="subgraph-breadcrumb-blueprint-tag"
:value="t('breadcrumbsMenu.blueprint')"
value="Blueprint"
severity="primary"
/>
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>

View File

@@ -1,253 +0,0 @@
/* eslint-disable vue/no-reserved-component-names */
/* eslint-disable vue/no-unused-emit-declarations */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
curveWidget: {
linear: 'Linear',
monotone_cubic: 'Smooth',
step: 'Step'
}
}
}
})
const upstreamHolder = vi.hoisted(() => ({
ref: null as { value: unknown } | null
}))
vi.mock('@/composables/useUpstreamValue', async () => {
const { ref } = await import('vue')
return {
useUpstreamValue: () => {
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
return upstreamHolder.ref
},
singleValueExtractor: () => () => undefined
}
})
const outputsHolder = vi.hoisted(() => ({
nodeOutputs: {} as Record<string, unknown>
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => outputsHolder
}))
import WidgetCurve from './WidgetCurve.vue'
import type { CurveData } from './types'
const CurveEditorStub = defineComponent({
name: 'CurveEditor',
props: {
modelValue: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
interpolation: { type: String, default: '' },
histogram: { type: Object, default: null }
},
emits: ['update:modelValue'],
template: `
<div data-testid="curve-editor"
:data-disabled="String(disabled)"
:data-interpolation="interpolation"
:data-has-histogram="String(!!histogram)"
:data-points="JSON.stringify(modelValue)"
@click="$emit('update:modelValue', [[0,0],[0.5,1],[1,0]])"
/>
`
})
const SelectStub = defineComponent({
name: 'Select',
props: { modelValue: { type: String, default: '' } },
emits: ['update:modelValue'],
template: `
<div data-testid="interp-select" :data-value="modelValue">
<button
data-testid="select-linear"
@click="$emit('update:modelValue', 'linear')"
>linear</button>
<slot />
</div>
`
})
const Passthrough = defineComponent({
name: 'SelectPassthrough',
template: '<slot />'
})
function makeWidget(
overrides: Partial<SimplifiedWidget<CurveData>> = {}
): SimplifiedWidget<CurveData> {
return {
name: 'curve_w',
type: 'curve',
value: {
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
},
options: {},
...overrides
} as unknown as SimplifiedWidget<CurveData>
}
function setUpstream(value: CurveData | undefined) {
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
upstreamHolder.ref.value = value
}
function renderWidget(
widget: SimplifiedWidget<CurveData>,
initialModel: CurveData = {
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
}
) {
const value = ref<CurveData>(initialModel)
const Harness = defineComponent({
components: { WidgetCurve },
setup: () => ({ value, widget }),
template: '<WidgetCurve v-model="value" :widget="widget" />'
})
const utils = render(Harness, {
global: {
plugins: [i18n],
stubs: {
CurveEditor: CurveEditorStub,
Select: SelectStub,
SelectContent: Passthrough,
SelectTrigger: Passthrough,
SelectValue: Passthrough,
SelectItem: Passthrough
}
}
})
return { ...utils, value }
}
describe('WidgetCurve', () => {
beforeEach(() => {
upstreamHolder.ref = null
outputsHolder.nodeOutputs = {}
})
describe('Point forwarding', () => {
it('forwards model points to CurveEditor', () => {
renderWidget(makeWidget(), {
points: [
[0, 0],
[0.5, 0.2],
[1, 1]
],
interpolation: 'monotone_cubic'
})
const parsed = JSON.parse(
screen.getByTestId('curve-editor').dataset.points!
)
expect(parsed).toEqual([
[0, 0],
[0.5, 0.2],
[1, 1]
])
})
it('updates v-model when CurveEditor emits new points', async () => {
const { value } = renderWidget(makeWidget())
const user = userEvent.setup()
await user.click(screen.getByTestId('curve-editor'))
expect(value.value.points).toEqual([
[0, 0],
[0.5, 1],
[1, 0]
])
})
it('preserves interpolation when points change', async () => {
const { value } = renderWidget(makeWidget(), {
points: [
[0, 0],
[1, 1]
],
interpolation: 'linear'
})
const user = userEvent.setup()
await user.click(screen.getByTestId('curve-editor'))
expect(value.value.interpolation).toBe('linear')
})
})
describe('Interpolation select', () => {
it('shows the Select when not disabled', () => {
renderWidget(makeWidget())
expect(screen.getByTestId('interp-select')).toBeInTheDocument()
})
it('hides the Select when disabled', () => {
renderWidget(makeWidget({ options: { disabled: true } }))
expect(screen.queryByTestId('interp-select')).toBeNull()
})
it('updates interpolation in v-model when Select emits a change', async () => {
const { value } = renderWidget(makeWidget())
const user = userEvent.setup()
await user.click(screen.getByTestId('select-linear'))
expect(value.value.interpolation).toBe('linear')
})
it('preserves points when interpolation changes', async () => {
const original: CurveData = {
points: [
[0, 0],
[0.3, 0.8],
[1, 1]
],
interpolation: 'monotone_cubic'
}
const { value } = renderWidget(makeWidget(), original)
const user = userEvent.setup()
await user.click(screen.getByTestId('select-linear'))
expect(value.value.points).toEqual(original.points)
})
})
describe('Disabled state + upstream', () => {
it('uses upstream curve when disabled and upstream is available', () => {
const upstream: CurveData = {
points: [
[0, 0],
[0.5, 0.5],
[1, 1]
],
interpolation: 'linear'
}
setUpstream(upstream)
renderWidget(
makeWidget({
options: { disabled: true },
linkedUpstream: { nodeId: 'n1' }
})
)
const parsed = JSON.parse(
screen.getByTestId('curve-editor').dataset.points!
)
expect(parsed).toEqual(upstream.points)
})
})
})

View File

@@ -1,77 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
const mockExecute = vi.hoisted(() => vi.fn())
const mockSelectionState = vi.hoisted(() => ({
isSingleImageNode: { value: true }
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: mockExecute })
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => mockSelectionState
}))
const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: 'en',
messages: {
en: {
commands: {
Comfy_MaskEditor_OpenMaskEditor: { label: 'Open in Mask Editor' }
}
}
}
})
const renderButton = () =>
render(MaskEditorButton, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
describe('MaskEditorButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelectionState.isSingleImageNode = ref(true)
})
it('should render with the localized aria-label when a single image node is selected', () => {
renderButton()
expect(
screen.getByRole('button', { name: 'Open in Mask Editor' })
).toBeInTheDocument()
})
it('should hide via v-show when no single image node is selected', () => {
mockSelectionState.isSingleImageNode = ref(false)
renderButton()
const btn = screen.getByLabelText('Open in Mask Editor', {
selector: 'button'
})
expect(btn.getAttribute('style') ?? '').toContain('display: none')
})
it('should execute the OpenMaskEditor command on click', async () => {
const user = userEvent.setup()
renderButton()
await user.click(
screen.getByRole('button', { name: 'Open in Mask Editor' })
)
expect(mockExecute).toHaveBeenCalledWith('Comfy.MaskEditor.OpenMaskEditor')
})
})

View File

@@ -1,178 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BrushCursor from '@/components/maskeditor/BrushCursor.vue'
import { BrushShape } from '@/extensions/core/maskeditor/types'
const initialMock = () =>
reactive({
brushVisible: true,
brushPreviewGradientVisible: false,
brushSettings: {
type: BrushShape.Arc,
size: 20,
opacity: 0.7,
hardness: 1,
stepSize: 5
},
zoomRatio: 1,
cursorPoint: { x: 100, y: 50 },
panOffset: { x: 0, y: 0 }
})
let mockStore: ReturnType<typeof initialMock>
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
const styleOf = (el: Element): string => el.getAttribute('style') ?? ''
const renderCursor = (containerRef?: HTMLElement) =>
render(BrushCursor, {
props: containerRef ? { containerRef } : {}
})
const getBrushEl = (): HTMLElement => screen.getByTestId('brush-cursor')
const getGradientEl = (): HTMLElement =>
screen.getByTestId('brush-cursor-gradient')
describe('BrushCursor', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
mockStore = initialMock()
})
describe('opacity', () => {
it('should be 1 when brushVisible is true', () => {
renderCursor()
expect(styleOf(getBrushEl())).toContain('opacity: 1')
})
it('should be 0 when brushVisible is false', () => {
mockStore.brushVisible = false
renderCursor()
expect(styleOf(getBrushEl())).toContain('opacity: 0')
})
})
describe('size and shape', () => {
it('should compute size as 2 * effectiveBrushSize * zoomRatio', () => {
// size=20, hardness=1 → effective=20; zoom=2 → diameter = 80
mockStore.brushSettings.size = 20
mockStore.brushSettings.hardness = 1
mockStore.zoomRatio = 2
renderCursor()
const style = styleOf(getBrushEl())
expect(style).toContain('width: 80px')
expect(style).toContain('height: 80px')
})
it('should grow effective size when hardness drops below 1', () => {
mockStore.brushSettings.size = 100
mockStore.brushSettings.hardness = 0
mockStore.zoomRatio = 1
renderCursor()
// effective = 100 * (1 + 1.0 * 0.5) = 150 → diameter = 300
expect(styleOf(getBrushEl())).toContain('width: 300px')
})
it('should use 50% borderRadius for Arc brush', () => {
mockStore.brushSettings.type = BrushShape.Arc
renderCursor()
expect(styleOf(getBrushEl())).toContain('border-radius: 50%')
})
it('should use 0% borderRadius for Rect brush', () => {
mockStore.brushSettings.type = BrushShape.Rect
renderCursor()
expect(styleOf(getBrushEl())).toContain('border-radius: 0%')
})
})
describe('position', () => {
it('should anchor to cursorPoint plus panOffset minus radius (no container)', () => {
mockStore.cursorPoint = { x: 200, y: 300 }
mockStore.panOffset = { x: 50, y: 25 }
mockStore.brushSettings.size = 20
mockStore.brushSettings.hardness = 1
mockStore.zoomRatio = 1
renderCursor()
// radius = effective(20,1) * 1 = 20
// left = 200 + 50 - 20 = 230
// top = 300 + 25 - 20 = 305
const style = styleOf(getBrushEl())
expect(style).toContain('left: 230px')
expect(style).toContain('top: 305px')
})
it('should subtract container offset when containerRef is provided', () => {
mockStore.cursorPoint = { x: 200, y: 300 }
mockStore.panOffset = { x: 0, y: 0 }
mockStore.brushSettings.size = 20
mockStore.brushSettings.hardness = 1
mockStore.zoomRatio = 1
const container = document.createElement('div')
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
left: 30,
top: 60,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({})
} as DOMRect)
renderCursor(container)
// left = 200 + 0 - 20 - 30 = 150; top = 300 + 0 - 20 - 60 = 220
const style = styleOf(getBrushEl())
expect(style).toContain('left: 150px')
expect(style).toContain('top: 220px')
})
})
describe('gradient preview', () => {
it('should be hidden by default', () => {
mockStore.brushPreviewGradientVisible = false
renderCursor()
expect(styleOf(getGradientEl())).toContain('display: none')
})
it('should be visible when brushPreviewGradientVisible is true', () => {
mockStore.brushPreviewGradientVisible = true
renderCursor()
expect(styleOf(getGradientEl())).toContain('display: block')
})
it('should use a flat fill at hardness=1', () => {
mockStore.brushPreviewGradientVisible = true
mockStore.brushSettings.hardness = 1
mockStore.brushSettings.size = 20
renderCursor()
// hard brush: getEffectiveHardness = (20*1)/20 = 1 → flat color
const style = styleOf(getGradientEl())
expect(style).toContain('rgba(255, 0, 0, 0.5)')
expect(style).not.toContain('radial-gradient')
})
// The radial-gradient (hardness < 1) branch uses a multi-line template
// literal as the background value; happy-dom's CSS parser drops the
// declaration entirely, so we can't assert on the rendered style. The
// underlying math (getEffectiveBrushSize / getEffectiveHardness) is
// covered by brushUtils.test.ts.
})
})

View File

@@ -1,7 +1,6 @@
<template>
<div
id="maskEditor_brush"
data-testid="brush-cursor"
:style="{
position: 'absolute',
opacity: brushOpacity,
@@ -16,7 +15,6 @@
>
<div
id="maskEditor_brushPreviewGradient"
data-testid="brush-cursor-gradient"
:style="{
display: gradientVisible ? 'block' : 'none',
background: gradientBackground

View File

@@ -1,230 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- shape buttons are unlabeled divs and number inputs have no aria labels */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import BrushSettingsPanel from '@/components/maskeditor/BrushSettingsPanel.vue'
import { BrushShape } from '@/extensions/core/maskeditor/types'
const initialMock = () => ({
brushSettings: reactive({
type: BrushShape.Arc,
size: 10,
opacity: 0.7,
hardness: 1,
stepSize: 5
}),
rgbColor: '#FF0000',
colorInput: null as HTMLInputElement | null,
setBrushSize: vi.fn(),
setBrushOpacity: vi.fn(),
setBrushHardness: vi.fn(),
setBrushStepSize: vi.fn(),
resetBrushToDefault: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
default: {
name: 'SliderControlStub',
props: ['label', 'min', 'max', 'step', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-slider="true" @click="$emit('update:modelValue', 0.5)">{{ modelValue }}</button>`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
brushSettings: 'Brush Settings',
brushShape: 'Brush Shape',
colorSelector: 'Color Selector',
thickness: 'Thickness',
opacity: 'Opacity',
hardness: 'Hardness',
stepSize: 'Step Size',
resetToDefault: 'Reset to Default'
}
}
}
})
const renderPanel = () =>
render(BrushSettingsPanel, { global: { plugins: [i18n] } })
const setNumberInput = (input: HTMLInputElement, value: string): void => {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('BrushSettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('brush shape buttons', () => {
it('should set brushSettings.type to Arc when arc button clicked', async () => {
mockStore.brushSettings.type = BrushShape.Rect
const { container } = renderPanel()
const user = userEvent.setup()
const arcEl = container.querySelector(
'.maskEditor_sidePanelBrushShapeCircle'
)
await user.click(arcEl as Element)
expect(mockStore.brushSettings.type).toBe(BrushShape.Arc)
})
it('should set brushSettings.type to Rect when rect button clicked', async () => {
const { container } = renderPanel()
const user = userEvent.setup()
const rectEl = container.querySelector(
'.maskEditor_sidePanelBrushShapeSquare'
)
await user.click(rectEl as Element)
expect(mockStore.brushSettings.type).toBe(BrushShape.Rect)
})
})
describe('reset button', () => {
it('should call resetBrushToDefault when clicked', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Reset to Default' }))
expect(mockStore.resetBrushToDefault).toHaveBeenCalledTimes(1)
})
})
describe('numeric inputs', () => {
it('should call setBrushSize when size number input changes', () => {
const { container } = renderPanel()
const sizeInput = container.querySelectorAll(
'input[type="number"]'
)[0] as HTMLInputElement
setNumberInput(sizeInput, '50')
expect(mockStore.setBrushSize).toHaveBeenCalledWith(50)
})
it('should call setBrushOpacity when opacity number input changes', () => {
const { container } = renderPanel()
const opacityInput = container.querySelectorAll(
'input[type="number"]'
)[1] as HTMLInputElement
setNumberInput(opacityInput, '0.4')
expect(mockStore.setBrushOpacity).toHaveBeenCalledWith(0.4)
})
it('should call setBrushHardness when hardness number input changes', () => {
const { container } = renderPanel()
const hardnessInput = container.querySelectorAll(
'input[type="number"]'
)[2] as HTMLInputElement
setNumberInput(hardnessInput, '0.6')
expect(mockStore.setBrushHardness).toHaveBeenCalledWith(0.6)
})
it('should call setBrushStepSize when step number input changes', () => {
const { container } = renderPanel()
const stepInput = container.querySelectorAll(
'input[type="number"]'
)[3] as HTMLInputElement
setNumberInput(stepInput, '20')
expect(mockStore.setBrushStepSize).toHaveBeenCalledWith(20)
})
})
describe('size slider (logarithmic)', () => {
it('should call setBrushSize with Math.round(Math.pow(250, value))', () => {
const { container } = renderPanel()
const sizeSlider = container.querySelectorAll(
'[data-slider="true"]'
)[0] as HTMLElement
sizeSlider.click()
// value = 0.5 → Math.round(Math.pow(250, 0.5)) = 16
expect(mockStore.setBrushSize).toHaveBeenCalledWith(16)
})
it('should map size 250 to slider value 1', () => {
mockStore.brushSettings.size = 250
const { container } = renderPanel()
const sizeSlider = container.querySelectorAll(
'[data-slider="true"]'
)[0] as HTMLElement
// Math.log(250) / Math.log(250) = 1
expect(sizeSlider.textContent).toContain('1')
})
it('should return cached raw slider value when size matches the mapping', async () => {
mockStore.setBrushSize.mockImplementation((size: number) => {
mockStore.brushSettings.size = size
})
const { container } = renderPanel()
const sizeSlider = container.querySelectorAll(
'[data-slider="true"]'
)[0] as HTMLElement
// Click sets rawSliderValue=0.5 → setBrushSize(16) → size=16
// → next getter run sees cached match → returns 0.5
sizeSlider.click()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sizeSlider.textContent).toContain('0.5')
})
})
describe('color input', () => {
it('should v-model rgbColor on the color input', () => {
const { container } = renderPanel()
const colorInput = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
colorInput.value = '#00ff00'
colorInput.dispatchEvent(new Event('input', { bubbles: true }))
expect(mockStore.rgbColor).toBe('#00ff00')
})
it('should expose color input ref to the store on mount', () => {
const { container } = renderPanel()
const colorInput = container.querySelector('input[type="color"]')
expect(mockStore.colorInput).toBe(colorInput)
})
it('should clear store.colorInput on unmount', () => {
const { unmount } = renderPanel()
expect(mockStore.colorInput).not.toBeNull()
unmount()
expect(mockStore.colorInput).toBeNull()
})
})
})

View File

@@ -1,168 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ColorSelectSettingsPanel from '@/components/maskeditor/ColorSelectSettingsPanel.vue'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
const mockStore = vi.hoisted(() => ({
colorSelectTolerance: 20,
selectionOpacity: 100,
colorSelectLivePreview: false,
applyWholeImage: false,
colorComparisonMethod: 'simple' as ColorComparisonMethod,
maskBoundary: false,
maskTolerance: 0,
setColorSelectTolerance: vi.fn(),
setSelectionOpacity: vi.fn(),
setMaskTolerance: vi.fn()
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
default: {
name: 'SliderControlStub',
props: ['label', 'min', 'max', 'step', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-control="slider" :aria-label="label" @click="$emit('update:modelValue', 99)">{{ modelValue }}</button>`
}
}))
vi.mock('@/components/maskeditor/controls/ToggleControl.vue', () => ({
default: {
name: 'ToggleControlStub',
props: ['label', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-control="toggle" :aria-label="label" @click="$emit('update:modelValue', !modelValue)">{{ modelValue }}</button>`
}
}))
vi.mock('@/components/maskeditor/controls/DropdownControl.vue', () => ({
default: {
name: 'DropdownControlStub',
props: ['label', 'options', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-control="dropdown" :aria-label="label" @click="$emit('update:modelValue', 'lab')">{{ modelValue }}</button>`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
colorSelectSettings: 'Color Select Settings',
tolerance: 'Tolerance',
selectionOpacity: 'Selection Opacity',
livePreview: 'Live Preview',
applyToWholeImage: 'Apply to Whole Image',
method: 'Method',
stopAtMask: 'Stop at mask',
maskTolerance: 'Mask Tolerance'
}
}
}
})
const renderPanel = () =>
render(ColorSelectSettingsPanel, { global: { plugins: [i18n] } })
describe('ColorSelectSettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.colorSelectTolerance = 20
mockStore.selectionOpacity = 100
mockStore.colorSelectLivePreview = false
mockStore.applyWholeImage = false
mockStore.colorComparisonMethod = ColorComparisonMethod.Simple
mockStore.maskBoundary = false
mockStore.maskTolerance = 0
})
it('should call setColorSelectTolerance when tolerance slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Tolerance' }))
expect(mockStore.setColorSelectTolerance).toHaveBeenCalledWith(99)
})
it('should call setSelectionOpacity when selection opacity slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Selection Opacity' }))
expect(mockStore.setSelectionOpacity).toHaveBeenCalledWith(99)
})
it('should toggle colorSelectLivePreview when live preview toggle emits', async () => {
const user = userEvent.setup()
mockStore.colorSelectLivePreview = false
renderPanel()
await user.click(screen.getByRole('button', { name: 'Live Preview' }))
expect(mockStore.colorSelectLivePreview).toBe(true)
})
it('should toggle applyWholeImage when whole-image toggle emits', async () => {
const user = userEvent.setup()
mockStore.applyWholeImage = false
renderPanel()
await user.click(
screen.getByRole('button', { name: 'Apply to Whole Image' })
)
expect(mockStore.applyWholeImage).toBe(true)
})
it('should set comparison method when dropdown emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Method' }))
expect(mockStore.colorComparisonMethod).toBe(ColorComparisonMethod.LAB)
})
it('should toggle maskBoundary when stop-at-mask toggle emits', async () => {
const user = userEvent.setup()
mockStore.maskBoundary = false
renderPanel()
await user.click(screen.getByRole('button', { name: 'Stop at mask' }))
expect(mockStore.maskBoundary).toBe(true)
})
it('should call setMaskTolerance when mask tolerance slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Mask Tolerance' }))
expect(mockStore.setMaskTolerance).toHaveBeenCalledWith(99)
})
it('should reflect store values on the controls', () => {
mockStore.colorSelectTolerance = 77
mockStore.colorComparisonMethod = ColorComparisonMethod.HSL
renderPanel()
expect(
screen.getByRole('button', { name: 'Tolerance' }).textContent
).toContain('77')
expect(
screen.getByRole('button', { name: 'Method' }).textContent
).toContain('hsl')
})
})

View File

@@ -1,281 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- layer rows have unlabeled checkboxes and the blend-mode select has no role-friendly label */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import ImageLayerSettingsPanel from '@/components/maskeditor/ImageLayerSettingsPanel.vue'
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
type ToolManager = ReturnType<typeof useToolManager>
const initialMock = () =>
reactive({
maskOpacity: 0.8,
maskBlendMode: MaskBlendMode.Black,
activeLayer: 'mask' as 'mask' | 'rgb',
currentTool: Tools.MaskPen,
image: { src: 'https://example.com/base.png' } as { src: string } | null,
maskCanvas: null as HTMLCanvasElement | null,
rgbCanvas: null as HTMLCanvasElement | null,
imgCanvas: null as HTMLCanvasElement | null,
setMaskOpacity: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
const mockUpdateMaskColor = vi.fn().mockResolvedValue(undefined)
const mockSetActiveLayer = vi.fn()
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
useCanvasManager: () => ({ updateMaskColor: mockUpdateMaskColor })
}))
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
default: {
name: 'SliderControlStub',
props: ['label', 'min', 'max', 'step', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-slider="true" @click="$emit('update:modelValue', 0.3)">{{ modelValue }}</button>`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
layers: 'Layers',
maskOpacity: 'Mask Opacity',
maskBlendingOptions: 'Mask Blending Options',
black: 'Black',
white: 'White',
negative: 'Negative',
maskLayer: 'Mask Layer',
paintLayer: 'Paint Layer',
baseImageLayer: 'Base Image Layer',
activateLayer: 'Activate Layer',
baseLayerPreview: 'Base layer preview'
}
}
}
})
const renderPanel = (props?: Record<string, unknown>) =>
render(ImageLayerSettingsPanel, {
global: { plugins: [i18n] },
props
})
const makeCanvas = (): HTMLCanvasElement => document.createElement('canvas')
describe('ImageLayerSettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('mask opacity slider', () => {
it('should call setMaskOpacity and update mask canvas opacity', async () => {
const user = userEvent.setup()
const canvas = makeCanvas()
mockStore.maskCanvas = canvas
const { container } = renderPanel()
await user.click(
container.querySelector('[data-slider="true"]') as HTMLElement
)
expect(mockStore.setMaskOpacity).toHaveBeenCalledWith(0.3)
expect(canvas.style.opacity).toBe('0.3')
})
it('should leave canvas alone when no maskCanvas is set', async () => {
const user = userEvent.setup()
const { container } = renderPanel()
await expect(
user.click(
container.querySelector('[data-slider="true"]') as HTMLElement
)
).resolves.not.toThrow()
expect(mockStore.setMaskOpacity).toHaveBeenCalledWith(0.3)
})
})
describe('blend mode select', () => {
it.each([
['black', MaskBlendMode.Black],
['white', MaskBlendMode.White],
['negative', MaskBlendMode.Negative],
['unknown-fallback', MaskBlendMode.Black]
] as const)('should map %s to MaskBlendMode.%s', async (raw, expected) => {
const { container } = renderPanel()
const select = container.querySelector('select') as HTMLSelectElement
Object.defineProperty(select, 'value', {
value: raw,
configurable: true
})
select.dispatchEvent(new Event('change', { bubbles: true }))
await new Promise((r) => setTimeout(r, 0))
expect(mockStore.maskBlendMode).toBe(expected)
expect(mockUpdateMaskColor).toHaveBeenCalled()
})
})
describe('layer visibility checkboxes', () => {
it('should toggle mask canvas opacity to maskOpacity when checked, 0 when unchecked', async () => {
const user = userEvent.setup()
const canvas = makeCanvas()
mockStore.maskCanvas = canvas
mockStore.maskOpacity = 0.5
const { container } = renderPanel()
const checkbox = container.querySelectorAll(
'input[type="checkbox"]'
)[0] as HTMLInputElement
await user.click(checkbox)
expect(canvas.style.opacity).toBe('0')
await user.click(checkbox)
expect(canvas.style.opacity).toBe('0.5')
})
it('should toggle paint (rgb) canvas opacity between 0 and 1', async () => {
const user = userEvent.setup()
const canvas = makeCanvas()
mockStore.rgbCanvas = canvas
const { container } = renderPanel()
const checkbox = container.querySelectorAll(
'input[type="checkbox"]'
)[1] as HTMLInputElement
await user.click(checkbox)
expect(canvas.style.opacity).toBe('0')
await user.click(checkbox)
expect(canvas.style.opacity).toBe('1')
})
it('should toggle base image canvas opacity between 0 and 1', async () => {
const user = userEvent.setup()
const canvas = makeCanvas()
mockStore.imgCanvas = canvas
const { container } = renderPanel()
const checkbox = container.querySelectorAll(
'input[type="checkbox"]'
)[2] as HTMLInputElement
await user.click(checkbox)
expect(canvas.style.opacity).toBe('0')
})
it('should not throw when toggling visibility for missing canvases', async () => {
const user = userEvent.setup()
const { container } = renderPanel()
const checkboxes = container.querySelectorAll('input[type="checkbox"]')
for (const cb of checkboxes) {
await expect(user.click(cb as HTMLInputElement)).resolves.not.toThrow()
}
})
})
describe('activate layer buttons', () => {
it('should forward the layer to toolManager.setActiveLayer when clicked', async () => {
const user = userEvent.setup()
mockStore.activeLayer = 'rgb'
renderPanel({
toolManager: {
setActiveLayer: mockSetActiveLayer
} as unknown as ToolManager
})
const [maskBtn] = screen.getAllByRole('button', {
name: 'Activate Layer'
})
await user.click(maskBtn)
expect(mockSetActiveLayer).toHaveBeenCalledWith('mask')
})
it('should not throw when toolManager prop is omitted', async () => {
const user = userEvent.setup()
mockStore.activeLayer = 'rgb'
renderPanel()
const [maskBtn] = screen.getAllByRole('button', {
name: 'Activate Layer'
})
await expect(user.click(maskBtn)).resolves.not.toThrow()
})
it('should mark the active-layer button disabled', () => {
mockStore.activeLayer = 'mask'
renderPanel()
const [maskBtn] = screen.getAllByRole('button', {
name: 'Activate Layer'
})
expect(maskBtn.hasAttribute('disabled')).toBe(true)
})
})
describe('paint layer activate visibility', () => {
const styleOf = (el: Element): string => el.getAttribute('style') ?? ''
it('should hide paint activate button when current tool is not Eraser', () => {
mockStore.currentTool = Tools.MaskPen
const { container } = renderPanel()
const buttons = Array.from(container.querySelectorAll('button'))
const paintBtn = buttons.find((b) => styleOf(b).includes('display:'))
expect(paintBtn).toBeDefined()
expect(styleOf(paintBtn as Element)).toContain('display: none')
})
it('should show paint activate button when current tool is Eraser', () => {
mockStore.currentTool = Tools.Eraser
const { container } = renderPanel()
const buttons = Array.from(container.querySelectorAll('button'))
const paintBtn = buttons.find((b) => styleOf(b).includes('display:'))
expect(paintBtn).toBeDefined()
expect(styleOf(paintBtn as Element)).toContain('display: block')
})
})
describe('base image preview', () => {
it('should render base image src from store', () => {
mockStore.image = { src: 'https://example.com/img.png' }
renderPanel()
const img = screen.getByAltText('Base layer preview')
expect((img as HTMLImageElement).src).toBe('https://example.com/img.png')
})
it('should render empty src when no image', () => {
mockStore.image = null
renderPanel()
const img = screen.getByAltText('Base layer preview')
expect(img.getAttribute('src')).toBe('')
})
})
})

View File

@@ -1,354 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import { reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
const mockKeyboard = vi.hoisted(() => ({
addListeners: vi.fn(),
removeListeners: vi.fn()
}))
const mockPanZoom = vi.hoisted(() => ({
initializeCanvasPanZoom: vi.fn().mockResolvedValue(undefined),
invalidatePanZoom: vi.fn().mockResolvedValue(undefined)
}))
const mockBrushDrawing = vi.hoisted(() => ({
initGPUResources: vi.fn().mockResolvedValue(undefined),
initPreviewCanvas: vi.fn(),
saveBrushSettings: vi.fn()
}))
const mockToolManager = vi.hoisted(() => ({
brushDrawing: mockBrushDrawing
}))
const mockImageLoader = vi.hoisted(() => ({
loadImages: vi.fn().mockResolvedValue({ width: 100, height: 100 })
}))
const mockMaskEditorLoader = vi.hoisted(() => ({
loadFromNode: vi.fn().mockResolvedValue(undefined)
}))
const mockCanvasHistory = vi.hoisted(() => ({
saveInitialState: vi.fn(),
clearStates: vi.fn()
}))
const initialMockStore = () =>
reactive({
activeLayer: 'mask' as 'mask' | 'rgb',
maskCanvas: null as HTMLCanvasElement | null,
rgbCanvas: null as HTMLCanvasElement | null,
imgCanvas: null as HTMLCanvasElement | null,
canvasContainer: null as HTMLElement | null,
canvasBackground: null as HTMLElement | null,
canvasHistory: mockCanvasHistory,
resetState: vi.fn()
})
let mockStore: ReturnType<typeof initialMockStore>
const mockDataStore = vi.hoisted(() => ({
reset: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
vi.mock('@/composables/maskeditor/useKeyboard', () => ({
useKeyboard: () => mockKeyboard
}))
vi.mock('@/composables/maskeditor/usePanAndZoom', () => ({
usePanAndZoom: () => mockPanZoom
}))
vi.mock('@/composables/maskeditor/useToolManager', () => ({
useToolManager: () => mockToolManager
}))
vi.mock('@/composables/maskeditor/useImageLoader', () => ({
useImageLoader: () => mockImageLoader
}))
vi.mock('@/composables/maskeditor/useMaskEditorLoader', () => ({
useMaskEditorLoader: () => mockMaskEditorLoader
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: () => mockDataStore
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
default: {
name: 'LoadingOverlayStub',
props: ['loading', 'size'],
template: `<div data-testid="loading-overlay" :data-loading="loading" />`
}
}))
vi.mock('@/components/maskeditor/ToolPanel.vue', () => ({
default: {
name: 'ToolPanelStub',
props: ['toolManager'],
template: '<div data-testid="tool-panel-stub" />'
}
}))
vi.mock('@/components/maskeditor/PointerZone.vue', () => ({
default: {
name: 'PointerZoneStub',
props: ['toolManager', 'panZoom'],
template: '<div data-testid="pointer-zone-stub" />'
}
}))
vi.mock('@/components/maskeditor/SidePanel.vue', () => ({
default: {
name: 'SidePanelStub',
props: ['toolManager'],
template: '<div data-testid="side-panel-stub" />'
}
}))
vi.mock('@/components/maskeditor/BrushCursor.vue', () => ({
default: {
name: 'BrushCursorStub',
props: ['containerRef'],
template: '<div data-testid="brush-cursor-stub" />'
}
}))
const observeSpy = vi.fn()
const disconnectSpy = vi.fn()
let lastResizeCallback: ResizeObserverCallback | null = null
class MockResizeObserver {
observe = observeSpy
disconnect = disconnectSpy
unobserve = vi.fn()
constructor(cb: ResizeObserverCallback) {
lastResizeCallback = cb
}
}
// `node` only flows into mocked `loader.loadFromNode`, so a typed sentinel
// with a stable identity is enough — we never read its fields.
const fakeNode = { id: 1, title: 'test-node' } as unknown as LGraphNode
const renderContent = () =>
render(MaskEditorContent, { props: { node: fakeNode } })
let originalResizeObserver: typeof ResizeObserver | undefined
describe('MaskEditorContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMockStore()
mockMaskEditorLoader.loadFromNode.mockResolvedValue(undefined)
mockImageLoader.loadImages.mockResolvedValue({ width: 100, height: 100 })
mockPanZoom.initializeCanvasPanZoom.mockResolvedValue(undefined)
mockBrushDrawing.initGPUResources.mockResolvedValue(undefined)
originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver
})
afterEach(() => {
globalThis.ResizeObserver =
originalResizeObserver as unknown as typeof ResizeObserver
})
describe('mount', () => {
it('should add keyboard listeners on mount', () => {
renderContent()
expect(mockKeyboard.addListeners).toHaveBeenCalledTimes(1)
})
it('should observe the container with a ResizeObserver', async () => {
renderContent()
await waitFor(() => expect(observeSpy).toHaveBeenCalledTimes(1))
})
it('should invalidate pan/zoom on resize', async () => {
renderContent()
await waitFor(() => expect(observeSpy).toHaveBeenCalled())
mockPanZoom.invalidatePanZoom.mockClear()
lastResizeCallback?.([], {} as ResizeObserver)
await new Promise((r) => setTimeout(r, 0))
expect(mockPanZoom.invalidatePanZoom).toHaveBeenCalledTimes(1)
})
it('should assign canvas refs to the store before init', async () => {
renderContent()
await waitFor(() => expect(mockStore.maskCanvas).not.toBeNull())
expect(mockStore.rgbCanvas).not.toBeNull()
expect(mockStore.imgCanvas).not.toBeNull()
expect(mockStore.canvasContainer).not.toBeNull()
expect(mockStore.canvasBackground).not.toBeNull()
})
})
describe('init flow', () => {
it('should run the init chain in the documented order', async () => {
renderContent()
await waitFor(() => {
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
})
const orderOf = (fn: { mock: { invocationCallOrder: number[] } }) =>
fn.mock.invocationCallOrder[0]
expect(orderOf(mockMaskEditorLoader.loadFromNode)).toBeLessThan(
orderOf(mockImageLoader.loadImages)
)
expect(orderOf(mockImageLoader.loadImages)).toBeLessThan(
orderOf(mockPanZoom.initializeCanvasPanZoom)
)
expect(orderOf(mockPanZoom.initializeCanvasPanZoom)).toBeLessThan(
orderOf(mockCanvasHistory.saveInitialState)
)
expect(orderOf(mockCanvasHistory.saveInitialState)).toBeLessThan(
orderOf(mockBrushDrawing.initGPUResources)
)
expect(orderOf(mockBrushDrawing.initGPUResources)).toBeLessThan(
orderOf(mockBrushDrawing.initPreviewCanvas)
)
expect(mockMaskEditorLoader.loadFromNode).toHaveBeenCalledWith(fakeNode)
})
it('should reveal the child UI components after init succeeds', async () => {
renderContent()
expect(await screen.findByTestId('tool-panel-stub')).toBeInTheDocument()
expect(await screen.findByTestId('pointer-zone-stub')).toBeInTheDocument()
expect(await screen.findByTestId('side-panel-stub')).toBeInTheDocument()
expect(await screen.findByTestId('brush-cursor-stub')).toBeInTheDocument()
})
it('should size the GPU preview canvas to match the mask canvas', async () => {
// Force the mask canvas to non-default dimensions during init so the
// assertion below proves the source actually copies width/height across
// (default 300x150 on both would make the test tautological).
mockBrushDrawing.initGPUResources.mockImplementationOnce(async () => {
if (mockStore.maskCanvas) {
mockStore.maskCanvas.width = 999
mockStore.maskCanvas.height = 777
}
})
renderContent()
await waitFor(() => {
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
})
const previewCanvas = mockBrushDrawing.initPreviewCanvas.mock
.calls[0][0] as HTMLCanvasElement
expect(previewCanvas.width).toBe(999)
expect(previewCanvas.height).toBe(777)
})
})
describe('init error', () => {
it('should close the dialog and log when loader.loadFromNode rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockMaskEditorLoader.loadFromNode.mockRejectedValueOnce(
new Error('load failed')
)
renderContent()
await waitFor(() => {
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
expect(errorSpy).toHaveBeenCalledWith(
'[MaskEditorContent] Initialization failed:',
expect.any(Error)
)
errorSpy.mockRestore()
})
it('should close the dialog and log when initializeCanvasPanZoom rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockPanZoom.initializeCanvasPanZoom.mockRejectedValueOnce(
new Error('panzoom failed')
)
renderContent()
await waitFor(() => {
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
expect(errorSpy).toHaveBeenCalledWith(
'[MaskEditorContent] Initialization failed:',
expect.any(Error)
)
errorSpy.mockRestore()
})
})
describe('drag handling', () => {
it('should prevent default on dragstart with Ctrl held', () => {
renderContent()
const root = screen.getByTestId('mask-editor-root')
const event = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
// happy-dom doesn't propagate ctrlKey through the DragEvent constructor.
Object.defineProperty(event, 'ctrlKey', { value: true })
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it('should not prevent default on plain dragstart without Ctrl', () => {
renderContent()
const root = screen.getByTestId('mask-editor-root')
const event = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
Object.defineProperty(event, 'ctrlKey', { value: false })
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})
describe('unmount cleanup', () => {
it('should run the full cleanup chain on unmount', async () => {
const { unmount } = renderContent()
await waitFor(() =>
expect(mockBrushDrawing.initGPUResources).toHaveBeenCalled()
)
unmount()
expect(mockBrushDrawing.saveBrushSettings).toHaveBeenCalledTimes(1)
expect(mockKeyboard.removeListeners).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.clearStates).toHaveBeenCalledTimes(1)
expect(mockStore.resetState).toHaveBeenCalledTimes(1)
expect(mockDataStore.reset).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,7 +1,6 @@
<template>
<div
ref="containerRef"
data-testid="mask-editor-root"
class="maskEditor-dialog-root flex size-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"

View File

@@ -1,87 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import PaintBucketSettingsPanel from '@/components/maskeditor/PaintBucketSettingsPanel.vue'
const mockStore = vi.hoisted(() => ({
paintBucketTolerance: 5,
fillOpacity: 100,
setPaintBucketTolerance: vi.fn(),
setFillOpacity: vi.fn()
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
default: {
name: 'SliderControlStub',
props: ['label', 'min', 'max', 'step', 'modelValue'],
emits: ['update:modelValue'],
template: `<button :aria-label="label" @click="$emit('update:modelValue', 42)">{{ modelValue }}</button>`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
paintBucketSettings: 'Paint Bucket Settings',
tolerance: 'Tolerance',
fillOpacity: 'Fill Opacity'
}
}
}
})
const renderPanel = () =>
render(PaintBucketSettingsPanel, { global: { plugins: [i18n] } })
describe('PaintBucketSettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.paintBucketTolerance = 5
mockStore.fillOpacity = 100
})
it('should bind tolerance slider to store value', () => {
mockStore.paintBucketTolerance = 87
renderPanel()
expect(
screen.getByRole('button', { name: 'Tolerance' }).textContent
).toContain('87')
})
it('should bind fill opacity slider to store value', () => {
mockStore.fillOpacity = 33
renderPanel()
expect(
screen.getByRole('button', { name: 'Fill Opacity' }).textContent
).toContain('33')
})
it('should call setPaintBucketTolerance when tolerance slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Tolerance' }))
expect(mockStore.setPaintBucketTolerance).toHaveBeenCalledWith(42)
})
it('should call setFillOpacity when fill opacity slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Fill Opacity' }))
expect(mockStore.setFillOpacity).toHaveBeenCalledWith(42)
})
})

View File

@@ -1,184 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { reactive, nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import PointerZone from '@/components/maskeditor/PointerZone.vue'
type ToolManager = ReturnType<typeof useToolManager>
type PanZoom = ReturnType<typeof usePanAndZoom>
const initialMock = () =>
reactive({
pointerZone: null as HTMLElement | null,
isPanning: false,
brushVisible: true
})
let mockStore: ReturnType<typeof initialMock>
const mockToolManager = vi.hoisted(() => ({
handlePointerDown: vi.fn().mockResolvedValue(undefined),
handlePointerMove: vi.fn().mockResolvedValue(undefined),
handlePointerUp: vi.fn().mockResolvedValue(undefined),
updateCursor: vi.fn()
}))
const mockPanZoom = vi.hoisted(() => ({
handleTouchStart: vi.fn(),
handleTouchMove: vi.fn().mockResolvedValue(undefined),
handleTouchEnd: vi.fn(),
zoom: vi.fn().mockResolvedValue(undefined),
updateCursorPosition: vi.fn()
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
const renderZone = () =>
render(PointerZone, {
props: {
toolManager: mockToolManager as unknown as ToolManager,
panZoom: mockPanZoom as unknown as PanZoom
}
})
const getZone = (): HTMLDivElement =>
screen.getByTestId('pointer-zone') as HTMLDivElement
describe('PointerZone', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('mount', () => {
it('should expose its root element to the store on mount', () => {
renderZone()
expect(mockStore.pointerZone).toBe(getZone())
})
})
describe('pointer event forwarding', () => {
it.each([
['pointerdown', 'handlePointerDown'],
['pointermove', 'handlePointerMove'],
['pointerup', 'handlePointerUp']
] as const)(
'should forward %s to toolManager.%s',
async (eventName, handlerName) => {
renderZone()
const zone = getZone()
zone.dispatchEvent(new Event(eventName, { bubbles: true }))
await nextTick()
expect(mockToolManager[handlerName]).toHaveBeenCalledTimes(1)
}
)
it('should hide brush and clear cursor on pointerleave', () => {
renderZone()
const zone = getZone()
zone.style.cursor = 'crosshair'
mockStore.brushVisible = true
zone.dispatchEvent(new Event('pointerleave', { bubbles: true }))
expect(mockStore.brushVisible).toBe(false)
expect(zone.style.cursor).toBe('')
})
it('should call toolManager.updateCursor on pointerenter', () => {
renderZone()
const zone = getZone()
zone.dispatchEvent(new Event('pointerenter', { bubbles: true }))
expect(mockToolManager.updateCursor).toHaveBeenCalledTimes(1)
})
})
describe('touch event forwarding', () => {
it.each([
['touchstart', 'handleTouchStart'],
['touchmove', 'handleTouchMove'],
['touchend', 'handleTouchEnd']
] as const)(
'should forward %s to panZoom.%s',
async (eventName, handlerName) => {
renderZone()
const zone = getZone()
zone.dispatchEvent(new Event(eventName, { bubbles: true }))
await nextTick()
expect(mockPanZoom[handlerName]).toHaveBeenCalledTimes(1)
}
)
})
describe('wheel handling', () => {
it('should call panZoom.zoom and update cursor position with the wheel coords', async () => {
renderZone()
const zone = getZone()
const event = new WheelEvent('wheel', { bubbles: true, deltaY: -1 })
// happy-dom doesn't propagate clientX/clientY through the WheelEvent
// constructor, so set them directly on the event instance.
Object.defineProperty(event, 'clientX', { value: 123 })
Object.defineProperty(event, 'clientY', { value: 45 })
zone.dispatchEvent(event)
// Flush awaited zoom() then the follow-up updateCursorPosition call
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockPanZoom.zoom).toHaveBeenCalledTimes(1)
expect(mockPanZoom.updateCursorPosition).toHaveBeenCalledWith({
x: 123,
y: 45
})
})
})
describe('isPanning watcher', () => {
it('should set cursor to "grabbing" when panning starts', async () => {
renderZone()
const zone = getZone()
mockStore.isPanning = true
await nextTick()
expect(zone.style.cursor).toBe('grabbing')
})
it('should call toolManager.updateCursor when panning ends', async () => {
renderZone()
mockStore.isPanning = true
await nextTick()
mockToolManager.updateCursor.mockClear()
mockStore.isPanning = false
await nextTick()
expect(mockToolManager.updateCursor).toHaveBeenCalledTimes(1)
})
})
describe('contextmenu', () => {
it('should prevent default on contextmenu', () => {
renderZone()
const zone = getZone()
const event = new Event('contextmenu', {
bubbles: true,
cancelable: true
})
zone.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
})
})

View File

@@ -1,7 +1,6 @@
<template>
<div
ref="pointerZoneRef"
data-testid="pointer-zone"
class="h-full w-[calc(100%-4rem-220px)]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"

View File

@@ -1,70 +0,0 @@
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SettingsPanelContainer from '@/components/maskeditor/SettingsPanelContainer.vue'
import { Tools } from '@/extensions/core/maskeditor/types'
const mockStore = vi.hoisted(() => ({
currentTool: 'pen' as Tools
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/components/maskeditor/BrushSettingsPanel.vue', () => ({
default: {
name: 'BrushSettingsPanelStub',
template: '<div>brush-panel</div>'
}
}))
vi.mock('@/components/maskeditor/ColorSelectSettingsPanel.vue', () => ({
default: {
name: 'ColorSelectSettingsPanelStub',
template: '<div>color-panel</div>'
}
}))
vi.mock('@/components/maskeditor/PaintBucketSettingsPanel.vue', () => ({
default: {
name: 'PaintBucketSettingsPanelStub',
template: '<div>bucket-panel</div>'
}
}))
describe('SettingsPanelContainer', () => {
beforeEach(() => {
mockStore.currentTool = Tools.MaskPen
})
it('should render PaintBucketSettingsPanel when current tool is MaskBucket', () => {
mockStore.currentTool = Tools.MaskBucket
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('bucket-panel')
})
it('should render ColorSelectSettingsPanel when current tool is MaskColorFill', () => {
mockStore.currentTool = Tools.MaskColorFill
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('color-panel')
})
it('should render BrushSettingsPanel for any other tool', () => {
mockStore.currentTool = Tools.MaskPen
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('brush-panel')
})
it('should render BrushSettingsPanel for Eraser', () => {
mockStore.currentTool = Tools.Eraser
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('brush-panel')
})
it('should render BrushSettingsPanel for PaintPen', () => {
mockStore.currentTool = Tools.PaintPen
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('brush-panel')
})
})

View File

@@ -1,49 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import SidePanel from '@/components/maskeditor/SidePanel.vue'
type ToolManager = ReturnType<typeof useToolManager>
vi.mock('@/components/maskeditor/SettingsPanelContainer.vue', () => ({
default: {
name: 'SettingsPanelContainerStub',
template: '<div data-testid="settings-panel-stub">settings</div>'
}
}))
vi.mock('@/components/maskeditor/ImageLayerSettingsPanel.vue', () => ({
default: {
name: 'ImageLayerSettingsPanelStub',
props: ['toolManager'],
template:
'<div data-testid="image-layer-stub">image-layer:{{ toolManager?.tag ?? "none" }}</div>'
}
}))
describe('SidePanel', () => {
it('should render both child panels', () => {
render(SidePanel)
expect(screen.getByTestId('settings-panel-stub')).toBeInTheDocument()
expect(screen.getByTestId('image-layer-stub')).toBeInTheDocument()
})
it('should forward toolManager prop to ImageLayerSettingsPanel', () => {
const toolManager = { tag: 'my-tool-manager' } as unknown as ToolManager
render(SidePanel, { props: { toolManager } })
expect(screen.getByTestId('image-layer-stub').textContent).toContain(
'my-tool-manager'
)
})
it('should render with no toolManager passed through', () => {
render(SidePanel)
expect(screen.getByTestId('image-layer-stub').textContent).toContain('none')
})
})

View File

@@ -1,155 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import ToolPanel from '@/components/maskeditor/ToolPanel.vue'
import { Tools, allTools } from '@/extensions/core/maskeditor/types'
type ToolManager = ReturnType<typeof useToolManager>
vi.mock('@/extensions/core/maskeditor/constants', () => ({
iconsHtml: {
pen: '<svg data-testid="icon-pen" />',
rgbPaint: '<svg data-testid="icon-rgbPaint" />',
eraser: '<svg data-testid="icon-eraser" />',
paintBucket: '<svg data-testid="icon-paintBucket" />',
colorSelect: '<svg data-testid="icon-colorSelect" />'
}
}))
const initialMock = () =>
reactive({
currentTool: Tools.MaskPen as Tools,
displayZoomRatio: 1,
image: null as { width: number; height: number } | null,
resetZoom: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
clickToResetZoom: 'Click to reset zoom'
}
}
}
})
const mockToolManager = vi.hoisted(() => ({
switchTool: vi.fn()
}))
const renderPanel = () =>
render(ToolPanel, {
global: { plugins: [i18n] },
props: { toolManager: mockToolManager as unknown as ToolManager }
})
const getToolButton = (tool: Tools): HTMLElement => {
const btns = screen.getAllByTestId('tool-button')
const match = btns.find((b) => b.dataset.tool === tool)
if (!match) throw new Error(`tool button for "${tool}" not found`)
return match
}
describe('ToolPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('tool list rendering', () => {
it('should render one button per tool in allTools', () => {
renderPanel()
expect(screen.getAllByTestId('tool-button')).toHaveLength(allTools.length)
})
it('should render the icon HTML for each tool', () => {
renderPanel()
for (const tool of allTools) {
expect(screen.getByTestId(`icon-${tool}`)).toBeInTheDocument()
}
})
})
describe('current tool highlight', () => {
it.each([Tools.MaskPen, Tools.Eraser, Tools.PaintPen] as const)(
'should mark the %s button as selected when it is the current tool',
(tool) => {
mockStore.currentTool = tool
renderPanel()
expect(getToolButton(tool).className).toContain(
'maskEditor_toolPanelContainerSelected'
)
}
)
it('should not mark non-current tools as selected', () => {
mockStore.currentTool = Tools.MaskPen
renderPanel()
for (const tool of allTools) {
if (tool === Tools.MaskPen) continue
expect(getToolButton(tool).className).not.toContain(
'maskEditor_toolPanelContainerSelected'
)
}
})
})
describe('tool selection', () => {
it('should call toolManager.switchTool with the clicked tool', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(getToolButton(Tools.Eraser))
expect(mockToolManager.switchTool).toHaveBeenCalledWith(Tools.Eraser)
})
})
describe('zoom indicator', () => {
it('should render rounded zoom percentage from displayZoomRatio', () => {
mockStore.displayZoomRatio = 1.236
renderPanel()
expect(screen.getByTestId('zoom-percentage').textContent).toBe('124%')
})
it('should render image dimensions when an image is loaded', () => {
mockStore.image = { width: 800, height: 600 }
renderPanel()
expect(screen.getByTestId('zoom-dimensions').textContent).toBe('800x600')
})
it('should render a single-space placeholder when no image', () => {
mockStore.image = null
renderPanel()
expect(screen.getByTestId('zoom-dimensions').textContent).toBe(' ')
})
it('should call resetZoom when the zoom indicator is clicked', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByTestId('zoom-percentage'))
expect(mockStore.resetZoom).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -4,8 +4,6 @@
<div
v-for="tool in allTools"
:key="tool"
data-testid="tool-button"
:data-tool="tool"
:class="[
'maskEditor_toolPanelContainer hover:bg-secondary-background-hover',
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
@@ -25,12 +23,8 @@
:title="t('maskEditor.clickToResetZoom')"
@click="onResetZoom"
>
<span data-testid="zoom-percentage" class="text-sm text-text-secondary">{{
zoomText
}}</span>
<span data-testid="zoom-dimensions" class="text-xs text-text-secondary">{{
dimensionsText
}}</span>
<span class="text-sm text-text-secondary">{{ zoomText }}</span>
<span class="text-xs text-text-secondary">{{ dimensionsText }}</span>
</div>
</div>
</template>

View File

@@ -1,79 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import DropdownControl from './DropdownControl.vue'
const renderComponent = (
props: {
label?: string
options?: string[] | { label: string; value: string | number }[]
modelValue?: string | number
} = {},
onUpdate?: (value: string | number) => void
) => {
const user = userEvent.setup()
const utils = render(DropdownControl, {
props: {
label: 'Mode',
options: ['One', 'Two', 'Three'],
modelValue: 'One',
...props,
'onUpdate:modelValue': onUpdate
}
})
return { user, ...utils }
}
describe('DropdownControl', () => {
it('should render the label', () => {
renderComponent({ label: 'Brush Mode' })
expect(screen.getByText('Brush Mode')).toBeInTheDocument()
})
it('should expand string options to {label,value} pairs', () => {
renderComponent({ options: ['Alpha', 'Beta'], modelValue: 'Alpha' })
const select = screen.getByRole('combobox') as HTMLSelectElement
const values = Array.from(select.options).map((o) => o.value)
const labels = Array.from(select.options).map((o) => o.textContent?.trim())
expect(values).toEqual(['Alpha', 'Beta'])
expect(labels).toEqual(['Alpha', 'Beta'])
})
it('should preserve {label,value} options as-is', () => {
renderComponent({
options: [
{ label: 'High', value: 1 },
{ label: 'Low', value: 2 }
],
modelValue: 1
})
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(Array.from(select.options).map((o) => o.value)).toEqual(['1', '2'])
expect(
Array.from(select.options).map((o) => o.textContent?.trim())
).toEqual(['High', 'Low'])
})
it('should reflect modelValue as the selected option', () => {
renderComponent({ options: ['One', 'Two'], modelValue: 'Two' })
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
'Two'
)
})
it('should emit update:modelValue with the chosen string value', async () => {
const onUpdate = vi.fn()
const { user } = renderComponent(
{ options: ['One', 'Two', 'Three'], modelValue: 'One' },
onUpdate
)
await user.selectOptions(screen.getByRole('combobox'), 'Three')
expect(onUpdate).toHaveBeenCalledWith('Three')
})
})

View File

@@ -1,64 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import SliderControl from './SliderControl.vue'
const renderComponent = (
props: {
label?: string
min?: number
max?: number
step?: number
modelValue?: number
} = {},
onUpdate?: (value: number) => void
) => {
return render(SliderControl, {
props: {
label: 'Brush Size',
min: 1,
max: 100,
modelValue: 10,
...props,
'onUpdate:modelValue': onUpdate
}
})
}
const setSliderValue = (input: HTMLInputElement, value: string): void => {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('SliderControl', () => {
it('should render the label', () => {
renderComponent({ label: 'Hardness' })
expect(screen.getByText('Hardness')).toBeInTheDocument()
})
it('should expose min, max, step and modelValue on the input', () => {
renderComponent({ min: 0, max: 50, step: 5, modelValue: 25 })
const input = screen.getByRole('slider') as HTMLInputElement
expect(input.min).toBe('0')
expect(input.max).toBe('50')
expect(input.step).toBe('5')
expect(input.value).toBe('25')
})
it('should default step to 1 when not provided', () => {
renderComponent({ min: 0, max: 10, modelValue: 5 })
expect((screen.getByRole('slider') as HTMLInputElement).step).toBe('1')
})
it('should emit update:modelValue with a number when input changes', () => {
const onUpdate = vi.fn()
renderComponent({ min: 1, max: 100, modelValue: 10 }, onUpdate)
setSliderValue(screen.getByRole('slider') as HTMLInputElement, '42')
expect(onUpdate).toHaveBeenLastCalledWith(42)
expect(typeof onUpdate.mock.calls.at(-1)![0]).toBe('number')
})
})

View File

@@ -1,60 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ToggleControl from './ToggleControl.vue'
const renderComponent = (
props: { label?: string; modelValue?: boolean } = {},
onUpdate?: (value: boolean) => void
) => {
const user = userEvent.setup()
const utils = render(ToggleControl, {
props: {
label: 'Smoothing',
modelValue: false,
...props,
'onUpdate:modelValue': onUpdate
}
})
return { user, ...utils }
}
describe('ToggleControl', () => {
it('should render the label', () => {
renderComponent({ label: 'Pressure Sensitivity' })
expect(screen.getByText('Pressure Sensitivity')).toBeInTheDocument()
})
it('should reflect modelValue=false as unchecked', () => {
renderComponent({ modelValue: false })
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(
false
)
})
it('should reflect modelValue=true as checked', () => {
renderComponent({ modelValue: true })
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(
true
)
})
it('should emit update:modelValue=true when toggled on', async () => {
const onUpdate = vi.fn()
const { user } = renderComponent({ modelValue: false }, onUpdate)
await user.click(screen.getByRole('checkbox'))
expect(onUpdate).toHaveBeenCalledWith(true)
})
it('should emit update:modelValue=false when toggled off', async () => {
const onUpdate = vi.fn()
const { user } = renderComponent({ modelValue: true }, onUpdate)
await user.click(screen.getByRole('checkbox'))
expect(onUpdate).toHaveBeenCalledWith(false)
})
})

View File

@@ -1,262 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
const mockCanvasHistory = vi.hoisted(() => ({
undo: vi.fn(),
redo: vi.fn()
}))
const initialMock = () =>
reactive({
canvasHistory: mockCanvasHistory,
brushVisible: true,
triggerClear: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
const mockCanvasTools = vi.hoisted(() => ({
invertMask: vi.fn(),
clearMask: vi.fn()
}))
const mockCanvasTransform = vi.hoisted(() => ({
rotateCounterclockwise: vi.fn().mockResolvedValue(undefined),
rotateClockwise: vi.fn().mockResolvedValue(undefined),
mirrorHorizontal: vi.fn().mockResolvedValue(undefined),
mirrorVertical: vi.fn().mockResolvedValue(undefined)
}))
const mockSaver = vi.hoisted(() => ({
save: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
useCanvasTools: () => mockCanvasTools
}))
vi.mock('@/composables/maskeditor/useCanvasTransform', () => ({
useCanvasTransform: () => mockCanvasTransform
}))
vi.mock('@/composables/maskeditor/useMaskEditorSaver', () => ({
useMaskEditorSaver: () => mockSaver
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
name: 'ButtonStub',
props: ['variant', 'disabled'],
template:
'<button :data-variant="variant" :disabled="disabled"><slot /></button>'
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
save: 'Save',
saving: 'Saving',
cancel: 'Cancel'
},
maskEditor: {
title: 'Mask Editor',
invert: 'Invert',
clear: 'Clear',
undo: 'Undo',
redo: 'Redo',
rotateLeft: 'Rotate Left',
rotateRight: 'Rotate Right',
mirrorHorizontal: 'Mirror Horizontal',
mirrorVertical: 'Mirror Vertical'
}
}
}
})
const renderHeader = () => render(TopBarHeader, { global: { plugins: [i18n] } })
describe('TopBarHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('title', () => {
it('should render the localized title', () => {
renderHeader()
expect(screen.getByText('Mask Editor')).toBeInTheDocument()
})
})
describe('history buttons', () => {
it('should call canvasHistory.undo when undo button is clicked', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Undo' }))
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
})
it('should call canvasHistory.redo when redo button is clicked', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Redo' }))
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
})
})
describe('canvas transform buttons', () => {
it.each([
['Rotate Left', 'rotateCounterclockwise'],
['Rotate Right', 'rotateClockwise'],
['Mirror Horizontal', 'mirrorHorizontal'],
['Mirror Vertical', 'mirrorVertical']
] as const)(
'should call canvasTransform.%s when %s button is clicked',
async (label, method) => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: label }))
expect(mockCanvasTransform[method]).toHaveBeenCalledTimes(1)
}
)
it.each([
['Rotate Left', 'rotateCounterclockwise', 'Rotate left failed:'],
['Rotate Right', 'rotateClockwise', 'Rotate right failed:'],
['Mirror Horizontal', 'mirrorHorizontal', 'Mirror horizontal failed:'],
['Mirror Vertical', 'mirrorVertical', 'Mirror vertical failed:']
] as const)(
'should swallow and log errors from %s',
async (label, method, expectedMsg) => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockCanvasTransform[method].mockRejectedValueOnce(new Error('boom'))
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: label }))
expect(errorSpy).toHaveBeenCalledWith(
`[TopBarHeader] ${expectedMsg}`,
expect.any(Error)
)
errorSpy.mockRestore()
}
)
})
describe('mask edit buttons', () => {
it('should call canvasTools.invertMask on Invert click', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Invert' }))
expect(mockCanvasTools.invertMask).toHaveBeenCalledTimes(1)
})
it('should call clearMask and store.triggerClear on Clear click', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Clear' }))
expect(mockCanvasTools.clearMask).toHaveBeenCalledTimes(1)
expect(mockStore.triggerClear).toHaveBeenCalledTimes(1)
})
})
describe('save', () => {
it('should hide brush, save, and close the dialog on success', async () => {
const user = userEvent.setup()
renderHeader()
mockStore.brushVisible = true
await user.click(screen.getByRole('button', { name: /save/i }))
expect(mockStore.brushVisible).toBe(false)
expect(mockSaver.save).toHaveBeenCalledTimes(1)
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
it('should switch the button text to "Saving" and disable the button while saving', async () => {
let resolve!: () => void
mockSaver.save.mockReturnValueOnce(
new Promise<void>((r) => {
resolve = r
})
)
const user = userEvent.setup()
renderHeader()
const clickPromise = user.click(
screen.getByRole('button', { name: /save/i })
)
const savingBtn = await screen.findByRole('button', { name: 'Saving' })
expect(savingBtn).toBeDisabled()
resolve()
await clickPromise
})
it('should restore brush + button state and log on save failure', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSaver.save.mockRejectedValueOnce(new Error('save failed'))
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: /save/i }))
expect(mockStore.brushVisible).toBe(true)
expect(errorSpy).toHaveBeenCalledWith(
'[TopBarHeader] Save failed:',
expect.any(Error)
)
expect(mockDialogStore.closeDialog).not.toHaveBeenCalled()
// After failure, the Save button reads "Save" again (not "Saving")
expect(
screen.getByRole('button', { name: /save/i }).textContent?.trim()
).toBe('Save')
errorSpy.mockRestore()
})
})
describe('cancel', () => {
it('should close the dialog with the global-mask-editor key', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-mask-editor'
})
})
})
})

View File

@@ -1,380 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCoordinateTransform } from '@/composables/maskeditor/useCoordinateTransform'
type MockStore = {
pointerZone: HTMLElement | null
canvasContainer: HTMLElement | null
maskCanvas: HTMLCanvasElement | null
}
const mockStore: MockStore = {
pointerZone: null,
canvasContainer: null,
maskCanvas: null
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
vi.mock('@vueuse/core', () => ({
createSharedComposable: <T extends (...args: unknown[]) => unknown>(fn: T) =>
fn
}))
const createElementWithRect = (rect: Partial<DOMRect>): HTMLElement => {
const el = document.createElement('div')
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...rect
} as DOMRect)
return el
}
const createCanvasWithRect = (
rect: Partial<DOMRect>,
width: number,
height: number
): HTMLCanvasElement => {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...rect
} as DOMRect)
return canvas
}
describe('useCoordinateTransform', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.pointerZone = null
mockStore.canvasContainer = null
mockStore.maskCanvas = null
})
describe('screenToCanvas', () => {
it('should return canvas coordinates when display size matches canvas size', () => {
mockStore.pointerZone = createElementWithRect({
left: 100,
top: 50,
width: 200,
height: 200
})
mockStore.canvasContainer = createElementWithRect({
left: 100,
top: 50,
width: 200,
height: 200
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 100, top: 50, width: 200, height: 200 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 50, y: 30 })).toEqual({
x: 50,
y: 30
})
})
it('should apply scale when canvas is rendered smaller than its bitmap', () => {
mockStore.pointerZone = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 100
})
mockStore.canvasContainer = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 100
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 0, top: 0, width: 100, height: 100 },
400,
400
)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 25, y: 50 })).toEqual({
x: 100,
y: 200
})
})
it('should account for pointerZone offset relative to canvasContainer', () => {
mockStore.pointerZone = createElementWithRect({
left: 200,
top: 100,
width: 200,
height: 200
})
mockStore.canvasContainer = createElementWithRect({
left: 150,
top: 80,
width: 200,
height: 200
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 150, top: 80, width: 200, height: 200 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 0, y: 0 })).toEqual({
x: 50,
y: 20
})
})
it('should support non-uniform scale factors', () => {
mockStore.pointerZone = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 50
})
mockStore.canvasContainer = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 50
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 0, top: 0, width: 100, height: 50 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 10, y: 10 })).toEqual({
x: 20,
y: 40
})
})
it('should return zero point and warn when pointerZone is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.canvasContainer = createElementWithRect({})
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalledWith(
'screenToCanvas called before elements are available'
)
warnSpy.mockRestore()
})
it('should return zero point when canvasContainer is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.pointerZone = createElementWithRect({})
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('should return zero point when maskCanvas is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.pointerZone = createElementWithRect({})
mockStore.canvasContainer = createElementWithRect({})
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
})
describe('canvasToScreen', () => {
it('should return pointerZone-relative coordinates when display matches bitmap', () => {
mockStore.pointerZone = createElementWithRect({
left: 100,
top: 50,
width: 200,
height: 200
})
mockStore.canvasContainer = createElementWithRect({
left: 100,
top: 50,
width: 200,
height: 200
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 100, top: 50, width: 200, height: 200 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 50, y: 30 })).toEqual({
x: 50,
y: 30
})
})
it('should apply inverse scale when canvas bitmap is larger than display', () => {
mockStore.pointerZone = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 100
})
mockStore.canvasContainer = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 100
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 0, top: 0, width: 100, height: 100 },
400,
400
)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 100, y: 200 })).toEqual({
x: 25,
y: 50
})
})
it('should account for pointerZone offset relative to canvasContainer', () => {
mockStore.pointerZone = createElementWithRect({
left: 200,
top: 100,
width: 200,
height: 200
})
mockStore.canvasContainer = createElementWithRect({
left: 150,
top: 80,
width: 200,
height: 200
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 150, top: 80, width: 200, height: 200 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 50, y: 20 })).toEqual({ x: 0, y: 0 })
})
it('should round-trip with screenToCanvas', () => {
mockStore.pointerZone = createElementWithRect({
left: 50,
top: 25,
width: 300,
height: 300
})
mockStore.canvasContainer = createElementWithRect({
left: 70,
top: 40,
width: 300,
height: 300
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 70, top: 40, width: 300, height: 300 },
600,
600
)
const transform = useCoordinateTransform()
const original = { x: 123, y: 87 }
const canvasPoint = transform.screenToCanvas(original)
const back = transform.canvasToScreen(canvasPoint)
expect(back.x).toBeCloseTo(original.x)
expect(back.y).toBeCloseTo(original.y)
})
it('should return zero point and warn when pointerZone is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.canvasContainer = createElementWithRect({})
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalledWith(
'canvasToScreen called before elements are available'
)
warnSpy.mockRestore()
})
it('should return zero point when canvasContainer is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.pointerZone = createElementWithRect({})
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('should return zero point when maskCanvas is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.pointerZone = createElementWithRect({})
mockStore.canvasContainer = createElementWithRect({})
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
})
})

View File

@@ -1,209 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
type MockCanvasHistory = {
undo: ReturnType<typeof vi.fn>
redo: ReturnType<typeof vi.fn>
}
type MockStore = {
canvasHistory: MockCanvasHistory
}
const { mockStore, mockCanvasHistory } = vi.hoisted(() => {
const mockCanvasHistory: MockCanvasHistory = {
undo: vi.fn(),
redo: vi.fn()
}
const mockStore: MockStore = {
canvasHistory: mockCanvasHistory
}
return { mockStore, mockCanvasHistory }
})
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
const dispatchKeyDown = (
init: KeyboardEventInit & { key: string }
): KeyboardEvent => {
const event = new KeyboardEvent('keydown', { cancelable: true, ...init })
document.dispatchEvent(event)
return event
}
const dispatchKeyUp = (key: string): void => {
document.dispatchEvent(new KeyboardEvent('keyup', { key }))
}
describe('useKeyboard', () => {
let keyboard: ReturnType<typeof useKeyboard>
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
keyboard = useKeyboard()
keyboard.addListeners()
})
afterEach(() => {
keyboard.removeListeners()
})
describe('isKeyDown', () => {
it('should return false for keys that have not been pressed', () => {
expect(keyboard.isKeyDown('a')).toBe(false)
})
it('should return true after a key is pressed', () => {
dispatchKeyDown({ key: 'a' })
expect(keyboard.isKeyDown('a')).toBe(true)
})
it('should return false after a pressed key is released', () => {
dispatchKeyDown({ key: 'a' })
dispatchKeyUp('a')
expect(keyboard.isKeyDown('a')).toBe(false)
})
it('should track multiple keys independently', () => {
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'b' })
expect(keyboard.isKeyDown('a')).toBe(true)
expect(keyboard.isKeyDown('b')).toBe(true)
dispatchKeyUp('a')
expect(keyboard.isKeyDown('a')).toBe(false)
expect(keyboard.isKeyDown('b')).toBe(true)
})
})
describe('handleKeyDown', () => {
it('should not duplicate the same key on repeated keydown events', () => {
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'a' })
dispatchKeyUp('a')
expect(keyboard.isKeyDown('a')).toBe(false)
})
it('should prevent default and blur the active element on space', () => {
const input = document.createElement('input')
document.body.appendChild(input)
input.focus()
const blurSpy = vi.spyOn(input, 'blur')
const event = dispatchKeyDown({ key: ' ' })
expect(event.defaultPrevented).toBe(true)
expect(blurSpy).toHaveBeenCalledTimes(1)
expect(keyboard.isKeyDown(' ')).toBe(true)
})
it('should not throw when activeElement is null', () => {
Object.defineProperty(document, 'activeElement', {
value: null,
configurable: true
})
try {
expect(() => dispatchKeyDown({ key: ' ' })).not.toThrow()
} finally {
Reflect.deleteProperty(document, 'activeElement')
}
})
it('should call undo on Ctrl+Z without shift', () => {
dispatchKeyDown({ key: 'z', ctrlKey: true })
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
})
it('should call undo on Meta+Z without shift', () => {
dispatchKeyDown({ key: 'z', metaKey: true })
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
})
it('should call redo on Ctrl+Shift+Z', () => {
dispatchKeyDown({ key: 'Z', ctrlKey: true, shiftKey: true })
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
})
it('should call redo on Ctrl+Y', () => {
dispatchKeyDown({ key: 'y', ctrlKey: true })
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
})
it('should not trigger undo or redo when alt is held', () => {
dispatchKeyDown({ key: 'z', ctrlKey: true, altKey: true })
dispatchKeyDown({ key: 'y', ctrlKey: true, altKey: true })
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
})
it('should not trigger undo or redo without ctrl or meta', () => {
dispatchKeyDown({ key: 'z' })
dispatchKeyDown({ key: 'y' })
dispatchKeyDown({ key: 'Z', shiftKey: true })
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
})
it('should ignore Ctrl+Shift+Y', () => {
dispatchKeyDown({ key: 'Y', ctrlKey: true, shiftKey: true })
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
})
})
describe('addListeners', () => {
it('should clear all tracked keys when the window loses focus', () => {
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'b' })
window.dispatchEvent(new Event('blur'))
expect(keyboard.isKeyDown('a')).toBe(false)
expect(keyboard.isKeyDown('b')).toBe(false)
})
})
describe('removeListeners', () => {
it('should stop responding to keyboard events after removal', () => {
keyboard.removeListeners()
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'z', ctrlKey: true })
expect(keyboard.isKeyDown('a')).toBe(false)
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
})
it('should stop clearing keys on window blur after removal', () => {
dispatchKeyDown({ key: 'a' })
keyboard.removeListeners()
window.dispatchEvent(new Event('blur'))
expect(keyboard.isKeyDown('a')).toBe(true)
})
})
})

View File

@@ -1,118 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const mockDialogStore = vi.hoisted(() => ({
showDialog: vi.fn()
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/components/maskeditor/dialog/TopBarHeader.vue', () => ({
default: { name: 'TopBarHeaderStub' }
}))
vi.mock('@/components/maskeditor/MaskEditorContent.vue', () => ({
default: { name: 'MaskEditorContentStub' }
}))
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
type NodeShape = {
imgs?: unknown[]
previewMediaType?: string
}
const nodeWithImage = (overrides: NodeShape = {}): LGraphNode =>
({
imgs: [new Image()],
previewMediaType: undefined,
...overrides
}) as unknown as LGraphNode
describe('useMaskEditor', () => {
let errorSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
describe('openMaskEditor', () => {
it('should open the dialog with the node forwarded as a prop', () => {
const node = nodeWithImage()
useMaskEditor().openMaskEditor(node)
expect(mockDialogStore.showDialog).toHaveBeenCalledTimes(1)
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'global-mask-editor',
props: { node }
})
)
})
it('should pass header and content components to the dialog', () => {
useMaskEditor().openMaskEditor(nodeWithImage())
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
expect.objectContaining({
headerComponent: expect.anything(),
component: expect.anything()
})
)
})
it('should configure modal dialog with maximizable and closable flags', () => {
useMaskEditor().openMaskEditor(nodeWithImage())
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
expect.objectContaining({
dialogComponentProps: expect.objectContaining({
modal: true,
maximizable: true,
closable: true
})
})
)
})
it('should accept a node whose previewMediaType is "image" without imgs', () => {
const node = nodeWithImage({
imgs: undefined,
previewMediaType: 'image'
})
useMaskEditor().openMaskEditor(node)
expect(mockDialogStore.showDialog).toHaveBeenCalledTimes(1)
})
it('should log and bail when node is null', () => {
useMaskEditor().openMaskEditor(null as unknown as LGraphNode)
expect(errorSpy).toHaveBeenCalledWith('[MaskEditor] No node provided')
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
})
it('should log and bail when node has neither imgs nor image preview', () => {
const node = nodeWithImage({ imgs: [], previewMediaType: undefined })
useMaskEditor().openMaskEditor(node)
expect(errorSpy).toHaveBeenCalledWith('[MaskEditor] Node has no images')
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
})
it('should bail when node has empty imgs and a non-image preview type', () => {
const node = nodeWithImage({ imgs: [], previewMediaType: 'video' })
useMaskEditor().openMaskEditor(node)
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,517 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, reactive } from 'vue'
import type { EffectScope } from 'vue'
import { useBrushDrawing } from '@/composables/maskeditor/useBrushDrawing'
import { useToolManager } from '@/composables/maskeditor/useToolManager'
import { Tools } from '@/extensions/core/maskeditor/types'
type MockStore = {
currentTool: Tools
activeLayer: 'mask' | 'rgb'
pointerZone: HTMLElement | null
brushVisible: boolean
brushPreviewGradientVisible: boolean
isAdjustingBrush: boolean
isPanning: boolean
}
const mockStore: MockStore = reactive({
currentTool: Tools.MaskPen,
activeLayer: 'mask',
pointerZone: null,
brushVisible: true,
brushPreviewGradientVisible: false,
isAdjustingBrush: false,
isPanning: false
}) as MockStore
const mockBrushDrawing = {
startDrawing: vi.fn().mockResolvedValue(undefined),
handleDrawing: vi.fn().mockResolvedValue(undefined),
drawEnd: vi.fn().mockResolvedValue(undefined),
startBrushAdjustment: vi.fn().mockResolvedValue(undefined),
handleBrushAdjustment: vi.fn().mockResolvedValue(undefined)
}
const mockCanvasTools = {
paintBucketFill: vi.fn(),
colorSelectFill: vi.fn().mockResolvedValue(undefined),
clearLastColorSelectPoint: vi.fn()
}
const mockCoordinateTransform = {
screenToCanvas: vi.fn((p: { x: number; y: number }) => ({
x: p.x * 2,
y: p.y * 2
})),
canvasToScreen: vi.fn()
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
vi.mock('@/composables/maskeditor/useBrushDrawing', () => ({
useBrushDrawing: vi.fn(() => mockBrushDrawing)
}))
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
useCanvasTools: vi.fn(() => mockCanvasTools)
}))
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
useCoordinateTransform: vi.fn(() => mockCoordinateTransform)
}))
vi.mock('@/scripts/app', () => ({
app: {
extensionManager: {
setting: {
get: vi.fn((key: string) => {
if (key === 'Comfy.MaskEditor.UseDominantAxis') return false
if (key === 'Comfy.MaskEditor.BrushAdjustmentSpeed') return 1
return undefined
})
}
}
}
}))
const mockKeyboard = {
isKeyDown: vi.fn().mockReturnValue(false),
addListeners: vi.fn(),
removeListeners: vi.fn()
}
const mockPanZoom = {
initializeCanvasPanZoom: vi.fn(),
handlePanStart: vi.fn(),
handlePanMove: vi.fn().mockResolvedValue(undefined),
handleTouchStart: vi.fn(),
handleTouchMove: vi.fn(),
handleTouchEnd: vi.fn(),
updateCursorPosition: vi.fn(),
zoom: vi.fn(),
invalidatePanZoom: vi.fn(),
addPenPointerId: vi.fn(),
removePenPointerId: vi.fn()
}
const pointerEvent = (
init: Partial<PointerEvent> & { pointerType?: string }
): PointerEvent => {
return {
preventDefault: vi.fn(),
pointerId: 1,
pointerType: 'mouse',
button: 0,
buttons: 0,
clientX: 0,
clientY: 0,
offsetX: 0,
offsetY: 0,
altKey: false,
...init
} as unknown as PointerEvent
}
let scope: EffectScope | null = null
const setup = (): ReturnType<typeof useToolManager> => {
scope = effectScope()
return scope.run(() =>
useToolManager(
mockKeyboard as unknown as Parameters<typeof useToolManager>[0],
mockPanZoom as unknown as Parameters<typeof useToolManager>[1]
)
)!
}
describe('useToolManager', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.currentTool = Tools.MaskPen
mockStore.activeLayer = 'mask'
mockStore.pointerZone = document.createElement('div')
mockStore.brushVisible = true
mockStore.brushPreviewGradientVisible = false
mockStore.isAdjustingBrush = false
mockStore.isPanning = false
mockKeyboard.isKeyDown.mockReturnValue(false)
})
afterEach(() => {
scope?.stop()
scope = null
})
describe('useBrushDrawing factory', () => {
it('should construct useBrushDrawing with settings from the extension manager', () => {
setup()
expect(useBrushDrawing).toHaveBeenCalledWith({
useDominantAxis: false,
brushAdjustmentSpeed: 1
})
})
})
describe('switchTool', () => {
it('should set the current tool on the store', () => {
const tm = setup()
tm.switchTool(Tools.Eraser)
expect(mockStore.currentTool).toBe(Tools.Eraser)
})
it('should update activeLayer to "rgb" when switching to PaintPen', () => {
const tm = setup()
tm.switchTool(Tools.PaintPen)
expect(mockStore.activeLayer).toBe('rgb')
})
it('should update activeLayer to "mask" when switching to MaskPen', () => {
const tm = setup()
mockStore.activeLayer = 'rgb'
tm.switchTool(Tools.MaskPen)
expect(mockStore.activeLayer).toBe('mask')
})
it('should set custom cursor and hide brush for MaskBucket', () => {
const tm = setup()
tm.switchTool(Tools.MaskBucket)
expect(mockStore.brushVisible).toBe(false)
expect(mockStore.pointerZone!.style.cursor).toContain('paintBucket.png')
})
it('should reset cursor to "none" and show brush for tools without custom cursor', () => {
const tm = setup()
tm.switchTool(Tools.MaskBucket)
expect(mockStore.brushVisible).toBe(false)
tm.switchTool(Tools.MaskPen)
expect(mockStore.brushVisible).toBe(true)
expect(mockStore.pointerZone!.style.cursor).toBe('none')
})
it('should not touch cursor when pointerZone is missing', () => {
const tm = setup()
mockStore.pointerZone = null
expect(() => tm.switchTool(Tools.MaskBucket)).not.toThrow()
})
})
describe('setActiveLayer', () => {
it('should switch from mask-only tool to PaintPen when activating rgb layer', () => {
const tm = setup()
mockStore.currentTool = Tools.MaskBucket
tm.setActiveLayer('rgb')
expect(mockStore.activeLayer).toBe('rgb')
expect(mockStore.currentTool).toBe(Tools.PaintPen)
})
it('should switch from PaintPen to MaskPen when activating mask layer', () => {
const tm = setup()
mockStore.currentTool = Tools.PaintPen
tm.setActiveLayer('mask')
expect(mockStore.activeLayer).toBe('mask')
expect(mockStore.currentTool).toBe(Tools.MaskPen)
})
it('should leave a non-mask-only tool alone when activating rgb', () => {
const tm = setup()
mockStore.currentTool = Tools.Eraser
tm.setActiveLayer('rgb')
expect(mockStore.currentTool).toBe(Tools.Eraser)
})
})
describe('updateCursor', () => {
it('should hide brush and set custom cursor for tools that define one', () => {
const tm = setup()
mockStore.currentTool = Tools.MaskColorFill
tm.updateCursor()
expect(mockStore.brushVisible).toBe(false)
expect(mockStore.pointerZone!.style.cursor).toContain('colorSelect.png')
expect(mockStore.brushPreviewGradientVisible).toBe(false)
})
it('should show brush and "none" cursor for tools without a custom cursor', () => {
const tm = setup()
mockStore.currentTool = Tools.MaskPen
tm.updateCursor()
expect(mockStore.brushVisible).toBe(true)
expect(mockStore.pointerZone!.style.cursor).toBe('none')
})
})
describe('currentTool watcher', () => {
it('should clear last color-select point when switching away from MaskColorFill', async () => {
setup()
mockStore.currentTool = Tools.MaskColorFill
await nextTick()
mockCanvasTools.clearLastColorSelectPoint.mockClear()
mockStore.currentTool = Tools.MaskPen
await nextTick()
expect(mockCanvasTools.clearLastColorSelectPoint).toHaveBeenCalledTimes(1)
})
it('should not clear color-select point when switching to MaskColorFill', async () => {
setup()
mockStore.currentTool = Tools.MaskPen
await nextTick()
mockCanvasTools.clearLastColorSelectPoint.mockClear()
mockStore.currentTool = Tools.MaskColorFill
await nextTick()
expect(mockCanvasTools.clearLastColorSelectPoint).not.toHaveBeenCalled()
})
})
describe('handlePointerDown', () => {
it('should ignore touch pointers entirely', async () => {
const tm = setup()
await tm.handlePointerDown(pointerEvent({ pointerType: 'touch' }))
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
expect(mockPanZoom.handlePanStart).not.toHaveBeenCalled()
expect(mockPanZoom.addPenPointerId).not.toHaveBeenCalled()
})
it('should register pen pointer id then continue tool routing', async () => {
const tm = setup()
await tm.handlePointerDown(
pointerEvent({
pointerType: 'pen',
button: 0,
buttons: 1,
pointerId: 7
})
)
expect(mockPanZoom.addPenPointerId).toHaveBeenCalledWith(7)
})
it('should start panning on middle mouse button (buttons===4)', async () => {
const tm = setup()
await tm.handlePointerDown(pointerEvent({ buttons: 4 }))
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
expect(mockStore.brushVisible).toBe(false)
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
})
it('should start panning on left button + space held', async () => {
const tm = setup()
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
await tm.handlePointerDown(pointerEvent({ buttons: 1 }))
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
})
it('should start drawing for MaskPen on left button', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskPen
await tm.handlePointerDown(pointerEvent({ button: 0, buttons: 1 }))
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
})
it('should start drawing for PaintPen on left button', async () => {
const tm = setup()
mockStore.currentTool = Tools.PaintPen
await tm.handlePointerDown(pointerEvent({ button: 0, buttons: 1 }))
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
})
it('should continue drawing for PaintPen when a non-left button fires while left is held', async () => {
const tm = setup()
mockStore.currentTool = Tools.PaintPen
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 1 }))
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(1)
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
})
it('should call paintBucketFill on MaskBucket left click using transformed coords', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskBucket
await tm.handlePointerDown(
pointerEvent({ button: 0, offsetX: 50, offsetY: 25 })
)
expect(mockCoordinateTransform.screenToCanvas).toHaveBeenCalledWith({
x: 50,
y: 25
})
expect(mockCanvasTools.paintBucketFill).toHaveBeenCalledWith({
x: 100,
y: 50
})
})
it('should call colorSelectFill on MaskColorFill left click', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskColorFill
await tm.handlePointerDown(
pointerEvent({ button: 0, offsetX: 10, offsetY: 20 })
)
expect(mockCanvasTools.colorSelectFill).toHaveBeenCalledWith({
x: 20,
y: 40
})
})
it('should start brush adjustment on alt + right-click', async () => {
const tm = setup()
await tm.handlePointerDown(
pointerEvent({ altKey: true, button: 2, buttons: 2 })
)
expect(mockStore.isAdjustingBrush).toBe(true)
expect(mockBrushDrawing.startBrushAdjustment).toHaveBeenCalled()
})
it('should start drawing on right-click for drawing tools', async () => {
const tm = setup()
mockStore.currentTool = Tools.Eraser
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 2 }))
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
})
it('should not start drawing for non-drawing tools', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskBucket
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 2 }))
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
})
})
describe('handlePointerMove', () => {
it('should ignore touch pointers', async () => {
const tm = setup()
await tm.handlePointerMove(pointerEvent({ pointerType: 'touch' }))
expect(mockPanZoom.updateCursorPosition).not.toHaveBeenCalled()
})
it('should always update cursor position for non-touch pointers', async () => {
const tm = setup()
await tm.handlePointerMove(pointerEvent({ clientX: 30, clientY: 40 }))
expect(mockPanZoom.updateCursorPosition).toHaveBeenCalledWith({
x: 30,
y: 40
})
})
it('should pan on middle button drag', async () => {
const tm = setup()
await tm.handlePointerMove(pointerEvent({ buttons: 4 }))
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
})
it('should pan on left button + space drag', async () => {
const tm = setup()
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
})
it('should ignore drawing for non-drawing tools', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskBucket
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
})
it('should adjust brush on alt + right-drag while adjusting', async () => {
const tm = setup()
mockStore.isAdjustingBrush = true
mockStore.currentTool = Tools.MaskPen
await tm.handlePointerMove(pointerEvent({ altKey: true, buttons: 2 }))
expect(mockBrushDrawing.handleBrushAdjustment).toHaveBeenCalled()
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
})
it('should call handleDrawing on left or right drag for drawing tools', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskPen
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(1)
await tm.handlePointerMove(pointerEvent({ buttons: 2 }))
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(2)
})
})
describe('handlePointerUp', () => {
it('should reset panning and brush state', async () => {
const tm = setup()
mockStore.isPanning = true
mockStore.brushVisible = false
mockStore.isAdjustingBrush = true
await tm.handlePointerUp(pointerEvent({}))
expect(mockStore.isPanning).toBe(false)
expect(mockStore.brushVisible).toBe(true)
expect(mockStore.isAdjustingBrush).toBe(false)
expect(mockBrushDrawing.drawEnd).toHaveBeenCalled()
})
it('should remove pen pointer id when pointerType is "pen"', async () => {
const tm = setup()
await tm.handlePointerUp(
pointerEvent({ pointerType: 'pen', pointerId: 12 })
)
expect(mockPanZoom.removePenPointerId).toHaveBeenCalledWith(12)
})
it('should bail out before drawEnd for touch pointers', async () => {
const tm = setup()
await tm.handlePointerUp(pointerEvent({ pointerType: 'touch' }))
expect(mockBrushDrawing.drawEnd).not.toHaveBeenCalled()
})
})
})

View File

@@ -86,129 +86,6 @@ describe('useNodeDragAndDrop', () => {
expect(isDragging).toBe(false)
})
describe('claimEvent flag', () => {
function createClaimableEvent(
options: Parameters<typeof createDragEvent>[0]
) {
const event = createDragEvent(options)
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
Object.assign(event, { preventDefault, stopPropagation })
return { event, preventDefault, stopPropagation }
}
it('claims the event synchronously before awaiting onDrop for valid file drops', async () => {
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.png')]
})
const onDrop = vi.fn().mockImplementation(async () => {
// By the time onDrop runs, the event must already be claimed —
// claiming after the await would let document fallback handlers fire.
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(stopPropagation).toHaveBeenCalledTimes(1)
return []
})
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(true)
expect(onDrop).toHaveBeenCalledTimes(1)
})
it('does not claim the event when files are filtered out', async () => {
const node = createNode()
useNodeDragAndDrop(node, {
onDrop: vi.fn().mockResolvedValue([]),
fileFilter: (file) => file.type === 'image/png'
})
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.jpg', 'image/jpeg')]
})
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('claims the event for same-origin uri drops before fetching', async () => {
const { event, preventDefault, stopPropagation } = createClaimableEvent({
uri: `${location.origin}/api/file?filename=uri.png`,
types: ['text/uri-list']
})
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(stopPropagation).toHaveBeenCalledTimes(1)
return fromAny<Response, unknown>({
ok: true,
blob: vi
.fn()
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
})
})
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(true)
})
it('does not claim the event for cross-origin uri drops', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent({
uri: 'https://example.com/api/file?filename=uri.png',
types: ['text/uri-list']
})
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('does not claim the event when drop has no files and no uri', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent(
{}
)
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('does not claim the event when claimEvent is omitted', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.png')]
})
const result = await node.onDragDrop?.(event)
expect(result).toBe(true)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
})
it('onDragDrop calls onDrop with filtered files', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const node = createNode()

View File

@@ -47,15 +47,11 @@ export const useNodeDragAndDrop = <T>(
const installedDragOver = isDraggingFiles
node.onDragOver = installedDragOver
const installedDragDrop = async function (e: DragEvent, claimEvent = false) {
const installedDragDrop = async function (e: DragEvent) {
if (!isDraggingValidFiles(e)) return false
const files = filterFiles(e.dataTransfer!.files)
if (files.length) {
if (claimEvent) {
e.preventDefault()
e.stopPropagation()
}
await onDrop(files)
return true
}
@@ -63,11 +59,6 @@ export const useNodeDragAndDrop = <T>(
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
if (!uri || uri.origin !== location.origin) return false
if (claimEvent) {
e.preventDefault()
e.stopPropagation()
}
try {
const resp = await fetch(uri)
const fileName = uri?.searchParams?.get('filename')

View File

@@ -241,259 +241,6 @@ describe('Load3d', () => {
})
})
describe('viewport wiring', () => {
it('isActive ORs the activity flags through isLoad3dActive', () => {
Object.assign(ctx.load3d, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true,
animationManager: { isAnimationPlaying: false, dispose: vi.fn() },
recordingManager: { getIsRecording: vi.fn(() => false) }
})
expect(ctx.load3d.isActive()).toBe(false)
;(ctx.load3d as { STATUS_MOUSE_ON_NODE: boolean }).STATUS_MOUSE_ON_NODE =
true
expect(ctx.load3d.isActive()).toBe(true)
})
it('handleResize letterboxes the renderer when a target aspect ratio is set', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
Object.defineProperty(parent, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(parent, 'clientHeight', {
value: 600,
configurable: true
})
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setSize = vi.fn()
const cameraResize = vi.fn()
const sceneResize = vi.fn()
Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, handleResize: cameraResize },
sceneManager: { ...ctx.sceneManager, handleResize: sceneResize }
})
ctx.load3d.handleResize()
// Container 800x600, target aspect 2:1 → letterboxed render area 800x400
expect(setSize).toHaveBeenCalledWith(800, 600)
expect(cameraResize).toHaveBeenCalledWith(800, 400)
expect(sceneResize).toHaveBeenCalledWith(800, 400)
})
it('renderMainScene applies the letterboxed viewport and feeds aspect to the camera', () => {
const setViewport = vi.fn()
const setScissor = vi.fn()
const setScissorTest = vi.fn()
const setClearColor = vi.fn()
const clear = vi.fn()
const render = vi.fn()
const updateAspectRatio = vi.fn()
const renderBackground = vi.fn()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 600,
configurable: true
})
const scene = {} as THREE.Scene
Object.assign(ctx.load3d, {
renderer: {
domElement: canvas,
setViewport,
setScissor,
setScissorTest,
setClearColor,
clear,
render
},
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, updateAspectRatio },
sceneManager: { ...ctx.sceneManager, renderBackground, scene }
})
ctx.load3d.renderMainScene()
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(updateAspectRatio).toHaveBeenCalledWith(2)
expect(setScissorTest).toHaveBeenCalledWith(true)
expect(render).toHaveBeenCalledWith(scene, ctx.cameraManager.activeCamera)
})
it('setBackgroundImage updates background size with letterbox dimensions when a texture is loaded', async () => {
const updateBackgroundSize = vi.fn()
const setBackgroundImage = vi.fn().mockResolvedValue(undefined)
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 600,
configurable: true
})
Object.assign(ctx.load3d, {
renderer: { domElement: canvas },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
sceneManager: {
...ctx.sceneManager,
setBackgroundImage,
updateBackgroundSize,
backgroundTexture: {},
backgroundMesh: {}
}
})
await ctx.load3d.setBackgroundImage('test.png')
expect(setBackgroundImage).toHaveBeenCalledWith('test.png')
// Container 800x600, target aspect 2:1 → letterbox render area 800x400
const args = updateBackgroundSize.mock.calls[0]
expect(args[2]).toBe(800)
expect(args[3]).toBe(400)
})
})
describe('render loop wiring', () => {
it('startAnimation registers a render loop whose tick body runs the per-frame managers when active', () => {
const animationUpdate = vi.fn()
const viewHelperUpdate = vi.fn()
const viewHelperRender = vi.fn()
const controlsUpdate = vi.fn()
const renderMainScene = vi.fn()
const resetViewport = vi.fn()
Object.assign(ctx.load3d, {
STATUS_MOUSE_ON_NODE: true,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: false,
clock: new THREE.Clock(),
animationManager: {
update: animationUpdate,
isAnimationPlaying: false,
dispose: vi.fn()
},
viewHelperManager: {
update: viewHelperUpdate,
viewHelper: { render: viewHelperRender }
},
controlsManager: { update: controlsUpdate },
recordingManager: { getIsRecording: vi.fn(() => false) },
renderMainScene,
resetViewport,
renderer: {}
})
;(ctx.load3d as unknown as { startAnimation(): void }).startAnimation()
const loop = (ctx.load3d as unknown as { renderLoop: { stop(): void } })
.renderLoop
expect(loop).not.toBeNull()
expect(typeof loop.stop).toBe('function')
// The first loop() ran synchronously; isActive() returned true
// (STATUS_MOUSE_ON_NODE), so the tick body executed once.
expect(animationUpdate).toHaveBeenCalledOnce()
expect(viewHelperUpdate).toHaveBeenCalledOnce()
expect(controlsUpdate).toHaveBeenCalledOnce()
expect(renderMainScene).toHaveBeenCalledOnce()
expect(resetViewport).toHaveBeenCalledOnce()
expect(viewHelperRender).toHaveBeenCalledOnce()
// Cancel the queued rAF so the test doesn't leak frames.
loop.stop()
})
it('remove() stops the active render loop and clears the handle', () => {
const stop = vi.fn()
const canvas = document.createElement('canvas')
Object.assign(ctx.load3d, {
renderLoop: { stop },
resizeObserver: null,
contextMenuAbortController: null,
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: canvas
},
sceneManager: { ...ctx.sceneManager, dispose: vi.fn() },
cameraManager: { ...ctx.cameraManager, dispose: vi.fn() },
controlsManager: { ...ctx.controlsManager, dispose: vi.fn() },
lightingManager: { dispose: vi.fn() },
hdriManager: { dispose: vi.fn() },
viewHelperManager: { dispose: vi.fn() },
loaderManager: { dispose: vi.fn() },
modelManager: { ...ctx.modelManager, dispose: vi.fn() },
recordingManager: { dispose: vi.fn() },
animationManager: { ...ctx.animationManager, dispose: vi.fn() },
gizmoManager: { ...ctx.gizmo, dispose: vi.fn() }
})
ctx.load3d.remove()
expect(stop).toHaveBeenCalledOnce()
expect(
(ctx.load3d as unknown as { renderLoop: unknown }).renderLoop
).toBeNull()
})
})
describe('adapter-driven kind queries', () => {
function makeWithAdapter(kind: 'mesh' | 'pointCloud' | 'splat' | null) {
const adapter = kind === null ? null : { kind }
Object.assign(ctx.load3d, {
loaderManager: { getCurrentAdapter: vi.fn(() => adapter) }
})
}
it('isSplatModel is true only when the current adapter kind is "splat"', () => {
makeWithAdapter('splat')
expect(ctx.load3d.isSplatModel()).toBe(true)
makeWithAdapter('mesh')
expect(ctx.load3d.isSplatModel()).toBe(false)
makeWithAdapter(null)
expect(ctx.load3d.isSplatModel()).toBe(false)
})
it('isPlyModel is true only when the current adapter kind is "pointCloud"', () => {
makeWithAdapter('pointCloud')
expect(ctx.load3d.isPlyModel()).toBe(true)
makeWithAdapter('mesh')
expect(ctx.load3d.isPlyModel()).toBe(false)
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }

View File

@@ -1,5 +1,7 @@
import * as THREE from 'three'
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -22,10 +24,6 @@ import type {
MaterialMode,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
function positionThumbnailCamera(
camera: THREE.PerspectiveCamera,
@@ -49,7 +47,7 @@ function positionThumbnailCamera(
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
protected animationFrameId: number | null = null
private loadingPromise: Promise<void> | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
@@ -77,7 +75,10 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
@@ -213,12 +214,69 @@ class Load3d {
this.resizeObserver.observe(container)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
*/
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
const canvas = this.renderer.domElement
this.contextMenuAbortController = new AbortController()
const { signal } = this.contextMenuAbortController
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseStart = { x: e.clientX, y: e.clientY }
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
if (
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
) {
this.rightMouseMoved = true
}
}
}
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const wasDragging =
this.rightMouseMoved ||
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
this.rightMouseMoved = false
if (wasDragging) {
return
}
e.preventDefault()
e.stopPropagation()
this.showNodeContextMenu(e)
}
canvas.addEventListener('mousedown', mousedownHandler, { signal })
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
}
private showNodeContextMenu(event: MouseEvent): void {
if (this.onContextMenuCallback) {
this.onContextMenuCallback(event)
}
}
getEventManager(): EventManager {
@@ -296,10 +354,22 @@ class Load3d {
}
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
let offsetX: number = 0
let offsetY: number = 0
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
offsetX = (containerWidth - renderWidth) / 2
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
offsetY = (containerHeight - renderHeight) / 2
}
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
@@ -307,10 +377,11 @@ class Load3d {
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.renderer.setViewport(offsetX, offsetY, renderWidth, renderHeight)
this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight)
this.cameraManager.updateAspectRatio(width / height)
const renderAspectRatio = renderWidth / renderHeight
this.cameraManager.updateAspectRatio(renderAspectRatio)
} else {
// No aspect ratio constraint: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
@@ -351,23 +422,28 @@ class Load3d {
}
private startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
this.renderMainScene()
if (!this.isActive()) {
return
}
this.resetViewport()
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
}
animate()
}
updateStatusMouseOnNode(onNode: boolean): void {
@@ -383,14 +459,14 @@ class Load3d {
}
isActive(): boolean {
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
mouseOnViewer: this.STATUS_MOUSE_ON_VIEWER,
recording: this.isRecording(),
initialRenderDone: this.INITIAL_RENDER_DONE,
animationPlaying: this.animationManager.isAnimationPlaying
})
return (
this.STATUS_MOUSE_ON_NODE ||
this.STATUS_MOUSE_ON_SCENE ||
this.STATUS_MOUSE_ON_VIEWER ||
this.isRecording() ||
!this.INITIAL_RENDER_DONE ||
this.animationManager.isAnimationPlaying
)
}
async exportModel(format: string): Promise<void> {
@@ -451,16 +527,24 @@ class Load3d {
const containerHeight = this.renderer.domElement.clientHeight
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
width,
height
renderWidth,
renderHeight
)
} else {
// No aspect ratio constraints: fill container
@@ -567,11 +651,11 @@ class Load3d {
}
isSplatModel(): boolean {
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
return this.modelManager.containsSplatMesh()
}
isPlyModel(): boolean {
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
return this.modelManager.originalModel instanceof THREE.BufferGeometry
}
clearModel(): void {
@@ -658,14 +742,21 @@ class Load3d {
}
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
this.cameraManager.handleResize(renderWidth, renderHeight)
this.sceneManager.handleResize(renderWidth, renderHeight)
} else {
// No aspect ratio constraint: use container dimensions directly
this.renderer.setSize(containerWidth, containerHeight)
@@ -854,8 +945,10 @@ class Load3d {
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null
}
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
@@ -865,8 +958,9 @@ class Load3d {
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}
this.sceneManager.dispose()
this.cameraManager.dispose()

View File

@@ -1,530 +0,0 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
EventManagerInterface,
MaterialMode,
ModelManagerInterface
} from './interfaces'
import { LoaderManager } from './LoaderManager'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
function makeEventManagerStub() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
type ModelManagerStub = {
clearModel: ReturnType<typeof vi.fn>
setupModel: ReturnType<typeof vi.fn>
setOriginalModel: ReturnType<typeof vi.fn>
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
standardMaterial: THREE.MeshStandardMaterial
materialMode: MaterialMode
originalFileName: string | null
originalURL: string | null
}
function makeModelManagerStub(): ModelManagerStub {
return {
clearModel: vi.fn(),
setupModel: vi.fn().mockResolvedValue(undefined),
setOriginalModel: vi.fn(),
originalMaterials: new WeakMap(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode: 'original',
originalFileName: 'model',
originalURL: null
}
}
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
getPLYEngineMock: vi.fn<() => string>(),
addAlert: vi.fn()
}))
vi.mock('./MeshModelAdapter', () => ({
MeshModelAdapter: class {
readonly kind = 'mesh' as const
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
readonly capabilities = {}
load = meshLoad
}
}))
vi.mock('./PointCloudModelAdapter', () => ({
PointCloudModelAdapter: class {
readonly kind = 'pointCloud' as const
readonly extensions = ['ply'] as const
readonly capabilities = {}
load = pointCloudLoad
},
getPLYEngine: () => getPLYEngineMock()
}))
vi.mock('./SplatModelAdapter', () => ({
SplatModelAdapter: class {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly capabilities = {}
load = splatLoad
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert })
}))
type LoaderManagerInternals = {
pickAdapter(extension: string): ModelAdapter | null
}
function makeLoaderManager() {
const modelManager = makeModelManagerStub()
const eventManager = makeEventManagerStub()
const lm = new LoaderManager(
modelManager as unknown as ConstructorParameters<typeof LoaderManager>[0],
eventManager
)
const internals = lm as unknown as LoaderManagerInternals
return {
lm,
modelManager,
eventManager,
pick: internals.pickAdapter.bind(lm)
}
}
describe('LoaderManager', () => {
beforeEach(() => {
vi.clearAllMocks()
getPLYEngineMock.mockReturnValue('three')
meshLoad.mockResolvedValue(null)
splatLoad.mockResolvedValue(null)
pointCloudLoad.mockResolvedValue(null)
})
describe('getCurrentAdapter', () => {
it('returns null before any model loads', () => {
const { lm } = makeLoaderManager()
expect(lm.getCurrentAdapter()).toBeNull()
})
it('exposes the picked adapter after a successful load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
})
it('resets to null at the start of a new load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
await lm.loadModel('api/view?filename=cube.xyz')
expect(lm.getCurrentAdapter()).toBeNull()
})
it('stays null when the adapter rejects', async () => {
const { lm } = makeLoaderManager()
// Seed with a previously-successful mesh load so we can prove a later
// failed splat load does not leave the splat adapter published.
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
splatLoad.mockRejectedValueOnce(new Error('boom'))
vi.spyOn(console, 'error').mockImplementation(() => {})
await lm.loadModel('api/view?filename=scan.splat')
expect(lm.getCurrentAdapter()).toBeNull()
})
it('stays null when the adapter resolves null (parse failure)', async () => {
const { lm } = makeLoaderManager()
pointCloudLoad.mockResolvedValueOnce(null)
await lm.loadModel('api/view?filename=scan.ply')
expect(lm.getCurrentAdapter()).toBeNull()
})
})
describe('loadModel ordering', () => {
it('keeps the old adapter current while clearModel runs (so future dispose hooks see it)', async () => {
const oldAdapter = {
kind: 'splat' as const,
extensions: ['splat'] as const,
capabilities: {
fitToViewer: false,
requiresMaterialRebuild: false,
gizmoTransform: false,
lighting: false,
exportable: false,
materialModes: [],
fitTargetSize: 5
},
load: vi.fn().mockResolvedValue(null)
} satisfies ModelAdapter
const modelManager = {
originalMaterials: new WeakMap(),
clearModel: vi.fn(),
setupModel: vi.fn()
} as unknown as ModelManagerInterface
const eventManager: EventManagerInterface = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
let adapterDuringClear: ModelAdapter | null | undefined
const lm = new LoaderManager(modelManager, eventManager, [oldAdapter])
// Prime the loader with an active adapter, then trigger a new load.
;(lm as unknown as { _currentAdapter: ModelAdapter })._currentAdapter =
oldAdapter
;(modelManager.clearModel as ReturnType<typeof vi.fn>).mockImplementation(
() => {
adapterDuringClear = lm.getCurrentAdapter()
}
)
await lm.loadModel(
'api/view?type=input&subfolder=&filename=a.splat',
'a.splat'
)
expect(adapterDuringClear).toBe(oldAdapter)
})
})
describe('pickAdapter', () => {
it.each(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
'routes %s to the mesh adapter',
(ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('mesh')
}
)
it.each(['spz', 'splat', 'ksplat'])(
'routes %s to the splat adapter',
(ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('splat')
}
)
it('routes .ply to the point-cloud adapter for the default three engine', () => {
getPLYEngineMock.mockReturnValue('three')
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
})
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
getPLYEngineMock.mockReturnValue('fastply')
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
})
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
getPLYEngineMock.mockReturnValue('sparkjs')
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('splat')
})
it('returns null for unknown extensions', () => {
const { pick } = makeLoaderManager()
expect(pick('xyz')).toBeNull()
expect(pick('')).toBeNull()
})
})
describe('loadModel', () => {
it('emits modelLoadingStart and records originalURL before dispatching', async () => {
const { lm, eventManager, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingStart',
null
)
expect(modelManager.originalURL).toBe('api/view?filename=cube.glb')
})
it('clears any existing model before routing to the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const order: string[] = []
modelManager.clearModel.mockImplementation(() => order.push('clear'))
meshLoad.mockImplementationOnce(async () => {
order.push('load')
return null
})
await lm.loadModel('api/view?filename=cube.glb')
expect(order).toEqual(['clear', 'load'])
})
it('derives originalFileName from an explicit originalFileName argument', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=ignored.glb', 'uploads/my-cube.glb')
expect(modelManager.originalFileName).toBe('my-cube')
})
it('derives originalFileName from the URL filename param when no override is given', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.originalFileName).toBe('cube')
})
it('falls back to "model" when the URL has no filename param', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?other=1')
expect(modelManager.originalFileName).toBe('model')
})
it('alerts when the file extension cannot be determined', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?other=1')
expect(addAlert).toHaveBeenCalledWith(
'toastMessages.couldNotDetermineFileType'
)
expect(modelManager.setupModel).not.toHaveBeenCalled()
expect(meshLoad).not.toHaveBeenCalled()
})
it('passes setupModel the object returned by the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const loaded = new THREE.Object3D()
meshLoad.mockResolvedValueOnce(loaded)
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.setupModel).toHaveBeenCalledWith(loaded)
})
it('skips setupModel when the adapter returns null', async () => {
const { lm, modelManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(null)
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.setupModel).not.toHaveBeenCalled()
})
it('emits modelLoadingEnd when the load completes', async () => {
const { lm, eventManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
})
it('forwards a decoded path and filename to the adapter', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel(
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
)
expect(meshLoad).toHaveBeenCalledWith(
expect.objectContaining({
setOriginalModel: expect.any(Function),
registerOriginalMaterial: expect.any(Function)
}),
'api/view?type=output&subfolder=nested%2Fdir&filename=',
'cube.glb'
)
})
it('defaults the path to type=input when no type param is given', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(meshLoad).toHaveBeenCalledWith(
expect.anything(),
'api/view?type=input&subfolder=&filename=',
'cube.glb'
)
})
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
getPLYEngineMock.mockReturnValue('sparkjs')
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=scan.ply')
expect(splatLoad).toHaveBeenCalled()
expect(pointCloudLoad).not.toHaveBeenCalled()
})
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
const { lm, eventManager } = makeLoaderManager()
const err = new Error('boom')
meshLoad.mockRejectedValueOnce(err)
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel')
expect(consoleError).toHaveBeenCalled()
})
it('discards the result of a stale load when a newer one has started', async () => {
const { lm, modelManager, eventManager } = makeLoaderManager()
let resolveFirst!: (value: THREE.Object3D) => void
const firstLoad = new Promise<THREE.Object3D>((r) => {
resolveFirst = r
})
const firstModel = new THREE.Object3D()
firstModel.name = 'first'
const secondModel = new THREE.Object3D()
secondModel.name = 'second'
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(secondModel)
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')
resolveFirst(firstModel)
await Promise.all([firstPromise, secondPromise])
expect(modelManager.setupModel).toHaveBeenCalledTimes(1)
expect(modelManager.setupModel).toHaveBeenCalledWith(secondModel)
const endEmits = eventManager.emitEvent.mock.calls.filter(
(call: unknown[]) => call[0] === 'modelLoadingEnd'
)
expect(endEmits).toHaveLength(1)
})
it('logs and drops the load when the URL is missing a filename param', async () => {
const { lm, modelManager } = makeLoaderManager()
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
await lm.loadModel('api/view?type=output', 'uploads/file.glb')
expect(consoleError).toHaveBeenCalledWith(
'Missing filename in URL:',
'api/view?type=output'
)
expect(modelManager.setupModel).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('proxies setOriginalModel and registerOriginalMaterial through the load context', async () => {
const { lm, modelManager } = makeLoaderManager()
let capturedCtx: ModelLoadContext | undefined
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
capturedCtx = ctx
return new THREE.Object3D()
})
await lm.loadModel('api/view?filename=cube.glb')
const mesh = new THREE.Mesh(
new THREE.BufferGeometry(),
new THREE.MeshBasicMaterial()
)
const mat = new THREE.MeshStandardMaterial()
capturedCtx!.setOriginalModel(mesh)
capturedCtx!.registerOriginalMaterial(mesh, mat)
expect(modelManager.setOriginalModel).toHaveBeenCalledWith(mesh)
expect(modelManager.originalMaterials.get(mesh)).toBe(mat)
})
it('exposes modelManager.standardMaterial and materialMode via getters on the load context', async () => {
const { lm, modelManager } = makeLoaderManager()
modelManager.materialMode = 'wireframe'
let capturedCtx: ModelLoadContext | undefined
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
capturedCtx = ctx
return new THREE.Object3D()
})
await lm.loadModel('api/view?filename=cube.glb')
expect(capturedCtx!.standardMaterial).toBe(modelManager.standardMaterial)
expect(capturedCtx!.materialMode).toBe('wireframe')
})
it('suppresses alerts and modelLoadingEnd when a stale load throws', async () => {
const { lm, eventManager } = makeLoaderManager()
let rejectFirst!: (err: unknown) => void
const firstLoad = new Promise<THREE.Object3D>((_, r) => {
rejectFirst = r
})
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(new THREE.Object3D())
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')
rejectFirst(new Error('stale failure'))
await Promise.all([firstPromise, secondPromise])
expect(addAlert).not.toHaveBeenCalled()
const endEmits = eventManager.emitEvent.mock.calls.filter(
(call: unknown[]) => call[0] === 'modelLoadingEnd'
)
expect(endEmits).toHaveLength(1)
consoleError.mockRestore()
})
})
})

View File

@@ -1,49 +1,60 @@
import type * as THREE from 'three'
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
// Use pre-bundled worker module (has all dependencies included)
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
import { MeshModelAdapter } from './MeshModelAdapter'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import {
type EventManagerInterface,
type LoaderManagerInterface,
type ModelManagerInterface
} from './interfaces'
/**
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
*/
function defaultAdapters(): ModelAdapter[] {
return [
new MeshModelAdapter(),
new PointCloudModelAdapter(),
new SplatModelAdapter()
]
}
import { FastPLYLoader } from './loader/FastPLYLoader'
export class LoaderManager implements LoaderManagerInterface {
private readonly modelManager: ModelManagerInterface
private readonly eventManager: EventManagerInterface
private readonly adapters: ModelAdapter[]
gltfLoader: GLTFLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
plyLoader: PLYLoader
fastPlyLoader: FastPLYLoader
private modelManager: ModelManagerInterface
private eventManager: EventManagerInterface
private currentLoadId: number = 0
private _currentAdapter: ModelAdapter | null = null
constructor(
modelManager: ModelManagerInterface,
eventManager: EventManagerInterface,
adapters?: readonly ModelAdapter[]
eventManager: EventManagerInterface
) {
this.modelManager = modelManager
this.eventManager = eventManager
this.adapters = adapters ? [...adapters] : defaultAdapters()
}
getCurrentAdapter(): ModelAdapter | null {
return this._currentAdapter
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader2Parallel()
// Set worker URL for Vite compatibility
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
this.plyLoader = new PLYLoader()
this.fastPlyLoader = new FastPLYLoader()
}
init(): void {}
@@ -57,7 +68,6 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager.emitEvent('modelLoadingStart', null)
this.modelManager.clearModel()
this._currentAdapter = null
this.modelManager.originalURL = url
@@ -70,9 +80,12 @@ export class LoaderManager implements LoaderManagerInterface {
} else {
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
fileExtension = filename?.split('.').pop()?.toLowerCase()
this.modelManager.originalFileName = filename
? filename.split('.')[0] || 'model'
: 'model'
if (filename) {
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
} else {
this.modelManager.originalFileName = 'model'
}
}
if (!fileExtension) {
@@ -80,21 +93,19 @@ export class LoaderManager implements LoaderManagerInterface {
return
}
const result = await this.loadModelInternal(url, fileExtension)
const model = await this.loadModelInternal(url, fileExtension)
if (loadId !== this.currentLoadId) {
return
}
if (result && result.model) {
this._currentAdapter = result.adapter
await this.modelManager.setupModel(result.model)
if (model) {
await this.modelManager.setupModel(model)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
} catch (error) {
if (loadId === this.currentLoadId) {
this._currentAdapter = null
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
@@ -102,50 +113,26 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
private pickAdapter(extension: string): ModelAdapter | null {
const match = this.adapters.find((adapter) =>
adapter.extensions.includes(extension)
)
if (!match) return null
// PLY may be routed through the splat adapter when the PLYEngine setting
// is sparkjs. Only honor the routing when both adapters are registered.
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
if (splat) return splat
}
return match
}
private createLoadContext(): ModelLoadContext {
const mm = this.modelManager
return {
setOriginalModel: (model) => mm.setOriginalModel(model),
registerOriginalMaterial: (mesh, material) =>
mm.originalMaterials.set(mesh, material),
get standardMaterial() {
return mm.standardMaterial
},
get materialMode() {
return mm.materialMode
}
}
}
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<{ adapter: ModelAdapter; model: THREE.Object3D | null } | null> {
): Promise<THREE.Object3D | null> {
let model: THREE.Object3D | null = null
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
if (!filename) {
console.error('Missing filename in URL:', url)
return null
}
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
const subfolder = params.get('subfolder') ?? ''
const path =
'api/view?type=' +
loadRootFolder +
@@ -153,10 +140,217 @@ export class LoaderManager implements LoaderManagerInterface {
encodeURIComponent(subfolder) +
'&filename='
const adapter = this.pickAdapter(fileExtension)
if (!adapter) return null
switch (fileExtension) {
case 'stl':
this.stlLoader.setPath(path)
const geometry = await this.stlLoader.loadAsync(filename)
this.modelManager.setOriginalModel(geometry)
geometry.computeVertexNormals()
const model = await adapter.load(this.createLoadContext(), path, filename)
return { adapter, model }
const mesh = new THREE.Mesh(
geometry,
this.modelManager.standardMaterial
)
const group = new THREE.Group()
group.add(mesh)
model = group
break
case 'fbx':
this.fbxLoader.setPath(path)
const fbxModel = await this.fbxLoader.loadAsync(filename)
this.modelManager.setOriginalModel(fbxModel)
model = fbxModel
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
break
case 'obj':
if (this.modelManager.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)
const mtlFileName = filename.replace(/\.obj$/, '.mtl')
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
} catch (e) {
console.log(
'No MTL file found or error loading it, continuing without materials'
)
}
}
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
const objUrl = path + encodeURIComponent(filename)
model = await this.objLoader.loadAsync(objUrl)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
}
})
break
case 'gltf':
case 'glb':
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
this.modelManager.setOriginalModel(gltf)
model = gltf.scene
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
this.modelManager.originalMaterials.set(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
break
case 'ply':
model = await this.loadPLY(path, filename)
break
case 'spz':
case 'splat':
case 'ksplat':
model = await this.loadSplat(path, filename)
break
}
return model
}
private async fetchModelData(path: string, filename: string) {
const route =
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
const response = await api.fetchApi(route)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.status}`)
}
return response.arrayBuffer()
}
private async loadSplat(
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await this.fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
this.modelManager.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
private async loadPLY(
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
if (plyEngine === 'sparkjs') {
return this.loadSplat(path, filename)
}
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
const arrayBuffer = await this.fetchModelData(path, filename)
const isASCII = isPLYAsciiFormat(arrayBuffer)
let plyGeometry: THREE.BufferGeometry
if (isASCII && plyEngine === 'fastply') {
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
} else {
this.plyLoader.setPath(path)
plyGeometry = this.plyLoader.parse(arrayBuffer)
}
this.modelManager.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
const materialMode = this.modelManager.materialMode
// Use Points rendering for pointCloud mode (better for point clouds)
if (materialMode === 'pointCloud') {
plyGeometry.computeBoundingSphere()
if (plyGeometry.boundingSphere) {
const center = plyGeometry.boundingSphere.center
const radius = plyGeometry.boundingSphere.radius
plyGeometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
plyGeometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
this.modelManager.originalMaterials.set(
plyPoints as unknown as THREE.Mesh,
pointMaterial
)
const plyGroup = new THREE.Group()
plyGroup.add(plyPoints)
return plyGroup
}
// Use Mesh rendering for other modes
let plyMaterial: THREE.Material
if (hasVertexColors) {
plyMaterial = new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
} else {
plyMaterial = this.modelManager.standardMaterial.clone()
plyMaterial.side = THREE.DoubleSide
}
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
const plyGroup = new THREE.Group()
plyGroup.add(plyMesh)
return plyGroup
}
}

View File

@@ -1,302 +0,0 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MeshModelAdapter } from './MeshModelAdapter'
import type { ModelLoadContext } from './ModelAdapter'
const stlLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<THREE.BufferGeometry>>()
}
const fbxLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<THREE.Object3D>>()
}
const gltfLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<{ scene: THREE.Object3D }>>()
}
const mtlLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<{ preload: () => void }>>()
}
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
vi.mock('three/examples/jsm/loaders/STLLoader', () => ({
STLLoader: class {
setPath = stlLoaderStub.setPath
loadAsync = stlLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/FBXLoader', () => ({
FBXLoader: class {
setPath = fbxLoaderStub.setPath
loadAsync = fbxLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/GLTFLoader', () => ({
GLTFLoader: class {
setPath = gltfLoaderStub.setPath
loadAsync = gltfLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/MTLLoader', () => ({
MTLLoader: class {
setPath = mtlLoaderStub.setPath
loadAsync = mtlLoaderStub.loadAsync
}
}))
vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
addMaterialsFromMtlLoader: vi.fn().mockReturnValue([])
}
}))
vi.mock('wwobjloader2/bundle/worker/module?url', () => ({
default: 'mock-worker-url'
}))
function makeContext(
materialMode: ModelLoadContext['materialMode'] = 'original'
): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode
}
}
function makeFbxLikeGroup(): THREE.Group {
const group = new THREE.Group()
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
group.add(mesh)
return group
}
describe('MeshModelAdapter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('identity', () => {
it('identifies as a mesh adapter with full capabilities', () => {
const adapter = new MeshModelAdapter()
expect(adapter.kind).toBe('mesh')
expect(adapter.capabilities.fitToViewer).toBe(true)
expect(adapter.capabilities.requiresMaterialRebuild).toBe(false)
expect(adapter.capabilities.gizmoTransform).toBe(true)
expect(adapter.capabilities.lighting).toBe(true)
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'normal',
'wireframe'
])
})
it('handles the expected mesh extensions', () => {
const adapter = new MeshModelAdapter()
expect([...adapter.extensions]).toEqual([
'stl',
'fbx',
'obj',
'gltf',
'glb'
])
})
})
describe('dispatch fallbacks', () => {
it('returns null when the filename extension belongs to another adapter', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'cloud.ply')
expect(result).toBeNull()
})
it('returns null for an unknown extension', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'data.xyz')
expect(result).toBeNull()
})
it('returns null for a filename without an extension', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'noextension')
expect(result).toBeNull()
})
})
describe('STL loader path', () => {
it('loads STL geometry and wraps it in a Group with a Mesh child', async () => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
)
stlLoaderStub.loadAsync.mockResolvedValue(geometry)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'model.stl')
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
})
})
describe('FBX loader path', () => {
it('loads an FBX model and registers its mesh materials', async () => {
const fbxModel = makeFbxLikeGroup()
fbxLoaderStub.loadAsync.mockResolvedValue(fbxModel)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'rig.fbx')
expect(fbxLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(fbxModel)
})
it('disables frustum culling on SkinnedMesh children', async () => {
const group = new THREE.Group()
const skinned = new THREE.SkinnedMesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
skinned.frustumCulled = true
group.add(skinned)
fbxLoaderStub.loadAsync.mockResolvedValue(group)
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'animated.fbx')
expect(skinned.frustumCulled).toBe(false)
})
})
describe('OBJ loader path', () => {
it('attempts the MTL sidecar in original material mode', async () => {
mtlLoaderStub.loadAsync.mockResolvedValue({ preload: vi.fn() })
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
await adapter.load(makeContext('original'), '/api/view/', 'cube.obj')
expect(mtlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(mtlLoaderStub.loadAsync).toHaveBeenCalledWith('cube.mtl')
expect(objLoaderStub.setMaterials).toHaveBeenCalled()
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
})
it('swallows MTL load errors and continues without materials', async () => {
mtlLoaderStub.loadAsync.mockRejectedValue(new Error('no mtl'))
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const result = await adapter.load(
makeContext('original'),
'/api/view/',
'cube.obj'
)
expect(result).toBeInstanceOf(THREE.Group)
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
})
it('skips the MTL attempt for non-original material modes', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
await adapter.load(makeContext('wireframe'), '/api/view/', 'cube.obj')
expect(mtlLoaderStub.loadAsync).not.toHaveBeenCalled()
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
})
it('registers materials for each mesh child', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'cube.obj')
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
})
describe('GLTF loader path', () => {
it('loads a .glb and returns the scene with vertex normals computed', async () => {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
const computeNormals = vi.spyOn(mesh.geometry, 'computeVertexNormals')
const scene = new THREE.Group()
scene.add(mesh)
const gltf = { scene }
gltfLoaderStub.loadAsync.mockResolvedValue(gltf)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'scene.glb')
expect(gltfLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.glb')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
expect(computeNormals).toHaveBeenCalled()
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(scene)
})
it('also handles .gltf filenames', async () => {
gltfLoaderStub.loadAsync.mockResolvedValue({ scene: new THREE.Group() })
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'scene.gltf')
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.gltf')
})
it('disables frustum culling on SkinnedMesh children inside the scene', async () => {
const scene = new THREE.Group()
const skinned = new THREE.SkinnedMesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
skinned.frustumCulled = true
scene.add(skinned)
gltfLoaderStub.loadAsync.mockResolvedValue({ scene })
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'rigged.glb')
expect(skinned.frustumCulled).toBe(false)
})
})
})

View File

@@ -1,155 +0,0 @@
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
// Use pre-bundled worker module (has all dependencies included).
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds.
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
export class MeshModelAdapter implements ModelAdapter {
readonly kind = 'mesh' as const
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}
private readonly gltfLoader = new GLTFLoader()
private readonly objLoader: OBJLoader2Parallel
private readonly mtlLoader = new MTLLoader()
private readonly fbxLoader = new FBXLoader()
private readonly stlLoader = new STLLoader()
constructor() {
this.objLoader = new OBJLoader2Parallel()
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
}
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const extension = filename.split('.').pop()?.toLowerCase()
switch (extension) {
case 'stl':
return this.loadSTL(ctx, path, filename)
case 'fbx':
return this.loadFBX(ctx, path, filename)
case 'obj':
return this.loadOBJ(ctx, path, filename)
case 'gltf':
case 'glb':
return this.loadGLTF(ctx, path, filename)
}
return null
}
private async loadSTL(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.stlLoader.setPath(path)
const geometry = await this.stlLoader.loadAsync(filename)
ctx.setOriginalModel(geometry)
geometry.computeVertexNormals()
const mesh = new THREE.Mesh(geometry, ctx.standardMaterial)
const group = new THREE.Group()
group.add(mesh)
return group
}
private async loadFBX(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.fbxLoader.setPath(path)
const fbxModel = await this.fbxLoader.loadAsync(filename)
ctx.setOriginalModel(fbxModel)
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
ctx.registerOriginalMaterial(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
return fbxModel
}
private async loadOBJ(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)
const mtlFileName = filename.replace(/\.obj$/i, '.mtl')
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
} catch {
console.log(
'No MTL file found or error loading it, continuing without materials'
)
}
}
const objUrl = path + encodeURIComponent(filename)
const model = await this.objLoader.loadAsync(objUrl)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
ctx.registerOriginalMaterial(child, child.material)
}
})
return model
}
private async loadGLTF(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
ctx.setOriginalModel(gltf)
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
ctx.registerOriginalMaterial(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
return gltf.scene
}
}

View File

@@ -1,89 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { DEFAULT_MODEL_CAPABILITIES, fetchModelData } from './ModelAdapter'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
}
}))
describe('DEFAULT_MODEL_CAPABILITIES', () => {
it('enables fit-to-viewer / gizmo / lighting / export by default', () => {
expect(DEFAULT_MODEL_CAPABILITIES.fitToViewer).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.requiresMaterialRebuild).toBe(false)
expect(DEFAULT_MODEL_CAPABILITIES.gizmoTransform).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.lighting).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
'original',
'normal',
'wireframe'
])
})
})
describe('fetchModelData', () => {
const mockFetchApi = vi.mocked(api.fetchApi)
beforeEach(() => {
mockFetchApi.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the arrayBuffer on a successful response', async () => {
const buf = new ArrayBuffer(8)
mockFetchApi.mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: vi.fn().mockResolvedValue(buf)
} as unknown as Response)
const result = await fetchModelData('api/view?...&filename=', 'model.glb')
expect(result).toBe(buf)
})
it('throws with status code when the response is not ok', async () => {
mockFetchApi.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
await expect(
fetchModelData('api/view?type=input&subfolder=&filename=', 'missing.glb')
).rejects.toThrow('Failed to fetch model: 404')
})
it('strips the leading api/ prefix and encodes the filename', async () => {
mockFetchApi.mockResolvedValue({
ok: true,
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
} as unknown as Response)
await fetchModelData(
'api/view?type=input&subfolder=&filename=',
'a b c.ply'
)
expect(mockFetchApi).toHaveBeenCalledWith(
'/view?type=input&subfolder=&filename=a%20b%20c.ply'
)
})
it('prepends a single slash when the path has no api/ prefix', async () => {
mockFetchApi.mockResolvedValue({
ok: true,
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
} as unknown as Response)
await fetchModelData('custom?filename=', 'scene.splat')
expect(mockFetchApi).toHaveBeenCalledWith('/custom?filename=scene.splat')
})
})

View File

@@ -1,88 +0,0 @@
import type * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { api } from '@/scripts/api'
import type { MaterialMode } from './interfaces'
export interface ModelLoadContext {
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
registerOriginalMaterial(
mesh: THREE.Mesh,
material: THREE.Material | THREE.Material[]
): void
readonly standardMaterial: THREE.MeshStandardMaterial
readonly materialMode: MaterialMode
}
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**
* Whether auto-normalize/centering on load and the explicit fit-to-viewer
* action should run. Splats render self-sized and are placed at a fixed
* camera distance instead.
*/
fitToViewer: boolean
/**
* Whether a material mode change must rebuild the scene object instead of
* traversing the existing mesh tree. True for point-cloud PLY (Mesh <->
* Points swap); false for regular meshes and self-rendering splats.
*/
requiresMaterialRebuild: boolean
/**
* Whether the gizmo transform UI (translate/rotate/scale) should be
* exposed for this model type. False for adapters whose already-normalized
* output makes user transforms meaningless (PLY point cloud).
*/
gizmoTransform: boolean
/** Whether scene-lighting controls apply. False for self-lit formats. */
lighting: boolean
/** Whether the model can be exported (GLB/OBJ/STL). */
exportable: boolean
/**
* Material modes offered in the UI for this format. An empty array hides
* the material-mode dropdown entirely.
*/
materialModes: readonly MaterialMode[]
/**
* World-space target size along the largest dimension after
* fit-to-viewer normalization. Controls how large the model ends up
* relative to the 20-unit scene grid; splats use a larger value so they
* don't shrink to a quarter of the floor.
*/
fitTargetSize: number
}
export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}
export interface ModelAdapter {
readonly kind: ModelAdapterKind
readonly extensions: readonly string[]
readonly capabilities: ModelAdapterCapabilities
load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null>
}
export async function fetchModelData(
path: string,
filename: string
): Promise<ArrayBuffer> {
const route = '/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
const response = await api.fetchApi(route)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.status}`)
}
return response.arrayBuffer()
}

View File

@@ -1,116 +0,0 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelLoadContext } from './ModelAdapter'
import * as ModelAdapterModule from './ModelAdapter'
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
const mockSettingGet = vi.fn<(key: string) => unknown>()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: mockSettingGet })
}))
vi.mock('@/scripts/metadata/ply', () => ({
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
}))
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
PLYLoader: class {
setPath = vi.fn()
parse = vi.fn(() => makePLYGeometry(false))
}
}))
vi.mock('./loader/FastPLYLoader', () => ({
FastPLYLoader: class {
parse = vi.fn(() => makePLYGeometry(false))
}
}))
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
)
if (withColors) {
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
}
return geometry
}
function makeContext(
materialMode: ModelLoadContext['materialMode'] = 'original'
): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode
}
}
describe('PointCloudModelAdapter', () => {
beforeEach(() => {
mockSettingGet.mockReset()
})
describe('identity', () => {
it('handles the ply extension', () => {
const adapter = new PointCloudModelAdapter()
expect([...adapter.extensions]).toEqual(['ply'])
})
it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => {
const adapter = new PointCloudModelAdapter()
expect(adapter.kind).toBe('pointCloud')
expect(adapter.capabilities.fitToViewer).toBe(false)
expect(adapter.capabilities.requiresMaterialRebuild).toBe(true)
expect(adapter.capabilities.gizmoTransform).toBe(false)
expect(adapter.capabilities.lighting).toBe(true)
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'pointCloud',
'normal',
'wireframe'
])
})
})
describe('load', () => {
beforeEach(() => {
mockSettingGet.mockReturnValue('three')
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
new ArrayBuffer(0)
)
})
it('returns a Group containing a Mesh for non-pointCloud modes', async () => {
const adapter = new PointCloudModelAdapter()
const ctx = makeContext('original')
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(child).toBeInstanceOf(THREE.Mesh)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
})
it('returns a Group containing Points when materialMode is pointCloud', async () => {
const adapter = new PointCloudModelAdapter()
const ctx = makeContext('pointCloud')
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(child).toBeInstanceOf(THREE.Points)
})
})
})

View File

@@ -1,120 +0,0 @@
import * as THREE from 'three'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { useSettingStore } from '@/platform/settings/settingStore'
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import { FastPLYLoader } from './loader/FastPLYLoader'
export function getPLYEngine(): string {
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
}
export class PointCloudModelAdapter implements ModelAdapter {
readonly kind = 'pointCloud' as const
readonly extensions = ['ply'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: false,
requiresMaterialRebuild: true,
gizmoTransform: false,
lighting: true,
exportable: true,
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
fitTargetSize: 5
}
private readonly plyLoader = new PLYLoader()
private readonly fastPlyLoader = new FastPLYLoader()
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const arrayBuffer = await fetchModelData(path, filename)
const isASCII = isPLYAsciiFormat(arrayBuffer)
const plyGeometry =
isASCII && getPLYEngine() === 'fastply'
? this.fastPlyLoader.parse(arrayBuffer)
: this.plyLoader.parse(arrayBuffer)
ctx.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
if (ctx.materialMode === 'pointCloud') {
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
}
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
}
}
function buildPointsGroup(
ctx: ModelLoadContext,
geometry: THREE.BufferGeometry,
hasVertexColors: boolean
): THREE.Group {
geometry.computeBoundingSphere()
if (geometry.boundingSphere) {
const { center, radius } = geometry.boundingSphere
geometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
geometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const points = new THREE.Points(geometry, pointMaterial)
ctx.registerOriginalMaterial(points as unknown as THREE.Mesh, pointMaterial)
const group = new THREE.Group()
group.add(points)
return group
}
function buildMeshGroup(
ctx: ModelLoadContext,
geometry: THREE.BufferGeometry,
hasVertexColors: boolean
): THREE.Group {
const material = hasVertexColors
? new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
: ctx.standardMaterial.clone()
if (!hasVertexColors && material instanceof THREE.MeshStandardMaterial) {
material.side = THREE.DoubleSide
}
const mesh = new THREE.Mesh(geometry, material)
ctx.registerOriginalMaterial(mesh, material)
const group = new THREE.Group()
group.add(mesh)
return group
}

View File

@@ -1,82 +0,0 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelLoadContext } from './ModelAdapter'
import * as ModelAdapterModule from './ModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
const { splatMeshCtor } = vi.hoisted(() => ({
splatMeshCtor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>()
}))
vi.mock('@sparkjsdev/spark', async () => {
const three = await import('three')
return {
SplatMesh: class extends three.Object3D {
constructor(opts: { fileBytes: ArrayBuffer }) {
super()
splatMeshCtor(opts)
}
}
}
})
function makeContext(): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode: 'original'
}
}
describe('SplatModelAdapter', () => {
beforeEach(() => {
splatMeshCtor.mockReset()
})
it('exposes splat capabilities on the adapter', () => {
const adapter = new SplatModelAdapter()
expect(adapter.kind).toBe('splat')
expect(adapter.capabilities.lighting).toBe(false)
expect(adapter.capabilities.exportable).toBe(false)
expect([...adapter.capabilities.materialModes]).toEqual([])
})
it('handles the Gaussian splat extensions', () => {
const adapter = new SplatModelAdapter()
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
})
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
const buf = new ArrayBuffer(128)
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(buf)
const adapter = new SplatModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view?', 'scene.splat')
expect(ModelAdapterModule.fetchModelData).toHaveBeenCalledWith(
'/api/view?',
'scene.splat'
)
expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf })
expect(result).toBeInstanceOf(THREE.Group)
expect(result.children).toHaveLength(1)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
})
it('propagates fetch errors', async () => {
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
new Error('Failed to fetch model: 500')
)
const adapter = new SplatModelAdapter()
await expect(
adapter.load(makeContext(), '/api/view?', 'scene.splat')
).rejects.toThrow('Failed to fetch model: 500')
})
})

View File

@@ -1,38 +0,0 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
export class SplatModelAdapter implements ModelAdapter {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: false,
requiresMaterialRebuild: false,
gizmoTransform: false,
lighting: false,
exportable: false,
materialModes: [],
fitTargetSize: 5
}
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
ctx.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
}

View File

@@ -3,7 +3,11 @@
import type * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import type { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import type { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import type { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import type { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import type { OBJLoader2Parallel } from 'wwobjloader2'
export type MaterialMode =
| 'original'
@@ -199,6 +203,12 @@ export interface ModelManagerInterface {
}
export interface LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
init(): void
dispose(): void
loadModel(url: string, originalFileName?: string): Promise<void>

View File

@@ -1,129 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
function rightMouse(type: string, x: number, y: number, buttons = 2) {
const event = new MouseEvent(type, {
button: 2,
buttons,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true
})
return event
}
describe('attachContextMenuGuard', () => {
let target: HTMLElement
let onMenu: ReturnType<typeof vi.fn<(event: MouseEvent) => void>>
let dispose: () => void
beforeEach(() => {
target = document.createElement('div')
document.body.appendChild(target)
onMenu = vi.fn<(event: MouseEvent) => void>()
})
afterEach(() => {
dispose?.()
target.remove()
})
it('invokes onMenu for a right-click without drag movement', () => {
dispose = attachContextMenuGuard(target, onMenu)
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
expect(onMenu).toHaveBeenCalledOnce()
})
it('preventDefault is called on the contextmenu event when menu fires', () => {
dispose = attachContextMenuGuard(target, onMenu)
target.dispatchEvent(rightMouse('mousedown', 0, 0))
const contextEvent = rightMouse('contextmenu', 0, 0)
target.dispatchEvent(contextEvent)
expect(contextEvent.defaultPrevented).toBe(true)
})
it('suppresses onMenu when the mouse moved past the drag threshold', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 120, 120))
target.dispatchEvent(rightMouse('contextmenu', 120, 120))
expect(onMenu).not.toHaveBeenCalled()
})
it('still fires onMenu when the mouse moved within the drag threshold', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 10 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 103, 104))
target.dispatchEvent(rightMouse('contextmenu', 103, 104))
expect(onMenu).toHaveBeenCalledOnce()
})
it('detects a drag from start to contextmenu even without mousemove events', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
expect(onMenu).not.toHaveBeenCalled()
})
it('resets drag state between right-clicks', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 200, 200))
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
expect(onMenu).not.toHaveBeenCalled()
target.dispatchEvent(rightMouse('mousedown', 50, 50))
target.dispatchEvent(rightMouse('contextmenu', 50, 50))
expect(onMenu).toHaveBeenCalledOnce()
})
it('ignores onMenu when isDisabled returns true', () => {
let disabled = true
dispose = attachContextMenuGuard(target, onMenu, {
isDisabled: () => disabled
})
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).not.toHaveBeenCalled()
disabled = false
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).toHaveBeenCalledOnce()
})
it('stops listening after dispose', () => {
dispose = attachContextMenuGuard(target, onMenu)
dispose()
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).not.toHaveBeenCalled()
})
it('ignores mousemove events without the right button held', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 200, 200, 0))
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
expect(onMenu).toHaveBeenCalledOnce()
})
})

View File

@@ -1,72 +0,0 @@
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
type ContextMenuGuardOptions = {
isDisabled?: () => boolean
dragThreshold?: number
}
export function attachContextMenuGuard(
target: HTMLElement,
onMenu: (event: MouseEvent) => void,
{ isDisabled = () => false, dragThreshold = 5 }: ContextMenuGuardOptions = {}
): () => void {
const abort = new AbortController()
const { signal } = abort
let start = { x: 0, y: 0 }
let moved = false
target.addEventListener(
'mousedown',
(e) => {
if (e.button === 2) {
start = { x: e.clientX, y: e.clientY }
moved = false
}
},
{ signal }
)
target.addEventListener(
'mousemove',
(e) => {
if (
e.buttons === 2 &&
exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
dragThreshold
)
) {
moved = true
}
},
{ signal }
)
target.addEventListener(
'contextmenu',
(e) => {
if (isDisabled()) return
const wasDragging =
moved ||
exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
dragThreshold
)
moved = false
if (wasDragging) return
e.preventDefault()
e.stopPropagation()
onMenu(e)
},
{ signal }
)
return () => abort.abort()
}

View File

@@ -1,62 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startRenderLoop } from './load3dRenderLoop'
describe('startRenderLoop', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs tick on each frame while isActive returns true', () => {
const tick = vi.fn()
const handle = startRenderLoop({ tick, isActive: () => true })
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextTimer()
expect(tick.mock.calls.length).toBeGreaterThanOrEqual(3)
handle.stop()
})
it('skips tick on frames where isActive returns false', () => {
let active = false
const tick = vi.fn()
const handle = startRenderLoop({ tick, isActive: () => active })
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextTimer()
expect(tick).not.toHaveBeenCalled()
active = true
vi.advanceTimersToNextTimer()
expect(tick).toHaveBeenCalledOnce()
handle.stop()
})
it('stop halts further ticks', () => {
const tick = vi.fn()
const handle = startRenderLoop({ tick, isActive: () => true })
vi.advanceTimersToNextTimer()
const callsBeforeStop = tick.mock.calls.length
handle.stop()
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextTimer()
expect(tick.mock.calls.length).toBe(callsBeforeStop)
})
it('is safe to call stop multiple times', () => {
const handle = startRenderLoop({ tick: vi.fn(), isActive: () => true })
handle.stop()
expect(() => handle.stop()).not.toThrow()
})
})

View File

@@ -1,32 +0,0 @@
type RenderLoopOptions = {
tick: () => void
isActive: () => boolean
}
export type RenderLoopHandle = {
stop: () => void
}
export function startRenderLoop({
tick,
isActive
}: RenderLoopOptions): RenderLoopHandle {
let frameId: number | null = null
const loop = () => {
frameId = requestAnimationFrame(loop)
if (!isActive()) return
tick()
}
loop()
return {
stop() {
if (frameId !== null) {
cancelAnimationFrame(frameId)
frameId = null
}
}
}
}

View File

@@ -1,108 +0,0 @@
import { describe, expect, it } from 'vitest'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
import type { Load3dActivityFlags } from './load3dViewport'
describe('computeLetterboxedViewport', () => {
it('pillarboxes when the container is wider than the target aspect', () => {
const viewport = computeLetterboxedViewport({ width: 800, height: 400 }, 1)
expect(viewport).toEqual({
offsetX: 200,
offsetY: 0,
width: 400,
height: 400
})
})
it('letterboxes when the container is taller than the target aspect', () => {
const viewport = computeLetterboxedViewport({ width: 400, height: 800 }, 1)
expect(viewport).toEqual({
offsetX: 0,
offsetY: 200,
width: 400,
height: 400
})
})
it('fills the container when aspect ratios match exactly', () => {
const viewport = computeLetterboxedViewport(
{ width: 1024, height: 768 },
1024 / 768
)
expect(viewport.offsetX).toBe(0)
expect(viewport.offsetY).toBe(0)
expect(viewport.width).toBe(1024)
expect(viewport.height).toBe(768)
})
it('handles a wide target aspect inside a square container', () => {
const viewport = computeLetterboxedViewport(
{ width: 600, height: 600 },
16 / 9
)
expect(viewport.offsetX).toBe(0)
expect(viewport.width).toBe(600)
expect(viewport.height).toBeCloseTo(337.5)
expect(viewport.offsetY).toBeCloseTo((600 - 337.5) / 2)
})
it('handles a tall target aspect inside a square container', () => {
const viewport = computeLetterboxedViewport(
{ width: 600, height: 600 },
9 / 16
)
expect(viewport.offsetY).toBe(0)
expect(viewport.height).toBe(600)
expect(viewport.width).toBeCloseTo(337.5)
expect(viewport.offsetX).toBeCloseTo((600 - 337.5) / 2)
})
it('preserves the target aspect ratio in the returned rect', () => {
const target = 16 / 9
const wide = computeLetterboxedViewport(
{ width: 1920, height: 500 },
target
)
const tall = computeLetterboxedViewport(
{ width: 500, height: 1920 },
target
)
expect(wide.width / wide.height).toBeCloseTo(target)
expect(tall.width / tall.height).toBeCloseTo(target)
})
})
describe('isLoad3dActive', () => {
const idle: Load3dActivityFlags = {
mouseOnNode: false,
mouseOnScene: false,
mouseOnViewer: false,
recording: false,
initialRenderDone: true,
animationPlaying: false
}
it('is inactive once the first frame is rendered with nothing happening', () => {
expect(isLoad3dActive(idle)).toBe(false)
})
it('is active before the first frame renders', () => {
expect(isLoad3dActive({ ...idle, initialRenderDone: false })).toBe(true)
})
it.each([
['mouseOnNode'],
['mouseOnScene'],
['mouseOnViewer'],
['recording'],
['animationPlaying']
] as const)('is active when %s is true', (flag) => {
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
})
})

View File

@@ -1,55 +0,0 @@
type Size = { width: number; height: number }
type LetterboxedViewport = {
offsetX: number
offsetY: number
width: number
height: number
}
export function computeLetterboxedViewport(
container: Size,
targetAspectRatio: number
): LetterboxedViewport {
const containerAspectRatio = container.width / container.height
if (containerAspectRatio > targetAspectRatio) {
const height = container.height
const width = height * targetAspectRatio
return {
offsetX: (container.width - width) / 2,
offsetY: 0,
width,
height
}
}
const width = container.width
const height = width / targetAspectRatio
return {
offsetX: 0,
offsetY: (container.height - height) / 2,
width,
height
}
}
export type Load3dActivityFlags = {
mouseOnNode: boolean
mouseOnScene: boolean
mouseOnViewer: boolean
recording: boolean
initialRenderDone: boolean
animationPlaying: boolean
}
export function isLoad3dActive(flags: Load3dActivityFlags): boolean {
return (
flags.mouseOnNode ||
flags.mouseOnScene ||
flags.mouseOnViewer ||
flags.recording ||
!flags.initialRenderDone ||
flags.animationPlaying
)
}

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "التطبيق",
"blueprint": "المخطط",
"clearWorkflow": "مسح سير العمل",
"deleteBlueprint": "حذف المخطط",
"deleteWorkflow": "حذف سير العمل",
@@ -919,10 +918,6 @@
"showSwapNodes": "عرض العقد البديلة",
"swapNodes": "يمكن استبدال بعض العقد ببدائل"
},
"errorPanelSurvey": {
"ctaButton": "أعطِ ملاحظاتك",
"ctaText": "ما رأيك في لوحة الأخطاء الجديدة؟"
},
"essentials": {
"batchImage": "معالجة صور دفعة واحدة",
"canny": "كانّي",

View File

@@ -2734,7 +2734,6 @@
"noReleaseNotes": "No release notes available."
},
"breadcrumbsMenu": {
"blueprint": "Blueprint",
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"exitAppMode": "Exit app mode",

View File

@@ -11743,8 +11743,8 @@
}
},
"OpenAIVideoSora2": {
"display_name": "OpenAI Sora - Video (Deprecated)",
"description": "OpenAI video and audio generation.\n\nDEPRECATION NOTICE: OpenAI will stop serving the Sora v2 API in September 2026. This node will be removed from ComfyUI at that time.",
"display_name": "OpenAI Sora - Video",
"description": "OpenAI video and audio generation.",
"inputs": {
"model": {
"name": "model"

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "Aplicación",
"blueprint": "Plano",
"clearWorkflow": "Limpiar flujo de trabajo",
"deleteBlueprint": "Eliminar Plano",
"deleteWorkflow": "Eliminar flujo de trabajo",
@@ -919,10 +918,6 @@
"showSwapNodes": "Mostrar nodos intercambiables",
"swapNodes": "Algunos nodos pueden ser reemplazados por alternativas"
},
"errorPanelSurvey": {
"ctaButton": "Dar opinión",
"ctaText": "¿Qué te parece el nuevo panel de errores?"
},
"essentials": {
"batchImage": "Procesar imágenes por lotes",
"canny": "Canny",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "برنامه",
"blueprint": "نقشه راه",
"clearWorkflow": "پاک‌سازی workflow",
"deleteBlueprint": "حذف blueprint",
"deleteWorkflow": "حذف workflow",
@@ -919,10 +918,6 @@
"showSwapNodes": "نمایش نودهای قابل جایگزینی",
"swapNodes": "برخی از نودها را می‌توان با گزینه‌های جایگزین تعویض کرد"
},
"errorPanelSurvey": {
"ctaButton": "ارسال بازخورد",
"ctaText": "نظر شما درباره پنل خطا جدید چیست؟"
},
"essentials": {
"batchImage": "پردازش دسته‌ای تصویر",
"canny": "لبه‌یابی Canny",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "Application",
"blueprint": "Plan",
"clearWorkflow": "Effacer le workflow",
"deleteBlueprint": "Supprimer le plan",
"deleteWorkflow": "Supprimer le workflow",
@@ -919,10 +918,6 @@
"showSwapNodes": "Afficher les nœuds de remplacement",
"swapNodes": "Certains nœuds peuvent être remplacés par des alternatives"
},
"errorPanelSurvey": {
"ctaButton": "Donner votre avis",
"ctaText": "Que pensez-vous du nouveau panneau derreurs ?"
},
"essentials": {
"batchImage": "Traitement par lot d'images",
"canny": "Canny",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "アプリ",
"blueprint": "ブループリント",
"clearWorkflow": "ワークフローをクリア",
"deleteBlueprint": "ブループリントを削除",
"deleteWorkflow": "ワークフローを削除",
@@ -919,10 +918,6 @@
"showSwapNodes": "代替可能なノードを表示",
"swapNodes": "いくつかのノードは代替可能です"
},
"errorPanelSurvey": {
"ctaButton": "フィードバックを送る",
"ctaText": "新しいエラーパネルはいかがですか?"
},
"essentials": {
"batchImage": "バッチ画像処理",
"canny": "Canny",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "앱",
"blueprint": "블루프린트",
"clearWorkflow": "워크플로 내용 지우기",
"deleteBlueprint": "블루프린트 삭제",
"deleteWorkflow": "워크플로 삭제",
@@ -919,10 +918,6 @@
"showSwapNodes": "교체 가능한 노드 표시",
"swapNodes": "일부 노드는 대체 가능한 노드로 교체할 수 있습니다"
},
"errorPanelSurvey": {
"ctaButton": "피드백 남기기",
"ctaText": "새로운 오류 패널은 어떠신가요?"
},
"essentials": {
"batchImage": "이미지 일괄 처리",
"canny": "Canny",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "App",
"blueprint": "Blueprint",
"clearWorkflow": "Limpar Fluxo de Trabalho",
"deleteBlueprint": "Excluir Blueprint",
"deleteWorkflow": "Excluir Fluxo de Trabalho",
@@ -919,10 +918,6 @@
"showSwapNodes": "Mostrar nós alternativos",
"swapNodes": "Alguns nós podem ser substituídos por alternativas"
},
"errorPanelSurvey": {
"ctaButton": "Enviar feedback",
"ctaText": "O que achou do novo painel de erros?"
},
"essentials": {
"batchImage": "Imagem em lote",
"canny": "Canny",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "Приложение",
"blueprint": "Чертёж",
"clearWorkflow": "Очистить рабочий процесс",
"deleteBlueprint": "Удалить схему",
"deleteWorkflow": "Удалить рабочий процесс",
@@ -919,10 +918,6 @@
"showSwapNodes": "Показать заменяемые узлы",
"swapNodes": "Некоторые узлы можно заменить альтернативами"
},
"errorPanelSurvey": {
"ctaButton": "Оставить отзыв",
"ctaText": "Как вам новая панель ошибок?"
},
"essentials": {
"batchImage": "Пакетная обработка изображений",
"canny": "Canny",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "Uygulama",
"blueprint": "Plan",
"clearWorkflow": "İş Akışını Temizle",
"deleteBlueprint": "Taslağı Sil",
"deleteWorkflow": "İş Akışını Sil",
@@ -919,10 +918,6 @@
"showSwapNodes": "Değiştirilebilir düğümleri göster",
"swapNodes": "Bazı düğümler alternatiflerle değiştirilebilir"
},
"errorPanelSurvey": {
"ctaButton": "Geri bildirim ver",
"ctaText": "Yeni hata paneli nasıl?"
},
"essentials": {
"batchImage": "Toplu Görüntü",
"canny": "Canny",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "應用程式",
"blueprint": "藍圖",
"clearWorkflow": "清除工作流程",
"deleteBlueprint": "刪除藍圖",
"deleteWorkflow": "刪除工作流程",
@@ -919,10 +918,6 @@
"showSwapNodes": "顯示可替換的節點",
"swapNodes": "有些節點可以用其他選項替換"
},
"errorPanelSurvey": {
"ctaButton": "提供回饋",
"ctaText": "新的錯誤面板感覺如何?"
},
"essentials": {
"batchImage": "批次圖片",
"canny": "Canny 邊緣",

View File

@@ -335,7 +335,6 @@
},
"breadcrumbsMenu": {
"app": "应用",
"blueprint": "蓝图",
"clearWorkflow": "清除工作流",
"deleteBlueprint": "删除蓝图",
"deleteWorkflow": "删除工作流",
@@ -919,10 +918,6 @@
"showSwapNodes": "显示可替换节点",
"swapNodes": "部分节点可以用替代项替换"
},
"errorPanelSurvey": {
"ctaButton": "提交反馈",
"ctaText": "新的错误面板感觉如何?"
},
"essentials": {
"batchImage": "批量图像",
"canny": "Canny",

View File

@@ -1,161 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => 0
})
}))
vi.mock('@/stores/assetDownloadStore', () => ({
useAssetDownloadStore: () => ({
isDownloadedThisSession: () => false,
acknowledgeAsset: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
deleteAsset: vi.fn()
}
}))
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
showConfirmDialog: vi.fn()
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
return {
...actual,
useImage: () => ({ isLoading: false, error: null })
}
})
const HASH = 'blake3:abc123def456'
const ORIGINAL_FILENAME = 'sunset_photo.png'
function createDisplayAsset(
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem {
return {
id: 'asset-1',
name: HASH,
asset_hash: HASH,
tags: ['input'],
preview_url: '/preview.png',
secondaryText: '',
badges: [],
stats: {},
user_metadata: {},
metadata: { filename: ORIGINAL_FILENAME },
...overrides
}
}
function renderCard(asset: AssetDisplayItem) {
setActivePinia(createPinia())
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
return render(AssetCard, {
props: { asset, interactive: true },
global: {
plugins: [i18n],
stubs: {
AssetBadgeGroup: true,
IconGroup: true,
MoreButton: true,
StatusBadge: true,
Button: { template: '<button><slot /></button>' }
},
directives: {
tooltip: {}
}
}
})
}
describe('AssetCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('FE-228: filename rendering', () => {
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
const asset = createDisplayAsset()
renderCard(asset)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
expect(heading).not.toHaveTextContent(HASH)
})
it('falls back to display_name when user_metadata.filename and metadata.filename are absent', () => {
const asset = createDisplayAsset({
metadata: undefined,
user_metadata: undefined,
display_name: ORIGINAL_FILENAME
})
renderCard(asset)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
expect(heading).not.toHaveTextContent(HASH)
})
})
describe('preserves user-curated display name', () => {
const CURATED_NAME = 'My Favorite SDXL LoRA'
const MODEL_FILENAME = 'lora_v1_epoch4.safetensors'
it('renders the curated name (user_metadata.name) when it differs from the raw asset.name', () => {
const asset = createDisplayAsset({
id: 'model-1',
name: MODEL_FILENAME,
asset_hash: undefined,
tags: ['models', 'loras'],
user_metadata: { name: CURATED_NAME },
metadata: { filename: MODEL_FILENAME }
})
renderCard(asset)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveTextContent(CURATED_NAME)
expect(heading).not.toHaveTextContent(MODEL_FILENAME)
})
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
const asset = createDisplayAsset({
name: HASH,
asset_hash: HASH,
user_metadata: { name: HASH },
metadata: { filename: ORIGINAL_FILENAME }
})
renderCard(asset)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveTextContent(ORIGINAL_FILENAME)
expect(heading).not.toHaveTextContent(HASH)
})
})
})

View File

@@ -143,7 +143,7 @@ import Button from '@/components/ui/button/Button.vue'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetCardTitle } from '@/platform/assets/utils/assetMetadataUtils'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -174,7 +174,7 @@ const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
const titleId = useId()
const descId = useId()
const displayName = computed(() => getAssetCardTitle(asset))
const displayName = computed(() => getAssetDisplayName(asset))
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))

View File

@@ -2,11 +2,7 @@
<div class="inline-flex items-center">
<Popover>
<template #button>
<Button
variant="secondary"
size="icon"
:aria-label="$t('assetBrowser.filterBy')"
>
<Button variant="secondary" size="icon">
<i class="icon-[lucide--list-filter]" />
</Button>
</template>

View File

@@ -1,93 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import MediaAssetFilterMenu from '@/platform/assets/components/MediaAssetFilterMenu.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
function renderMenu(mediaTypeFilters: string[] = []) {
const onUpdate = vi.fn()
const utils = render(MediaAssetFilterMenu, {
props: {
mediaTypeFilters,
'onUpdate:mediaTypeFilters': onUpdate
},
global: {
mocks: {
$t: (key: string) => key
}
}
})
return { ...utils, onUpdate, user: userEvent.setup() }
}
const labelByType: Record<string, string> = {
image: 'sideToolbar.mediaAssets.filterImage',
video: 'sideToolbar.mediaAssets.filterVideo',
audio: 'sideToolbar.mediaAssets.filterAudio',
'3d': 'sideToolbar.mediaAssets.filter3D'
}
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
return screen.getByRole('checkbox', { name: labelByType[type] })
}
describe('MediaAssetFilterMenu', () => {
it('renders all four media-type checkboxes', () => {
renderMenu()
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes).toHaveLength(4)
for (const type of Object.keys(labelByType)) {
expect(getCheckbox(type)).toBeTruthy()
}
})
it('reflects checked state from the prop via aria-checked', () => {
renderMenu(['image', '3d'])
expect(getCheckbox('image').getAttribute('aria-checked')).toBe('true')
expect(getCheckbox('3d').getAttribute('aria-checked')).toBe('true')
expect(getCheckbox('video').getAttribute('aria-checked')).toBe('false')
expect(getCheckbox('audio').getAttribute('aria-checked')).toBe('false')
})
it('emits an array containing the new type when an unchecked box is clicked', async () => {
const { onUpdate, user } = renderMenu([])
await user.click(getCheckbox('video'))
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith(['video'])
})
it('emits an array without the type when a checked box is clicked again', async () => {
const { onUpdate, user } = renderMenu(['image', 'audio'])
await user.click(getCheckbox('audio'))
expect(onUpdate).toHaveBeenCalledWith(['image'])
})
it('appends to the existing filter list rather than replacing it', async () => {
const { onUpdate, user } = renderMenu(['image'])
await user.click(getCheckbox('video'))
expect(onUpdate).toHaveBeenCalledWith(['image', 'video'])
})
it('toggles via keyboard (Enter and Space)', async () => {
const { onUpdate, user } = renderMenu([])
getCheckbox('image').focus()
await user.keyboard('{Enter}')
expect(onUpdate).toHaveBeenLastCalledWith(['image'])
getCheckbox('audio').focus()
await user.keyboard(' ')
expect(onUpdate).toHaveBeenLastCalledWith(['audio'])
})
})

View File

@@ -1,130 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { defineComponent, ref } from 'vue'
import MediaAssetSettingsMenu from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
import type { SortBy } from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
const KEYS = {
list: 'sideToolbar.queueProgressOverlay.viewList',
grid: 'sideToolbar.queueProgressOverlay.viewGrid',
newest: 'sideToolbar.mediaAssets.sortNewestFirst',
oldest: 'sideToolbar.mediaAssets.sortOldestFirst',
longest: 'sideToolbar.mediaAssets.sortLongestFirst',
fastest: 'sideToolbar.mediaAssets.sortFastestFirst'
} as const
interface MountOptions {
viewMode?: 'list' | 'grid'
sortBy?: SortBy
showSortOptions?: boolean
showGenerationTimeSort?: boolean
}
function mountWithModels(options: MountOptions = {}) {
const viewMode = ref<'list' | 'grid'>(options.viewMode ?? 'list')
const sortBy = ref<SortBy>(options.sortBy ?? 'newest')
const Host = defineComponent({
components: { MediaAssetSettingsMenu },
setup() {
return {
viewMode,
sortBy,
showSortOptions: options.showSortOptions ?? false,
showGenerationTimeSort: options.showGenerationTimeSort ?? false
}
},
template: `
<MediaAssetSettingsMenu
v-model:viewMode="viewMode"
v-model:sortBy="sortBy"
:showSortOptions="showSortOptions"
:showGenerationTimeSort="showGenerationTimeSort"
/>
`
})
const utils = render(Host, {
global: {
mocks: {
$t: (key: string) => key
}
}
})
return { ...utils, viewMode, sortBy, user: userEvent.setup() }
}
function getButton(label: string): HTMLElement {
return screen.getByRole('button', { name: label })
}
describe('MediaAssetSettingsMenu', () => {
describe('view-mode options (always visible)', () => {
it('renders both list and grid view options', () => {
mountWithModels()
expect(getButton(KEYS.list)).toBeTruthy()
expect(getButton(KEYS.grid)).toBeTruthy()
})
it('updates the v-model:viewMode when an option is clicked', async () => {
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
await user.click(getButton(KEYS.grid))
expect(viewMode.value).toBe('grid')
})
})
describe('sort options (gated by showSortOptions)', () => {
it('hides newest/oldest sort buttons when showSortOptions is false', () => {
mountWithModels({ showSortOptions: false })
expect(screen.queryByRole('button', { name: KEYS.newest })).toBeNull()
expect(screen.queryByRole('button', { name: KEYS.oldest })).toBeNull()
})
it('shows newest and oldest options when showSortOptions is true', () => {
mountWithModels({ showSortOptions: true })
expect(getButton(KEYS.newest)).toBeTruthy()
expect(getButton(KEYS.oldest)).toBeTruthy()
})
it('hides longest/fastest options unless showGenerationTimeSort is also true', () => {
mountWithModels({
showSortOptions: true,
showGenerationTimeSort: false
})
expect(screen.queryByRole('button', { name: KEYS.longest })).toBeNull()
expect(screen.queryByRole('button', { name: KEYS.fastest })).toBeNull()
})
it('shows generation-time options when both flags are true', () => {
mountWithModels({
showSortOptions: true,
showGenerationTimeSort: true
})
expect(getButton(KEYS.longest)).toBeTruthy()
expect(getButton(KEYS.fastest)).toBeTruthy()
})
})
describe('v-model:sortBy round-trip', () => {
const cases: Array<{ key: keyof typeof KEYS; expected: SortBy }> = [
{ key: 'newest', expected: 'newest' },
{ key: 'oldest', expected: 'oldest' },
{ key: 'longest', expected: 'longest' },
{ key: 'fastest', expected: 'fastest' }
]
for (const { key, expected } of cases) {
it(`emits ${expected} when ${key} is clicked`, async () => {
const { sortBy, user } = mountWithModels({
sortBy: 'newest',
showSortOptions: true,
showGenerationTimeSort: true
})
await user.click(getButton(KEYS[key]))
expect(sortBy.value).toBe(expected)
})
}
})
})

View File

@@ -1,191 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
interface AssetSpec {
id: string
name: string
/** Unix ms; written into both `created_at` (ISO) and `user_metadata.create_time`. */
createTime?: number
/** Seconds, written into `user_metadata.executionTimeInSeconds`. */
executionSeconds?: number
}
function makeAsset(spec: AssetSpec): AssetItem {
const userMetadata: Record<string, unknown> = {}
if (spec.createTime !== undefined) {
userMetadata.create_time = spec.createTime
}
if (spec.executionSeconds !== undefined) {
userMetadata.executionTimeInSeconds = spec.executionSeconds
}
return {
id: spec.id,
name: spec.name,
tags: [],
created_at:
spec.createTime !== undefined
? new Date(spec.createTime).toISOString()
: undefined,
user_metadata: userMetadata
}
}
function ids(assets: AssetItem[]): string[] {
return assets.map((a) => a.id)
}
describe('useMediaAssetFiltering', () => {
describe('media-type filter', () => {
it('returns all assets when no filters are selected', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'a', name: 'a.png' }),
makeAsset({ id: 'b', name: 'b.mp4' }),
makeAsset({ id: 'c', name: 'c.glb' })
])
const { filteredAssets } = useMediaAssetFiltering(assets)
expect(ids(filteredAssets.value).sort()).toEqual(['a', 'b', 'c'])
})
it('filters to a single media kind', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img', name: 'img.png' }),
makeAsset({ id: 'vid', name: 'vid.mp4' }),
makeAsset({ id: 'aud', name: 'aud.wav' }),
makeAsset({ id: '3d', name: 'model.glb' })
])
const { mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['video']
expect(ids(filteredAssets.value)).toEqual(['vid'])
})
it('combines multiple kinds via OR', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img', name: 'img.png' }),
makeAsset({ id: 'vid', name: 'vid.mp4' }),
makeAsset({ id: 'aud', name: 'aud.wav' })
])
const { mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['image', 'audio']
expect(ids(filteredAssets.value).sort()).toEqual(['aud', 'img'])
})
it("normalizes '3D' filename detection to lowercase '3d' for filter match", () => {
// getMediaTypeFromFilename returns '3D' for .glb, but the filter array
// stores the lowercase '3d' the menu emits — composable must reconcile.
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img', name: 'img.png' }),
makeAsset({ id: 'mesh', name: 'mesh.glb' })
])
const { mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['3d']
expect(ids(filteredAssets.value)).toEqual(['mesh'])
})
it('excludes unsupported media kinds (e.g. text) when any filter is active', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img', name: 'img.png' }),
makeAsset({ id: 'doc', name: 'notes.txt' })
])
const { mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['image']
expect(ids(filteredAssets.value)).toEqual(['img'])
})
})
describe('sort', () => {
const t1 = 1_000_000
const t2 = 2_000_000
const t3 = 3_000_000
it('defaults to newest first by create_time descending', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'old', name: 'a.png', createTime: t1 }),
makeAsset({ id: 'mid', name: 'b.png', createTime: t2 }),
makeAsset({ id: 'new', name: 'c.png', createTime: t3 })
])
const { filteredAssets } = useMediaAssetFiltering(assets)
expect(ids(filteredAssets.value)).toEqual(['new', 'mid', 'old'])
})
it('sorts oldest first by create_time ascending', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'new', name: 'c.png', createTime: t3 }),
makeAsset({ id: 'old', name: 'a.png', createTime: t1 }),
makeAsset({ id: 'mid', name: 'b.png', createTime: t2 })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'oldest'
expect(ids(filteredAssets.value)).toEqual(['old', 'mid', 'new'])
})
it('sorts longest by executionTimeInSeconds descending', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'fast', name: 'a.png', executionSeconds: 3 }),
makeAsset({ id: 'slow', name: 'b.png', executionSeconds: 10 }),
makeAsset({ id: 'mid', name: 'c.png', executionSeconds: 5 })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'longest'
expect(ids(filteredAssets.value)).toEqual(['slow', 'mid', 'fast'])
})
it('sorts fastest by executionTimeInSeconds ascending', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'fast', name: 'a.png', executionSeconds: 3 }),
makeAsset({ id: 'slow', name: 'b.png', executionSeconds: 10 }),
makeAsset({ id: 'mid', name: 'c.png', executionSeconds: 5 })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'fastest'
expect(ids(filteredAssets.value)).toEqual(['fast', 'mid', 'slow'])
})
it('falls back to created_at when user_metadata.create_time is absent', () => {
const a = makeAsset({ id: 'a', name: 'a.png', createTime: t1 })
const b = makeAsset({ id: 'b', name: 'b.png', createTime: t2 })
// Strip the user_metadata.create_time path on both, leaving created_at.
a.user_metadata = {}
b.user_metadata = {}
const assets = ref<AssetItem[]>([a, b])
const { filteredAssets } = useMediaAssetFiltering(assets)
expect(ids(filteredAssets.value)).toEqual(['b', 'a'])
})
})
describe('composition', () => {
it('applies media-type filter then sort', () => {
const t1 = 1_000_000
const t2 = 2_000_000
const t3 = 3_000_000
const assets = ref<AssetItem[]>([
makeAsset({ id: 'img-old', name: 'a.png', createTime: t1 }),
makeAsset({ id: 'vid', name: 'b.mp4', createTime: t2 }),
makeAsset({ id: 'img-new', name: 'c.png', createTime: t3 })
])
const { mediaTypeFilters, sortBy, filteredAssets } =
useMediaAssetFiltering(assets)
mediaTypeFilters.value = ['image']
sortBy.value = 'oldest'
expect(ids(filteredAssets.value)).toEqual(['img-old', 'img-new'])
})
})
})

Some files were not shown because too many files have changed in this diff Show More