mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 14:11:55 +00:00
Compare commits
4 Commits
austin/nam
...
fix/lgraph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e5fd4a08 | ||
|
|
522095b79f | ||
|
|
777163f419 | ||
|
|
e9c397c0c0 |
@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| Perf test file | `browser_tests/tests/performance.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
|
||||
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
|
||||
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
|
||||
| Report generator | `scripts/perf-report.ts` |
|
||||
| Stats utilities | `scripts/perf-stats.ts` |
|
||||
|
||||
@@ -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
|
||||
|
||||
4
.github/workflows/ci-website-e2e.yaml
vendored
4
.github/workflows/ci-website-e2e.yaml
vendored
@@ -2,7 +2,7 @@ name: 'CI: Website E2E'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-700);
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-600);
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-500);
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
|
||||
1
apps/website/.gitignore
vendored
1
apps/website/.gitignore
vendored
@@ -2,7 +2,6 @@ dist/
|
||||
.astro/
|
||||
test-results/
|
||||
playwright-report/
|
||||
results.json
|
||||
|
||||
# Platform-specific Playwright snapshots (CI runs Linux)
|
||||
*-win32.png
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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": [
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const investors = [
|
||||
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
|
||||
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
|
||||
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
|
||||
{ name: 'ABSTRACT', icon: '/icons/investors/abstract.svg' },
|
||||
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
|
||||
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('about.story.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
|
||||
>
|
||||
{{ t('about.story.headingBefore', locale)
|
||||
}}<span class="text-primary-comfy-yellow">{{
|
||||
t('about.story.headingHighlight', locale)
|
||||
}}</span
|
||||
>{{ t('about.story.headingAfter', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
|
||||
{{ t('about.story.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Investor card -->
|
||||
<div
|
||||
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
|
||||
>
|
||||
<div class="inline-flex items-center">
|
||||
<!-- OUR badge (shorter) -->
|
||||
<div class="relative z-10 flex h-9 items-center">
|
||||
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
|
||||
>
|
||||
OUR
|
||||
</span>
|
||||
</div>
|
||||
<!-- Union connector (overlaps both badges to eliminate seams) -->
|
||||
<img
|
||||
src="/icons/node-union-2size-reverse.svg"
|
||||
alt=""
|
||||
class="relative z-20 -mx-px h-12 w-auto"
|
||||
/>
|
||||
<!-- INVESTORS badge (taller) -->
|
||||
<div class="relative z-10 flex h-12 items-center">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
|
||||
>
|
||||
INVESTORS
|
||||
</span>
|
||||
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('about.story.investorsBody', locale) }}
|
||||
</p>
|
||||
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
|
||||
<div
|
||||
v-for="investor in investors"
|
||||
:key="investor.name"
|
||||
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
|
||||
>
|
||||
<img
|
||||
:src="investor.icon"
|
||||
:alt="investor.name"
|
||||
class="max-h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
|
||||
>
|
||||
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
|
||||
{{ t('about.quote.text', locale) }}
|
||||
</p>
|
||||
<p
|
||||
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
|
||||
>
|
||||
{{ t('about.quote.attribution', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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
|
||||
|
||||
@@ -223,7 +223,7 @@ onUnmounted(() => {
|
||||
|
||||
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
|
||||
<BrandButton
|
||||
:href="externalLinks.apiKeys"
|
||||
:href="externalLinks.platform"
|
||||
size="lg"
|
||||
class="text-center lg:min-w-60 lg:p-4"
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
@@ -61,7 +61,7 @@ const steps = [
|
||||
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
|
||||
>
|
||||
<BrandButton
|
||||
:href="externalLinks.apiKeys"
|
||||
:href="externalLinks.cloud"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="w-full text-center lg:w-auto lg:min-w-48"
|
||||
@@ -69,7 +69,7 @@ const steps = [
|
||||
{{ t('api.hero.getApiKeys', locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
:href="externalLinks.docsApi"
|
||||
:href="externalLinks.docs"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full text-center lg:w-auto lg:min-w-48"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -44,303 +44,162 @@ onMounted(() => {
|
||||
<svg
|
||||
ref="svgRef"
|
||||
class="block size-full"
|
||||
viewBox="0 0 1600 1046"
|
||||
viewBox="600 -50 1000 1100"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g clip-path="url(#enterpriseHeroClip)">
|
||||
<rect width="1600" height="1046" fill="#211927" />
|
||||
<rect
|
||||
width="800"
|
||||
height="800"
|
||||
transform="translate(712 112)"
|
||||
fill="#211927"
|
||||
/>
|
||||
<!-- Ripple rings -->
|
||||
<path
|
||||
class="ripple-path"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-1"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-2"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-3"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- Ripple rings -->
|
||||
<!-- Exploding block cluster -->
|
||||
<g stroke="#4D3762" stroke-width="2">
|
||||
<path
|
||||
class="ripple-path"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
class="block-piece"
|
||||
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path delay-1"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
class="block-piece"
|
||||
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path delay-2"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
class="block-piece"
|
||||
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path delay-3"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
class="block-piece"
|
||||
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
|
||||
<!-- Exploding block cluster -->
|
||||
<g class="block-cluster">
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.372L1175.19 482.387C1175.19 475.211 1170.16 466.485 1163.94 462.899L1117.21 435.919C1113.28 433.647 1106.2 433.752 1102.27 436.025L1055.52 463.03C1049.31 466.62 1044.27 475.347 1044.27 482.524L1044.25 536.508C1044.25 541.052 1047.7 547.23 1051.64 549.502L1098.37 576.482C1104.58 580.069 1114.65 580.066 1120.87 576.476L1167.61 549.472C1171.55 547.194 1175.17 540.924 1175.17 536.372Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.2 528.1C1098.2 520.924 1093.16 512.198 1086.95 508.612L1040.22 481.632C1036.29 479.36 1029.21 479.465 1025.28 481.738L978.532 508.743C972.318 512.333 967.279 521.06 967.277 528.237L967.263 582.221C967.261 586.765 970.709 592.943 974.644 595.215L1021.37 622.195C1027.59 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.41 528.1C1125.4 520.924 1130.44 512.198 1136.65 508.612L1183.38 481.632C1187.32 479.36 1194.39 479.465 1198.32 481.738L1245.07 508.743C1251.28 512.333 1256.32 521.06 1256.32 528.237L1256.34 582.221C1256.34 586.765 1252.89 592.943 1248.96 595.215L1202.23 622.195C1196.01 625.782 1185.94 625.779 1179.73 622.189L1132.98 595.184C1129.04 592.907 1125.42 586.637 1125.42 582.085Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.173L1045.25 573.188C1045.25 566.012 1050.28 557.286 1056.49 553.7L1103.22 526.72C1107.16 524.448 1114.23 524.553 1118.17 526.826L1164.91 553.831C1171.13 557.42 1176.17 566.148 1176.17 573.325L1176.18 627.309C1176.18 631.853 1172.74 638.031 1168.8 640.303L1122.07 667.283C1115.86 670.87 1105.79 670.867 1099.57 667.277L1052.83 640.272C1048.88 637.995 1045.26 631.725 1045.26 627.173Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.630 971.884 332.359 975.826 330.082Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.910 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
width="422.621"
|
||||
height="1125.11"
|
||||
transform="matrix(-1 0 0 1 909.219 9.26587)"
|
||||
fill="url(#enterpriseHeroFade)"
|
||||
style="pointer-events: none"
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.63 971.884 332.359 975.826 330.082Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.91 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
width="422.621"
|
||||
height="1125.11"
|
||||
transform="matrix(-1 0 0 1 909.219 9.26587)"
|
||||
fill="url(#enterpriseHeroFade)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="enterpriseHeroFade"
|
||||
@@ -353,9 +212,6 @@ onMounted(() => {
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
<clipPath id="enterpriseHeroClip">
|
||||
<rect width="1600" height="1046" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -399,13 +255,13 @@ onMounted(() => {
|
||||
animation: ripple-effect 4s linear infinite;
|
||||
}
|
||||
|
||||
.delay-1 {
|
||||
.ripple-delay-1 {
|
||||
animation-delay: -1s;
|
||||
}
|
||||
.delay-2 {
|
||||
.ripple-delay-2 {
|
||||
animation-delay: -2s;
|
||||
}
|
||||
.delay-3 {
|
||||
.ripple-delay-3 {
|
||||
animation-delay: -3s;
|
||||
}
|
||||
|
||||
@@ -425,11 +281,6 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.block-cluster {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
.block-piece {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -27,7 +27,6 @@ export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
}
|
||||
|
||||
export const externalLinks = {
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -4,12 +4,12 @@ const translations = {
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
'zh-CN': '视觉 AI 的\n最强可控性'
|
||||
'zh-CN': '视觉 AI 的\n专业控制'
|
||||
},
|
||||
'hero.subtitle': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
@@ -20,11 +20,11 @@ const translations = {
|
||||
},
|
||||
'showcase.subtitle2': {
|
||||
en: 'Start from a community template or build from scratch.',
|
||||
'zh-CN': '从工作流模板开始,或从零构建。'
|
||||
'zh-CN': '从社区模板开始,或从零构建。'
|
||||
},
|
||||
'showcase.feature1.title': {
|
||||
en: 'Full Control with Nodes',
|
||||
'zh-CN': '节点带来的可控性'
|
||||
'zh-CN': '节点式完全控制'
|
||||
},
|
||||
'showcase.feature1.description': {
|
||||
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
|
||||
@@ -49,8 +49,8 @@ const translations = {
|
||||
'zh-CN':
|
||||
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
|
||||
},
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运作' },
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.label': {
|
||||
@@ -83,7 +83,8 @@ const translations = {
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
|
||||
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
|
||||
},
|
||||
'useCase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
@@ -163,7 +164,7 @@ const translations = {
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
'zh-CN': '查看本地版特性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -175,7 +176,7 @@ const translations = {
|
||||
},
|
||||
'products.cloud.cta': {
|
||||
en: 'SEE CLOUD FEATURES',
|
||||
'zh-CN': '查看云端属性'
|
||||
'zh-CN': '查看云端特性'
|
||||
},
|
||||
'products.api.title': {
|
||||
en: 'Comfy\nAPI',
|
||||
@@ -187,7 +188,7 @@ const translations = {
|
||||
},
|
||||
'products.api.cta': {
|
||||
en: 'SEE API FEATURES',
|
||||
'zh-CN': '查看 API 属性'
|
||||
'zh-CN': '查看 API 特性'
|
||||
},
|
||||
'products.enterprise.title': {
|
||||
en: 'Comfy\nEnterprise',
|
||||
@@ -199,7 +200,7 @@ const translations = {
|
||||
},
|
||||
'products.enterprise.cta': {
|
||||
en: 'SEE ENTERPRISE FEATURES',
|
||||
'zh-CN': '查看企业版属性'
|
||||
'zh-CN': '查看企业版特性'
|
||||
},
|
||||
|
||||
// CaseStudySpotlightSection
|
||||
@@ -1214,7 +1215,7 @@ const translations = {
|
||||
'pricing.included.feature4.description': {
|
||||
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
|
||||
'zh-CN':
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro)。'
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro)。'
|
||||
},
|
||||
'pricing.included.feature5.title': {
|
||||
en: 'Add more credits anytime',
|
||||
@@ -1244,12 +1245,12 @@ const translations = {
|
||||
},
|
||||
'pricing.included.feature8.title': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
'zh-CN': '合作节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
@@ -1504,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' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/about/HeroSection.vue'
|
||||
import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
@@ -9,7 +8,6 @@ import CareersSection from '../components/about/CareersSection.vue'
|
||||
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
<ValuesSection client:visible />
|
||||
<CareersSection />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/about/HeroSection.vue'
|
||||
import StorySection from '../../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../../components/about/CareersSection.vue'
|
||||
@@ -9,7 +8,6 @@ import CareersSection from '../../components/about/CareersSection.vue'
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<OurValuesSection locale="zh-CN" />
|
||||
<ValuesSection locale="zh-CN" client:visible />
|
||||
<CareersSection locale="zh-CN" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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" }]
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
globals: false
|
||||
}
|
||||
})
|
||||
@@ -15,15 +15,11 @@ browser_tests/
|
||||
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
|
||||
│ ├── selectors.ts - Centralized TestIds
|
||||
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
|
||||
│ ├── components/ - Page object classes (locators, user interactions)
|
||||
│ │ ├── Actionbar.ts
|
||||
│ ├── components/ - Page object components (locators, user interactions)
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── ManageGroupNode.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ ├── Templates.ts
|
||||
│ │ ├── Topbar.ts
|
||||
│ │ └── ...
|
||||
│ │ └── Topbar.ts
|
||||
│ ├── helpers/ - Focused helper classes (domain-specific actions)
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
@@ -32,36 +28,17 @@ browser_tests/
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Standalone utility functions (used by tests or fixtures)
|
||||
│ ├── builderTestUtils.ts
|
||||
│ ├── clipboardSpy.ts
|
||||
│ ├── fitToView.ts
|
||||
│ ├── perfReporter.ts
|
||||
│ └── ...
|
||||
│ └── utils/ - Pure utility functions (no page dependency)
|
||||
├── helpers/ - Test-specific utilities
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
### Architectural Separation
|
||||
|
||||
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
|
||||
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
|
||||
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
|
||||
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
|
||||
|
||||
### Placement Rule
|
||||
|
||||
When adding a new file, use this decision tree:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[New file in browser_tests/fixtures/] --> B{Has any code?}
|
||||
B -- No, JSON/data only --> D[fixtures/data/]
|
||||
B -- Yes --> C{Is it a class?}
|
||||
C -- No, exported functions --> U[fixtures/utils/]
|
||||
C -- Yes --> E{Owns locators for a<br/>specific UI region?}
|
||||
E -- Yes --> P[fixtures/components/]
|
||||
E -- No, coordinates actions<br/>across the app --> H[fixtures/helpers/]
|
||||
```
|
||||
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
|
||||
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
||||
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
|
||||
|
||||
## Page Object Locator Style
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ DISABLE_VUE_PLUGINS=true
|
||||
# Test against dev server (recommended) or backend directly
|
||||
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
|
||||
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
|
||||
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
|
||||
|
||||
# Path to ComfyUI for backing up user data/settings before tests
|
||||
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
@@ -140,9 +139,12 @@ Always check for existing helpers and fixtures before implementing new ones:
|
||||
|
||||
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
|
||||
- **ComfyMouse**: Helper for precise mouse operations on the canvas
|
||||
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component page objects (e.g. `Actionbar.ts`, `Templates.ts`, `ContextMenu.ts`)
|
||||
- **Helper Classes**: Check `browser_tests/fixtures/helpers/` for domain-specific helper classes wired into ComfyPage (e.g. `CanvasHelper.ts`, `WorkflowHelper.ts`)
|
||||
- **Utility Functions**: Check `browser_tests/fixtures/utils/` for standalone utilities (e.g. `fitToView.ts`, `clipboardSpy.ts`, `builderTestUtils.ts`)
|
||||
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
|
||||
- `actionbar.ts`: Interact with the action bar
|
||||
- `manageGroupNode.ts`: Group node management operations
|
||||
- `templates.ts`: Template workflows operations
|
||||
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
|
||||
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [450, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
|
||||
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
@@ -22,7 +22,6 @@ import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
|
||||
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
|
||||
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
ModelLibrarySidebarTab,
|
||||
@@ -55,13 +54,11 @@ class ComfyPropertiesPanel {
|
||||
readonly root: Locator
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +137,6 @@ class ComfyMenu {
|
||||
|
||||
export class ComfyPage {
|
||||
public readonly url: string
|
||||
public readonly apiUrl: string
|
||||
// All canvas position operations are based on default view of canvas.
|
||||
public readonly canvas: Locator
|
||||
public readonly selectionToolbox: Locator
|
||||
@@ -163,7 +159,6 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly templatesDialog: TemplatesDialog
|
||||
public readonly titleEditor: TitleEditor
|
||||
public readonly mediaLightbox: MediaLightbox
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly appMode: AppModeHelper
|
||||
@@ -200,7 +195,6 @@ export class ComfyPage {
|
||||
public readonly request: APIRequestContext
|
||||
) {
|
||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
|
||||
this.canvas = page.locator('#graph-canvas')
|
||||
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
@@ -210,14 +204,13 @@ export class ComfyPage {
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
this.templates = new ComfyTemplates(page)
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.templatesDialog = new TemplatesDialog(page)
|
||||
this.titleEditor = new TitleEditor(page)
|
||||
this.mediaLightbox = new MediaLightbox(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.appMode = new AppModeHelper(this)
|
||||
@@ -243,7 +236,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.apiUrl}/api/users`)
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||
|
||||
@@ -257,7 +250,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async createUser(username: string) {
|
||||
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
|
||||
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
@@ -269,7 +262,7 @@ export class ComfyPage {
|
||||
|
||||
async setupSettings(settings: Record<string, unknown>) {
|
||||
const resp = await this.request.post(
|
||||
`${this.apiUrl}/api/devtools/set_settings`,
|
||||
`${this.url}/api/devtools/set_settings`,
|
||||
{
|
||||
data: settings
|
||||
}
|
||||
|
||||
@@ -1,104 +1,29 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
export type { RootCategoryId }
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
readonly filterSearch: Locator
|
||||
readonly results: Locator
|
||||
readonly filterOptions: Locator
|
||||
readonly filterChips: Locator
|
||||
readonly noResults: Locator
|
||||
readonly nodeIdBadge: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
const page = comfyPage.page
|
||||
constructor(readonly page: Page) {
|
||||
this.dialog = page.getByRole('search')
|
||||
this.input = this.dialog.getByRole('combobox')
|
||||
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
|
||||
this.results = this.dialog.getByTestId(searchBoxV2.resultItem)
|
||||
this.filterOptions = this.dialog.getByTestId(searchBoxV2.filterOption)
|
||||
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
|
||||
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
|
||||
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
|
||||
this.input = this.dialog.locator('input[type="text"]')
|
||||
this.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
}
|
||||
|
||||
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
|
||||
categoryButton(categoryId: string): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.category(categoryId))
|
||||
return this.dialog.getByTestId(`category-${categoryId}`)
|
||||
}
|
||||
|
||||
/** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */
|
||||
rootCategoryButton(id: RootCategoryId): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.rootCategory(id))
|
||||
filterBarButton(name: string): Locator {
|
||||
return this.dialog.getByRole('button', { name })
|
||||
}
|
||||
|
||||
/** Top filter-bar input/output type popover trigger. */
|
||||
typeFilterButton(key: 'input' | 'output'): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.typeFilter(key))
|
||||
}
|
||||
|
||||
async applyTypeFilter(
|
||||
key: 'input' | 'output',
|
||||
typeName: string
|
||||
): Promise<void> {
|
||||
const trigger = this.typeFilterButton(key)
|
||||
await trigger.click()
|
||||
await this.filterOptions.first().waitFor({ state: 'visible' })
|
||||
await this.filterSearch.fill(typeName)
|
||||
await this.filterOptions.filter({ hasText: typeName }).first().click()
|
||||
// The popover does not auto-close on selection — toggle the trigger.
|
||||
await trigger.click()
|
||||
await this.filterOptions.first().waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async removeFilterChip(index = 0): Promise<void> {
|
||||
await this.filterChips
|
||||
.nth(index)
|
||||
.getByTestId(searchBoxV2.chipDelete)
|
||||
.click()
|
||||
}
|
||||
|
||||
async toggle(): Promise<void> {
|
||||
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (await this.input.isVisible()) return
|
||||
await this.toggle()
|
||||
await this.input.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async openByDoubleClickCanvas(): Promise<void> {
|
||||
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
|
||||
// does not intercept; coords target a viewport spot that is on the canvas
|
||||
// and clear of both the side toolbar and any default-graph nodes.
|
||||
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
}
|
||||
|
||||
async ensureV2Search(): Promise<void> {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
}
|
||||
|
||||
async setup(): Promise<void> {
|
||||
await this.ensureV2Search()
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
async reload(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class PublishDialog extends BaseDialog {
|
||||
readonly nav: Locator
|
||||
readonly footer: Locator
|
||||
readonly savePrompt: Locator
|
||||
readonly describeStep: Locator
|
||||
readonly finishStep: Locator
|
||||
readonly profilePrompt: Locator
|
||||
readonly gateFlow: Locator
|
||||
readonly nameInput: Locator
|
||||
readonly descriptionTextarea: Locator
|
||||
readonly tagsInput: Locator
|
||||
readonly backButton: Locator
|
||||
readonly nextButton: Locator
|
||||
readonly publishButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.publish.dialog)
|
||||
this.nav = this.root.getByTestId(TestIds.publish.nav)
|
||||
this.footer = this.root.getByTestId(TestIds.publish.footer)
|
||||
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
|
||||
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
|
||||
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
|
||||
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
|
||||
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
|
||||
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
|
||||
this.descriptionTextarea = this.describeStep.locator('textarea')
|
||||
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
|
||||
this.backButton = this.footer.getByRole('button', { name: 'Back' })
|
||||
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
|
||||
this.publishButton = this.footer.getByRole('button', {
|
||||
name: 'Publish to ComfyHub'
|
||||
})
|
||||
}
|
||||
|
||||
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
|
||||
// dev and production, rather than clicking through the UI.
|
||||
async open(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.dialog.showPublishDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
tagSuggestion(name: string): Locator {
|
||||
return this.describeStep.getByText(name, { exact: true })
|
||||
}
|
||||
|
||||
navStep(label: string): Locator {
|
||||
return this.nav.getByRole('button', { name: label })
|
||||
}
|
||||
|
||||
currentNavStep(): Locator {
|
||||
return this.nav.locator('[aria-current="step"]')
|
||||
}
|
||||
|
||||
async goNext(): Promise<void> {
|
||||
await this.nextButton.click()
|
||||
}
|
||||
|
||||
async goBack(): Promise<void> {
|
||||
await this.backButton.click()
|
||||
}
|
||||
|
||||
async goToStep(label: string): Promise<void> {
|
||||
await this.navStep(label).click()
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* The node/group title-editing input. Rendered in three scopes: the canvas
|
||||
* overlay (page-wide), the properties panel, and the Vue node itself.
|
||||
*/
|
||||
export class TitleEditor {
|
||||
public readonly input: Locator
|
||||
|
||||
constructor(scope: Page | Locator) {
|
||||
this.input = scope.getByTestId(TestIds.node.titleInput)
|
||||
}
|
||||
|
||||
async setTitle(title: string): Promise<void> {
|
||||
await this.input.fill(title)
|
||||
await this.input.press('Enter')
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
await this.input.press('Escape')
|
||||
}
|
||||
|
||||
async expectVisible(): Promise<void> {
|
||||
await expect(this.input).toBeVisible()
|
||||
}
|
||||
|
||||
async expectHidden(): Promise<void> {
|
||||
await expect(this.input).toBeHidden()
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,32 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportResponse,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = '**/api/jobs?*'
|
||||
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetExportRoutePattern = '**/api/assets/export'
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,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']
|
||||
@@ -168,23 +88,12 @@ function getExecutionDuration(job: RawJobListItem): number {
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private cloudAssetsResponse: ListAssetsResponse | null = null
|
||||
private assetExportRequests: CreateAssetExportData['body'][] = []
|
||||
private assetExportResponse: CreateAssetExportResponse | null = null
|
||||
private importedFiles: string[] = []
|
||||
private readonly jobDetailRouteHandlers = new Map<
|
||||
string,
|
||||
(route: Route) => Promise<void>
|
||||
>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -261,82 +170,6 @@ export class AssetsHelper {
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
|
||||
this.cloudAssetsResponse = response
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cloudAssetsRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.cloudAssetsResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyCloudAssets(): Promise<void> {
|
||||
await this.mockCloudAssets({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
})
|
||||
}
|
||||
|
||||
async captureAssetExportRequests(
|
||||
response: CreateAssetExportResponse = {
|
||||
task_id: 'asset-export-task',
|
||||
status: 'created'
|
||||
}
|
||||
): Promise<CreateAssetExportData['body'][]> {
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = response
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
this.assetExportRouteHandler = async (route: Route) => {
|
||||
this.assetExportRequests.push(
|
||||
route.request().postDataJSON() as CreateAssetExportData['body']
|
||||
)
|
||||
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.assetExportResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
|
||||
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
|
||||
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
|
||||
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
|
||||
|
||||
if (existingHandler) {
|
||||
await this.page.unroute(pattern, existingHandler)
|
||||
}
|
||||
|
||||
const handler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(detail)
|
||||
})
|
||||
}
|
||||
|
||||
this.jobDetailRouteHandlers.set(pattern, handler)
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
@@ -392,9 +225,6 @@ export class AssetsHelper {
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.cloudAssetsResponse = null
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = null
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
@@ -402,22 +232,6 @@ export class AssetsHelper {
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetsListRoutePattern,
|
||||
this.cloudAssetsRouteHandler
|
||||
)
|
||||
this.cloudAssetsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetExportRoutePattern,
|
||||
this.assetExportRouteHandler
|
||||
)
|
||||
this.assetExportRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
@@ -433,10 +247,5 @@ export class AssetsHelper {
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
|
||||
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.jobDetailRouteHandlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class CanvasHelper {
|
||||
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
|
||||
* cause Playwright's actionability check to fail on the canvas locator.
|
||||
*/
|
||||
async toAbsolute(position: Position): Promise<Position> {
|
||||
private async toAbsolute(position: Position): Promise<Position> {
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
return { x: box.x + position.x, y: box.y + position.y }
|
||||
@@ -150,24 +150,6 @@ export class CanvasHelper {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async getOffset(): Promise<[number, number]> {
|
||||
return this.page.evaluate(
|
||||
() => [...window.app!.canvas.ds.offset] as [number, number]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold `Control+Shift` and drag from `from` to `to` using page-absolute
|
||||
* coordinates.
|
||||
*/
|
||||
async ctrlShiftDrag(from: Position, to: Position): Promise<void> {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.down('Shift')
|
||||
await this.dragAndDrop(from, to)
|
||||
await this.page.keyboard.up('Shift')
|
||||
await this.page.keyboard.up('Control')
|
||||
}
|
||||
|
||||
async convertOffsetToCanvas(
|
||||
pos: [number, number]
|
||||
): Promise<[number, number]> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { basename } from 'path'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
|
||||
@@ -13,11 +13,7 @@ import type { Page } from '@playwright/test'
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
private readonly appUrl: string
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
}
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
@@ -38,7 +34,7 @@ export class CloudAuthHelper {
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto(`${this.appUrl}/api/users`)
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'fs'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
GraphAddOptions,
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
@@ -43,45 +39,6 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a node to the graph by type.
|
||||
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
|
||||
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
|
||||
* true and cursorPosition is provided, a synthetic MouseEvent is created
|
||||
* as the dragEvent.
|
||||
* @param cursorPosition - Client coordinates for ghost placement dragEvent
|
||||
*/
|
||||
async addNode(
|
||||
type: string,
|
||||
options?: Omit<GraphAddOptions, 'dragEvent'>,
|
||||
cursorPosition?: Position
|
||||
): Promise<NodeReference> {
|
||||
const id = await this.page.evaluate(
|
||||
([nodeType, opts, cursor]) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)!
|
||||
const addOpts: Record<string, unknown> = { ...opts }
|
||||
if (opts?.ghost && cursor) {
|
||||
addOpts.dragEvent = new MouseEvent('click', {
|
||||
clientX: cursor.x,
|
||||
clientY: cursor.y
|
||||
})
|
||||
}
|
||||
window.app!.graph.add(node, addOpts as GraphAddOptions)
|
||||
return node.id
|
||||
},
|
||||
[type, options ?? {}, cursorPosition ?? null] as const
|
||||
)
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
}
|
||||
|
||||
/** Remove all nodes from the graph and clean. */
|
||||
async clearGraph() {
|
||||
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
AssetInfo,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
HubLabelListResponse,
|
||||
HubProfile,
|
||||
WorkflowPublishInfo
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
|
||||
|
||||
const DEFAULT_PROFILE: HubProfile = {
|
||||
username: 'testuser',
|
||||
display_name: 'Test User',
|
||||
description: 'A test creator',
|
||||
avatar_url: undefined
|
||||
}
|
||||
|
||||
const DEFAULT_TAG_LABELS: HubLabelInfo[] = [
|
||||
{ name: 'anime', display_name: 'anime', type: 'tag' },
|
||||
{ name: 'upscale', display_name: 'upscale', type: 'tag' },
|
||||
{ name: 'faceswap', display_name: 'faceswap', type: 'tag' },
|
||||
{ name: 'img2img', display_name: 'img2img', type: 'tag' },
|
||||
{ name: 'controlnet', display_name: 'controlnet', type: 'tag' }
|
||||
]
|
||||
|
||||
const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = {
|
||||
workflow_id: 'test-workflow-id-456',
|
||||
share_id: 'test-share-id-123',
|
||||
publish_time: new Date().toISOString(),
|
||||
listed: true,
|
||||
assets: []
|
||||
}
|
||||
|
||||
const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
|
||||
upload_url: 'https://mock-s3.example.com/upload',
|
||||
public_url: 'https://mock-s3.example.com/asset.png',
|
||||
token: 'mock-upload-token'
|
||||
}
|
||||
|
||||
export class PublishApiHelper {
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockProfile(profile: HubProfile | null): Promise<void> {
|
||||
await this.addRoute('**/hub/profiles/me', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
if (profile === null) {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(profile)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async mockTagLabels(
|
||||
labels: HubLabelInfo[] = DEFAULT_TAG_LABELS
|
||||
): Promise<void> {
|
||||
const response: HubLabelListResponse = { labels }
|
||||
await this.addRoute('**/hub/labels**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishStatus(
|
||||
status: 'unpublished' | WorkflowPublishInfo
|
||||
): Promise<void> {
|
||||
await this.addRoute('**/userdata/*/publish', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
if (status === 'unpublished') {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(status)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
|
||||
const response: ShareableAssetsResponse = { assets }
|
||||
await this.addRoute('**/assets/from-workflow', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishWorkflow(
|
||||
response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE
|
||||
): Promise<void> {
|
||||
await this.removeRoutes('**/hub/workflows')
|
||||
await this.addRoute('**/hub/workflows', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishWorkflowError(
|
||||
statusCode = 500,
|
||||
message = 'Failed to publish workflow'
|
||||
): Promise<void> {
|
||||
await this.removeRoutes('**/hub/workflows')
|
||||
await this.addRoute('**/hub/workflows', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: statusCode,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockUploadUrl(
|
||||
response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE
|
||||
): Promise<void> {
|
||||
await this.addRoute('**/hub/assets/upload-url', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async setupDefaultMocks(options?: {
|
||||
hasProfile?: boolean
|
||||
hasPrivateAssets?: boolean
|
||||
}): Promise<void> {
|
||||
const { hasProfile = true, hasPrivateAssets = false } = options ?? {}
|
||||
|
||||
await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null)
|
||||
await this.mockTagLabels()
|
||||
await this.mockPublishStatus('unpublished')
|
||||
await this.mockShareableAssets(
|
||||
hasPrivateAssets
|
||||
? [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'my_model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
await this.mockPublishWorkflow()
|
||||
await this.mockUploadUrl()
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
}
|
||||
|
||||
private async addRoute(
|
||||
pattern: string,
|
||||
handler: (route: Route) => Promise<void>
|
||||
): Promise<void> {
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
private async removeRoutes(pattern: string): Promise<void> {
|
||||
const handlers = this.routeHandlers.filter(
|
||||
(route) => route.pattern === pattern
|
||||
)
|
||||
for (const { handler } of handlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = this.routeHandlers.filter(
|
||||
(route) => route.pattern !== pattern
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const publishFixture = comfyPageFixture.extend<{
|
||||
publishApi: PublishApiHelper
|
||||
publishDialog: PublishDialog
|
||||
}>({
|
||||
publishApi: async ({ comfyPage }, use) => {
|
||||
const helper = new PublishApiHelper(comfyPage.page)
|
||||
await use(helper)
|
||||
await helper.cleanup()
|
||||
},
|
||||
publishDialog: async ({ comfyPage }, use) => {
|
||||
await use(new PublishDialog(comfyPage.page))
|
||||
}
|
||||
})
|
||||
@@ -59,9 +59,6 @@ export const TestIds = {
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
@@ -86,10 +83,7 @@ export const TestIds = {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button',
|
||||
loginButton: 'login-button',
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more'
|
||||
subscribeButton: 'topbar-subscribe-button'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
@@ -215,18 +209,6 @@ export const TestIds = {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
},
|
||||
publish: {
|
||||
dialog: 'publish-dialog',
|
||||
savePrompt: 'publish-save-prompt',
|
||||
describeStep: 'publish-describe-step',
|
||||
finishStep: 'publish-finish-step',
|
||||
footer: 'publish-footer',
|
||||
profilePrompt: 'publish-profile-prompt',
|
||||
nav: 'publish-nav',
|
||||
gateFlow: 'publish-gate-flow',
|
||||
nameInput: 'publish-name-input',
|
||||
tagsInput: 'publish-tags-input'
|
||||
},
|
||||
loading: {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
@@ -252,17 +234,6 @@ export const TestIds = {
|
||||
batchCounter: 'batch-counter',
|
||||
batchNext: 'batch-next',
|
||||
batchPrev: 'batch-prev'
|
||||
},
|
||||
searchBoxV2: {
|
||||
resultItem: 'result-item',
|
||||
filterOption: 'filter-option',
|
||||
filterChip: 'filter-chip',
|
||||
chipDelete: 'chip-delete',
|
||||
noResults: 'no-results',
|
||||
nodeIdBadge: 'node-id-badge',
|
||||
category: (id: string) => `category-${id}`,
|
||||
rootCategory: (id: string) => `search-category-${id}`,
|
||||
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
|
||||
}
|
||||
} as const
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
public readonly header: Locator
|
||||
public readonly title: Locator
|
||||
public readonly titleEditor: TitleEditor
|
||||
public readonly titleInput: Locator
|
||||
public readonly body: Locator
|
||||
public readonly pinIndicator: Locator
|
||||
public readonly collapseButton: Locator
|
||||
@@ -17,7 +16,7 @@ export class VueNodeFixture {
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
this.title = locator.getByTestId('node-title')
|
||||
this.titleEditor = new TitleEditor(locator)
|
||||
this.titleInput = locator.getByTestId('node-title-input')
|
||||
this.body = locator.locator('[data-testid^="node-body-"]')
|
||||
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
|
||||
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||
@@ -31,8 +30,17 @@ export class VueNodeFixture {
|
||||
|
||||
async setTitle(value: string): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
await this.titleEditor.expectVisible()
|
||||
await this.titleEditor.setTitle(value)
|
||||
const input = this.titleInput
|
||||
await input.waitFor({ state: 'visible' })
|
||||
await input.fill(value)
|
||||
await input.press('Enter')
|
||||
}
|
||||
|
||||
async cancelTitleEdit(): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await input.waitFor({ state: 'visible' })
|
||||
await input.press('Escape')
|
||||
}
|
||||
|
||||
async toggleCollapse(): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { config as dotenvConfig } from 'dotenv'
|
||||
import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { COVERAGE_OUTPUT_DIR, coverageSourceFilter } from '@e2e/coverageConfig'
|
||||
import { writePerfReport } from '@e2e/fixtures/utils/perfReporter'
|
||||
import { writePerfReport } from '@e2e/helpers/perfReporter'
|
||||
import { restorePath } from '@e2e/utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
import { comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
|
||||
interface BuilderSetupResult {
|
||||
inputNodeTitle: string
|
||||
@@ -56,13 +56,7 @@ export function writePerfReport(
|
||||
gitSha = process.env.GITHUB_SHA ?? 'local',
|
||||
branch = process.env.GITHUB_HEAD_REF ?? 'local'
|
||||
) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync('test-results', { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!entries.length) return
|
||||
if (!readdirSync('test-results', { withFileTypes: true }).length) return
|
||||
|
||||
let tempFiles: string[]
|
||||
try {
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode arrange step', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
|
||||
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
|
||||
const RESIZE_NODE_ID = '1'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import {
|
||||
saveAndReopenInAppMode,
|
||||
setupSubgraphBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
} from '@e2e/helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import {
|
||||
getClipboardText,
|
||||
interceptClipboardWrite
|
||||
} from '@e2e/fixtures/utils/clipboardSpy'
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
} from '@e2e/helpers/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
} from '@e2e/helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
|
||||
/**
|
||||
* After a first save, open save-as again from the chevron,
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
const CLIP_NODE_COUNT = 2
|
||||
|
||||
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
|
||||
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(
|
||||
clipNodes,
|
||||
'Default workflow is expected to contain exactly two CLIPTextEncode nodes'
|
||||
).toHaveLength(CLIP_NODE_COUNT)
|
||||
const p1 = await clipNodes[0].getPosition()
|
||||
const p2 = await clipNodes[1].getPosition()
|
||||
const margin = 64
|
||||
const from = await comfyPage.canvasOps.toAbsolute({
|
||||
x: Math.min(p1.x, p2.x) - margin,
|
||||
y: Math.min(p1.y, p2.y) - margin
|
||||
})
|
||||
const to = await comfyPage.canvasOps.toAbsolute({
|
||||
x: Math.max(p1.x, p2.x) + margin,
|
||||
y: Math.max(p1.y, p2.y) + margin
|
||||
})
|
||||
return { from, to }
|
||||
}
|
||||
|
||||
test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Graph.CanvasInfo', () => {
|
||||
test(
|
||||
'toggles the bottom-left HUD',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const box = await comfyPage.canvas.boundingBox()
|
||||
expect(box, 'Canvas bounding box must be available').not.toBeNull()
|
||||
// HUD is drawn ~80px tall along the bottom edge of the canvas; grab a
|
||||
// comfortable 180px × 160px strip to catch it across viewports.
|
||||
const HUD_WIDTH = 180
|
||||
const HUD_HEIGHT = 160
|
||||
const hudClip = {
|
||||
x: box!.x,
|
||||
y: box!.y + box!.height - HUD_HEIGHT,
|
||||
width: HUD_WIDTH,
|
||||
height: HUD_HEIGHT
|
||||
}
|
||||
|
||||
await test.step('Capture HUD region with setting off', async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-off.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Capture HUD region with setting on', async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-on.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Comfy.Graph.CtrlShiftZoom', () => {
|
||||
const CTRL_SHIFT_DRAG_FROM = { x: 100, y: 100 }
|
||||
const CTRL_SHIFT_DRAG_TO = { x: 400, y: 400 }
|
||||
|
||||
test('Ctrl+Shift+drag zooms canvas when enabled', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', true)
|
||||
await comfyPage.canvasOps.resetView()
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
CTRL_SHIFT_DRAG_FROM,
|
||||
CTRL_SHIFT_DRAG_TO
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
.not.toBeCloseTo(initialScale, 2)
|
||||
})
|
||||
|
||||
test('Ctrl+Shift+drag does not zoom when disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', false)
|
||||
await comfyPage.canvasOps.resetView()
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
CTRL_SHIFT_DRAG_FROM,
|
||||
CTRL_SHIFT_DRAG_TO
|
||||
)
|
||||
|
||||
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Graph.LiveSelection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.NavigationMode',
|
||||
'standard'
|
||||
)
|
||||
})
|
||||
|
||||
test('selects nodes mid-drag when enabled', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', true)
|
||||
const { from, to } = await getClipNodesDragBox(comfyPage)
|
||||
|
||||
await comfyPage.page.mouse.move(from.x, from.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(CLIP_NODE_COUNT)
|
||||
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('defers selection to drag end when disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', false)
|
||||
const { from, to } = await getClipNodesDragBox(comfyPage)
|
||||
|
||||
await comfyPage.page.mouse.move(from.x, from.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
|
||||
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
|
||||
|
||||
await comfyPage.page.mouse.up()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(CLIP_NODE_COUNT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Canvas.MouseWheelScroll', () => {
|
||||
const WHEEL_POS = { x: 400, y: 400 }
|
||||
|
||||
test('wheel zooms when set to zoom', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.MouseWheelScroll',
|
||||
'zoom'
|
||||
)
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.getScale()).not.toBeCloseTo(
|
||||
initialScale,
|
||||
3
|
||||
)
|
||||
})
|
||||
|
||||
test('wheel pans when set to panning', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.MouseWheelScroll',
|
||||
'panning'
|
||||
)
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
const initialOffset = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 3)
|
||||
const offset = await comfyPage.canvasOps.getOffset()
|
||||
expect(
|
||||
Math.abs(offset[0] - initialOffset[0]) +
|
||||
Math.abs(offset[1] - initialOffset[1])
|
||||
).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => {
|
||||
test('override to panning makes empty left-drag pan the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step("Flip to 'select' then back to 'panning' (NavigationMode→custom)", async () => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.LeftMouseClickBehavior',
|
||||
'select'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.LeftMouseClickBehavior',
|
||||
'panning'
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.canvasOps.resetView()
|
||||
|
||||
const initialOffset = await comfyPage.canvasOps.getOffset()
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: 200, y: 300 },
|
||||
{ x: 400, y: 500 }
|
||||
)
|
||||
const offset = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
expect(
|
||||
Math.abs(offset[0] - initialOffset[0]) +
|
||||
Math.abs(offset[1] - initialOffset[1])
|
||||
).toBeGreaterThan(50)
|
||||
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('override to select turns empty left-drag into a selection rectangle', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.LeftMouseClickBehavior',
|
||||
'select'
|
||||
)
|
||||
const { from, to } = await getClipNodesDragBox(comfyPage)
|
||||
|
||||
await comfyPage.canvasOps.dragAndDrop(from, to)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(CLIP_NODE_COUNT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pointer settings', () => {
|
||||
/**
|
||||
* Press left-mouse at canvas-relative `pos`, hold for `holdMs` (0 = no
|
||||
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
|
||||
* because it exists only to probe the CanvasPointer timing thresholds.
|
||||
*/
|
||||
const holdDragAt = async (
|
||||
comfyPage: ComfyPage,
|
||||
pos: { x: number; y: number },
|
||||
opts: { dx: number; dy: number; holdMs: number }
|
||||
) => {
|
||||
const abs = await comfyPage.canvasOps.toAbsolute(pos)
|
||||
await comfyPage.page.mouse.move(abs.x, abs.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await sleep(opts.holdMs)
|
||||
await comfyPage.page.mouse.move(abs.x + opts.dx, abs.y + opts.dy)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('DoubleClickTime controls whether two clicks open the title editor', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.DoubleClickTitleToEdit',
|
||||
true
|
||||
)
|
||||
const clipNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(
|
||||
clipNodes,
|
||||
'Default workflow must have CLIPTextEncode nodes'
|
||||
).toHaveLength(CLIP_NODE_COUNT)
|
||||
const titlePos = await clipNodes[0].getTitlePosition()
|
||||
const CLICK_GAP_MS = 200
|
||||
|
||||
await test.step(`Gap (${CLICK_GAP_MS}ms) exceeds DoubleClickTime → editor stays hidden`, async () => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Pointer.DoubleClickTime',
|
||||
100
|
||||
)
|
||||
await comfyPage.canvasOps.mouseClickAt(titlePos)
|
||||
await sleep(CLICK_GAP_MS)
|
||||
await comfyPage.canvasOps.mouseClickAt(titlePos)
|
||||
await comfyPage.titleEditor.expectHidden()
|
||||
})
|
||||
|
||||
await test.step(`Gap (${CLICK_GAP_MS}ms) within DoubleClickTime → editor opens`, async () => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Pointer.DoubleClickTime',
|
||||
1000
|
||||
)
|
||||
await comfyPage.canvasOps.mouseClickAt(titlePos)
|
||||
await sleep(CLICK_GAP_MS)
|
||||
await comfyPage.canvasOps.mouseClickAt(titlePos)
|
||||
await comfyPage.titleEditor.expectVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('ClickBufferTime governs the click-vs-drag time threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Keep drift generous so only elapsed time distinguishes click vs drag.
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
|
||||
const node = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
)[0]
|
||||
const titlePos = await node.getTitlePosition()
|
||||
const NUDGE = 2
|
||||
const HOLD_MS = 250
|
||||
|
||||
await test.step(`Buffer=2000ms (hold=${HOLD_MS}ms within buffer) → click, node stays put`, async () => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Pointer.ClickBufferTime',
|
||||
2000
|
||||
)
|
||||
const before = await node.getPosition()
|
||||
await holdDragAt(comfyPage, titlePos, {
|
||||
dx: NUDGE,
|
||||
dy: NUDGE,
|
||||
holdMs: HOLD_MS
|
||||
})
|
||||
const after = await node.getPosition()
|
||||
expect(after.x).toBeCloseTo(before.x, 0)
|
||||
expect(after.y).toBeCloseTo(before.y, 0)
|
||||
})
|
||||
|
||||
await test.step(`Buffer=50ms (hold=${HOLD_MS}ms exceeds buffer) → drag, node moves`, async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 50)
|
||||
const before = await node.getPosition()
|
||||
await holdDragAt(comfyPage, titlePos, {
|
||||
dx: NUDGE,
|
||||
dy: NUDGE,
|
||||
holdMs: HOLD_MS
|
||||
})
|
||||
const after = await node.getPosition()
|
||||
expect(
|
||||
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('ClickDrift governs the click-vs-drag distance threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Keep buffer generous so only drift distance matters.
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 2000)
|
||||
const node = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
)[0]
|
||||
const titlePos = await node.getTitlePosition()
|
||||
const NUDGE = 8
|
||||
|
||||
await test.step(`Drift=20px (nudge=${NUDGE}px within tolerance) → click, node stays put`, async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
|
||||
const before = await node.getPosition()
|
||||
await holdDragAt(comfyPage, titlePos, {
|
||||
dx: NUDGE,
|
||||
dy: NUDGE,
|
||||
holdMs: 0
|
||||
})
|
||||
const after = await node.getPosition()
|
||||
expect(after.x).toBeCloseTo(before.x, 0)
|
||||
expect(after.y).toBeCloseTo(before.y, 0)
|
||||
})
|
||||
|
||||
await test.step(`Drift=1px (nudge=${NUDGE}px exceeds tolerance) → drag, node moves`, async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 1)
|
||||
const before = await node.getPosition()
|
||||
await holdDragAt(comfyPage, titlePos, {
|
||||
dx: NUDGE,
|
||||
dy: NUDGE,
|
||||
holdMs: 0
|
||||
})
|
||||
const after = await node.getPosition()
|
||||
expect(
|
||||
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('LiteGraph.Canvas.MaximumFps', () => {
|
||||
// Behavioural FPS counting via rAF is not reliable under Playwright
|
||||
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
|
||||
// render-loop throttle value instead — that is what actually governs
|
||||
// frame cadence.
|
||||
const getFrameGap = (comfyPage: ComfyPage) =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
|
||||
|
||||
test('caps the render loop frame gap', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)
|
||||
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 30, 1)
|
||||
|
||||
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 60)
|
||||
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 60, 1)
|
||||
|
||||
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 0)
|
||||
await expect.poll(() => getFrameGap(comfyPage)).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -13,35 +12,25 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
|
||||
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
stubCloudAssets: void
|
||||
}>({
|
||||
cloudAssetRequests: async ({ page: _page }, use) => {
|
||||
await use([])
|
||||
},
|
||||
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
|
||||
stubCloudAssets: [
|
||||
async ({ cloudAssetRequests, page }, use) => {
|
||||
const pattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetsRouteHandler = (route: Route) => {
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
return route.fulfill({
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
}
|
||||
await page.route(pattern, assetsRouteHandler)
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern, assetsRouteHandler)
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
@@ -53,36 +42,23 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
})
|
||||
|
||||
test('should use first cloud asset when server default is not in assets', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
// Wait for the checkpoint asset query to complete and the existing widget
|
||||
// to upgrade into asset mode before creating a fresh node. The current
|
||||
// default node may keep a previously resolved value; what matters is that
|
||||
// new nodes resolve against the cloud asset list after the fetch.
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) => {
|
||||
const includeTags =
|
||||
new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags.split(',').includes('checkpoints')
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// The default workflow contains a CheckpointLoaderSimple node whose
|
||||
// server default (from object_info) is a local file not in cloud assets.
|
||||
// Wait for the existing node's asset widget to mount, confirming the
|
||||
// assets store has been populated from the stub before adding a new node.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate((waitingForWidgetType) => {
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
return (
|
||||
node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type ?? waitingForWidgetType
|
||||
)
|
||||
}, WAITING_FOR_WIDGET_TYPE),
|
||||
return node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('asset')
|
||||
@@ -105,22 +81,15 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(
|
||||
({ id, waitingForWidgetType, waitingForWidgetValue }) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return waitingForWidgetType
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? waitingForWidgetValue : val
|
||||
},
|
||||
{
|
||||
id: nodeId,
|
||||
waitingForWidgetType: WAITING_FOR_WIDGET_TYPE,
|
||||
waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE
|
||||
}
|
||||
),
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return 'waiting:type'
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? 'waiting:value' : val
|
||||
}, nodeId),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(CLOUD_ASSETS[0].name)
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
|
||||
|
||||
const PUBLISH_FEATURE_FLAGS = {
|
||||
comfyhub_upload_enabled: true,
|
||||
comfyhub_profile_gate_enabled: true
|
||||
} as const
|
||||
|
||||
async function saveAndOpenPublishDialog(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: PublishDialog,
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
// Bounded wait: point-in-time isVisible() can miss dialogs that open
|
||||
// slightly after saveWorkflow() resolves.
|
||||
try {
|
||||
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
} catch {
|
||||
// No overwrite dialog — workflow name was unique.
|
||||
}
|
||||
|
||||
await dialog.open()
|
||||
}
|
||||
|
||||
test.describe('Publish dialog - wizard navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf')
|
||||
})
|
||||
|
||||
test('opens on the Describe step by default', async ({ publishDialog }) => {
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
await expect(publishDialog.nameInput).toBeVisible()
|
||||
await expect(publishDialog.descriptionTextarea).toBeVisible()
|
||||
})
|
||||
|
||||
test('pre-fills workflow name from active workflow', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/)
|
||||
})
|
||||
|
||||
test('Next button navigates to Examples step', async ({ publishDialog }) => {
|
||||
await publishDialog.goNext()
|
||||
await expect(publishDialog.describeStep).toBeHidden()
|
||||
// Examples step should show thumbnail toggle and upload area
|
||||
await expect(
|
||||
publishDialog.root.getByText('Select a thumbnail')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back button returns to Describe step from Examples', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishDialog.goNext()
|
||||
await expect(publishDialog.describeStep).toBeHidden()
|
||||
|
||||
await publishDialog.goBack()
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
})
|
||||
|
||||
test('navigates through all steps to Finish', async ({ publishDialog }) => {
|
||||
await publishDialog.goNext() // → Examples
|
||||
await publishDialog.goNext() // → Finish
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.publishButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking nav step navigates directly', async ({ publishDialog }) => {
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.goToStep('Describe your workflow')
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(publishDialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Describe step', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf')
|
||||
})
|
||||
|
||||
test('allows editing the workflow name', async ({ publishDialog }) => {
|
||||
await publishDialog.nameInput.clear()
|
||||
await publishDialog.nameInput.fill('My Custom Workflow')
|
||||
await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow')
|
||||
})
|
||||
|
||||
test('allows editing the description', async ({ publishDialog }) => {
|
||||
await publishDialog.descriptionTextarea.fill(
|
||||
'A great workflow for anime art'
|
||||
)
|
||||
await expect(publishDialog.descriptionTextarea).toHaveValue(
|
||||
'A great workflow for anime art'
|
||||
)
|
||||
})
|
||||
|
||||
test('displays tag suggestions from mocked API', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.root.getByText('anime')).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('upscale')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#11548): Tag click emits update:tags but the tag does not appear in
|
||||
// the active list during E2E. Needs investigation of the parent state
|
||||
// binding.
|
||||
test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => {
|
||||
await publishDialog.root.getByText('anime').click()
|
||||
|
||||
await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Examples step', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf')
|
||||
await publishDialog.goNext() // Navigate to Examples step
|
||||
})
|
||||
|
||||
test('shows thumbnail type toggle options', async ({ publishDialog }) => {
|
||||
await expect(
|
||||
publishDialog.root.getByText('Image', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Video', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Image comparison', { exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows example image upload tile', async ({ publishDialog }) => {
|
||||
await expect(
|
||||
publishDialog.root.getByRole('button', { name: 'Upload example image' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Finish step with profile', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('shows profile card with username', async ({ publishDialog }) => {
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('@testuser')).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('Test User')).toBeVisible()
|
||||
})
|
||||
|
||||
test('publish button is enabled when no private assets', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.publishButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Finish step with private assets', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({
|
||||
hasProfile: true,
|
||||
hasPrivateAssets: true
|
||||
})
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('publish button is disabled until assets acknowledged', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.publishButton).toBeDisabled()
|
||||
|
||||
const checkbox = publishDialog.finishStep.getByRole('checkbox')
|
||||
await checkbox.check()
|
||||
|
||||
await expect(publishDialog.publishButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - no profile', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: false })
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-noprofile-wf'
|
||||
)
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('shows profile creation prompt when user has no profile', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.profilePrompt).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Create a profile to publish to ComfyHub')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking create profile CTA shows profile creation form', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishDialog.root
|
||||
.getByRole('button', { name: 'Create a profile' })
|
||||
.click()
|
||||
await expect(publishDialog.gateFlow).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - unsaved workflow', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
// Don't save workflow — open dialog on the default temporary workflow
|
||||
})
|
||||
|
||||
test('shows save prompt for temporary workflow', async ({
|
||||
comfyPage,
|
||||
publishDialog
|
||||
}) => {
|
||||
// Create a new workflow to ensure it's temporary
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await publishDialog.open()
|
||||
|
||||
await expect(publishDialog.savePrompt).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText(
|
||||
'You must save your workflow before publishing'
|
||||
)
|
||||
).toBeVisible()
|
||||
// Nav should be hidden when save is required
|
||||
await expect(publishDialog.nav).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - submission', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
})
|
||||
|
||||
test('successful publish closes dialog', async ({
|
||||
comfyPage,
|
||||
publishApi,
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.publishButton.click()
|
||||
await expect(publishDialog.root).toBeHidden({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('failed publish shows error toast', async ({
|
||||
comfyPage,
|
||||
publishApi,
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
// Override publish mock with error response
|
||||
await publishApi.mockPublishWorkflowError(500, 'Internal error')
|
||||
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-submit-fail-wf'
|
||||
)
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.publishButton.click()
|
||||
|
||||
// Error toast should appear
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
// Dialog should remain open
|
||||
await expect(publishDialog.root).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '@e2e/fixtures/utils/clipboardSpy'
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
|
||||
async function triggerConfigureError(
|
||||
comfyPage: ComfyPage,
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SignInDialog } from '@e2e/fixtures/components/SignInDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Enable the show_signin_button server feature flag so LoginButton renders
|
||||
* in WorkflowTabs (which uses `flags.showSignInButton ?? isDesktop`).
|
||||
* The flag is reset automatically on each fresh page load in beforeEach.
|
||||
*/
|
||||
async function enableLoginButtonFlag(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
show_signin_button: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Login Button', { tag: ['@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.describe('Visibility', () => {
|
||||
test('button is hidden when show_signin_button flag is off', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
show_signin_button: false
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('button is visible when show_signin_button flag is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ARIA', () => {
|
||||
test('button has correct aria-label', async ({ comfyPage }) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
await expect(button).toHaveAttribute('aria-label', /.+/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Click behaviour', () => {
|
||||
test('clicking the button opens the sign-in dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
const dialog = new SignInDialog(comfyPage.page)
|
||||
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).click()
|
||||
await expect(dialog.root).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Hover popover', () => {
|
||||
test('hovering shows an informational popover', async ({ comfyPage }) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('popover contains a Learn more link', async ({ comfyPage }) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
|
||||
const learnMoreLink = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.loginButtonPopoverLearnMore
|
||||
)
|
||||
await expect(learnMoreLink).toBeVisible()
|
||||
await expect(learnMoreLink).toHaveAttribute('href', /api-nodes/)
|
||||
})
|
||||
|
||||
test('popover hides after mouse leaves the button area', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
await button.hover()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.canvas.hover()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,14 +22,18 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: centerX, y: centerY }
|
||||
const nodeId = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
return node.id
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
return { nodeId, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
@@ -76,6 +80,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(Math.abs(result.diffX)).toBeLessThan(5)
|
||||
expect(Math.abs(result.diffY)).toBeLessThan(5)
|
||||
@@ -148,124 +153,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('moving ghost onto existing node and clicking places correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get existing KSampler node from the default workflow
|
||||
const [ksamplerRef] =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
const ksamplerPos = await ksamplerRef.getPosition()
|
||||
const ksamplerSize = await ksamplerRef.getSize()
|
||||
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
|
||||
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
|
||||
|
||||
// Start ghost placement away from the existing node
|
||||
const startX = 50
|
||||
const startY = 50
|
||||
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const ghostRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: startX, y: startY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Move ghost onto the existing node
|
||||
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click to finalize — on top of the existing node
|
||||
await comfyPage.page.mouse.click(targetX, targetY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Ghost should be placed (no longer ghost)
|
||||
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
|
||||
expect(ghostResult).not.toBeNull()
|
||||
expect(ghostResult!.ghost).toBe(false)
|
||||
|
||||
// Ghost node should have moved from its start position toward where we clicked
|
||||
const ghostPos = await ghostRef.getPosition()
|
||||
expect(
|
||||
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
|
||||
).toBe(true)
|
||||
|
||||
// Existing node should NOT be selected
|
||||
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
|
||||
expect(selectedIds).not.toContain(ksamplerRef.id)
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph blueprint added from search box enters ghost mode',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
|
||||
// Convert a node to a subgraph and publish it as a blueprint
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
|
||||
expect(subgraphNodes).toHaveLength(1)
|
||||
const subgraphNode = subgraphNodes[0]
|
||||
|
||||
const blueprintName = `ghost-test-${Date.now()}`
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
name: blueprintName
|
||||
})
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
|
||||
await comfyPage.toast.closeToasts(1)
|
||||
|
||||
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Open v2 search box and search for the published blueprint
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.input.fill(blueprintName)
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
// Click the result to add the node (v2 search box uses ghost mode)
|
||||
await searchBoxV2.results.first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// A new node should exist on the graph in ghost mode
|
||||
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
|
||||
|
||||
const ghostNodeId = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.state.ghostNodeId
|
||||
})
|
||||
expect(ghostNodeId).not.toBeNull()
|
||||
|
||||
const ghostState = await getNodeById(comfyPage, ghostNodeId!)
|
||||
expect(ghostState).not.toBeNull()
|
||||
expect(ghostState!.ghost).toBe(true)
|
||||
|
||||
// Wait for search box to close, then click to confirm placement
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
await comfyPage.nextFrame()
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
await comfyPage.page.mouse.click(
|
||||
Math.round(viewport.width / 2),
|
||||
Math.round(viewport.height / 2)
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPlace = await getNodeById(comfyPage, ghostNodeId!)
|
||||
expect(afterPlace).not.toBeNull()
|
||||
expect(afterPlace!.ghost).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
|
||||
@@ -5,19 +5,32 @@ import {
|
||||
|
||||
test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.setup()
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
})
|
||||
|
||||
test('Can open search and add node', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await searchBoxV2.open()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
@@ -27,28 +40,33 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await searchBoxV2.open()
|
||||
// Default results should be visible without typing.
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// Default results should be visible without typing
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
// Enter should add the first (selected) result
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Bookmarked filter shows only bookmarked nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSampler'
|
||||
])
|
||||
await searchBoxV2.reload(comfyPage)
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.rootCategoryButton('favorites').click()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('favorites').click()
|
||||
|
||||
await expect(searchBoxV2.results).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toContainText('KSampler')
|
||||
@@ -59,10 +77,13 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,23 +91,26 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await test.step('Open Input filter popover', async () => {
|
||||
await searchBoxV2.typeFilterButton('input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
})
|
||||
// Click "Input" filter chip in the filter bar
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
|
||||
await test.step('Select MODEL type', async () => {
|
||||
await searchBoxV2.filterSearch.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
// Filter options should appear
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
|
||||
// Type to narrow and select MODEL
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Filter chip should appear and results should be filtered
|
||||
await expect(
|
||||
searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
|
||||
).toContainText('MODEL')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -96,33 +120,32 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await searchBoxV2.open()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
const results = searchBoxV2.results
|
||||
await expect(results.first()).toBeVisible()
|
||||
|
||||
await test.step('First result is selected by default', async () => {
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
// First result selected by default
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await test.step('ArrowDown moves selection to next result', async () => {
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
// ArrowDown moves selection
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
await test.step('ArrowUp moves selection back', async () => {
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
// ArrowUp moves back
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await test.step('Enter selects and adds the node', async () => {
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
})
|
||||
// Enter selects and adds node
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,17 +2,27 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.setup()
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
})
|
||||
|
||||
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.openByDoubleClickCanvas()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await expect(searchBoxV2.dialog).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -22,40 +32,43 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await searchBoxV2.open()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount)
|
||||
})
|
||||
|
||||
for (const closeKey of ['Enter', 'Escape'] as const) {
|
||||
test(`Reopening search after ${closeKey} has no persisted state`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
test('Search clears when reopening', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await comfyPage.page.keyboard.press(closeKey)
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.open()
|
||||
await expect(searchBoxV2.input).toHaveValue('')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await expect(searchBoxV2.input).toHaveValue('')
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Category navigation updates results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
@@ -63,6 +76,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
|
||||
await searchBoxV2.categoryButton('loaders').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.allTextContents())
|
||||
.not.toEqual(samplingResults)
|
||||
@@ -73,301 +87,58 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
test('Filter chip removal restores results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
// Search first to keep the result set under the 64-item cap.
|
||||
await searchBoxV2.input.fill('Load')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredCount = await searchBoxV2.results.count()
|
||||
|
||||
await test.step('Apply Input/MODEL filter', async () => {
|
||||
await searchBoxV2.applyTypeFilter('input', 'MODEL')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.not.toBe(unfilteredCount)
|
||||
})
|
||||
|
||||
await test.step('Remove the filter chip', async () => {
|
||||
await searchBoxV2.removeFilterChip()
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(0)
|
||||
await expect(searchBoxV2.results).toHaveCount(unfilteredCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Link release', () => {
|
||||
test('Link release opens search with pre-applied type filter', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// disconnectEdge pulls a CLIP link → expect a single CLIP filter chip.
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
|
||||
// Record initial result text for comparison
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredResults = await searchBoxV2.results.allTextContents()
|
||||
|
||||
// Apply Input filter with MODEL type
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Verify filter chip appeared and results changed
|
||||
const filterChip = searchBoxV2.dialog.getByTestId('filter-chip')
|
||||
await expect(filterChip).toBeVisible()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.allTextContents())
|
||||
.not.toEqual(unfilteredResults)
|
||||
|
||||
// Remove filter by clicking the chip delete button
|
||||
await filterChip.getByTestId('chip-delete').click()
|
||||
|
||||
// Filter chip should be removed
|
||||
await expect(filterChip).toBeHidden()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Link release auto-connects added node', async ({ comfyPage }) => {
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowUp on first item keeps first selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const NODE_TYPE = 'CLIPTextEncode'
|
||||
const refsBefore = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
|
||||
const idsBefore = new Set(refsBefore.map((n) => n.id))
|
||||
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('CLIP Text Encode')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
const results = searchBoxV2.results
|
||||
await expect(results.first()).toBeVisible()
|
||||
|
||||
// A new CLIPTextEncode node should have been added.
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.nodeOps
|
||||
.getNodeRefsByType(NODE_TYPE)
|
||||
.then((refs) => refs.length)
|
||||
)
|
||||
.toBe(refsBefore.length + 1)
|
||||
// First result should be selected by default
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Verify the auto-connect: the newly-added node's CLIP input must be
|
||||
// connected (proves the release wasn't just dropped).
|
||||
const refsAfter = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
|
||||
const newNode = refsAfter.find((n) => !idsBefore.has(n.id))
|
||||
expect(newNode, 'expected a new CLIPTextEncode node').toBeDefined()
|
||||
const clipInput = await newNode!.getInput(0)
|
||||
await expect.poll(() => clipInput.getLinkCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Filter combinations', () => {
|
||||
test('Output type filter filters results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.input.fill('Load')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredCount = await searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.applyTypeFilter('output', 'IMAGE')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.not.toBe(unfilteredCount)
|
||||
})
|
||||
|
||||
test('Multiple type filters (Input + Output) narrows results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.applyTypeFilter('input', 'MODEL')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const singleFilterCount = await searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.applyTypeFilter('output', 'LATENT')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(2)
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.toBeLessThan(singleFilterCount)
|
||||
})
|
||||
|
||||
test('Root filter + search query narrows results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('Sampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredCount = await searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.rootCategoryButton('comfy').click()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.toBeLessThan(unfilteredCount)
|
||||
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Root filter + category selection', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.rootCategoryButton('comfy').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const comfyCount = await searchBoxV2.results.count()
|
||||
|
||||
// Under root filter, categories are prefixed (e.g. comfy/sampling).
|
||||
await searchBoxV2.categoryButton('comfy/sampling').click()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.toBeLessThan(comfyCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Category sidebar', () => {
|
||||
test('Category tree expand and collapse', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const samplingBtn = searchBoxV2.categoryButton('sampling')
|
||||
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
|
||||
|
||||
await test.step('Expanding sampling reveals its subcategories', async () => {
|
||||
await samplingBtn.click()
|
||||
await expect(subcategory).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Collapsing sampling hides its subcategories', async () => {
|
||||
await samplingBtn.click()
|
||||
await expect(subcategory).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const parentCount = await searchBoxV2.results.count()
|
||||
|
||||
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
|
||||
await expect(subcategory).toBeVisible()
|
||||
await subcategory.click()
|
||||
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.toBeLessThan(parentCount)
|
||||
})
|
||||
|
||||
test('Most relevant resets category filter', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const defaultCount = await searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.not.toBe(defaultCount)
|
||||
|
||||
await searchBoxV2.categoryButton('most-relevant').click()
|
||||
await expect(searchBoxV2.results).toHaveCount(defaultCount)
|
||||
})
|
||||
|
||||
test(
|
||||
'Blueprint root chip filters to published blueprints',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const blueprintName = `chip-test-${crypto.randomUUID().slice(0, 8)}`
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.nodeOps
|
||||
.getNodeRefsByTitle('New Subgraph')
|
||||
.then((refs) => refs.length)
|
||||
)
|
||||
.toBe(1)
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
|
||||
await subgraphNodes[0].click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
name: blueprintName
|
||||
})
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
|
||||
await comfyPage.toast.closeToasts(1)
|
||||
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
const blueprintsChip = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Blueprint
|
||||
)
|
||||
await expect(blueprintsChip).toBeVisible()
|
||||
await blueprintsChip.click()
|
||||
|
||||
// Blueprints persist across tests on the same worker; filter by the
|
||||
// unique name we just published rather than asserting the full list.
|
||||
await expect(
|
||||
searchBoxV2.results.filter({ hasText: blueprintName })
|
||||
).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const getCount = () => searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.input.fill('S')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const count1 = await getCount()
|
||||
|
||||
await searchBoxV2.input.fill('Sa')
|
||||
await expect.poll(getCount).toBeLessThan(count1)
|
||||
const count2 = await getCount()
|
||||
|
||||
await searchBoxV2.input.fill('Sampler')
|
||||
await expect.poll(getCount).toBeLessThan(count2)
|
||||
})
|
||||
|
||||
test('No results shown for nonsensical query', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
|
||||
|
||||
await expect(searchBoxV2.noResults).toBeVisible()
|
||||
await expect(searchBoxV2.results).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Filter chip interaction', () => {
|
||||
test('Multiple filter chips displayed', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.applyTypeFilter('input', 'MODEL')
|
||||
await searchBoxV2.applyTypeFilter('output', 'LATENT')
|
||||
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(2)
|
||||
const chipTexts = await searchBoxV2.filterChips.allTextContents()
|
||||
expect(chipTexts.some((t) => t.includes('MODEL'))).toBe(true)
|
||||
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings-driven behavior', () => {
|
||||
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.ShowIdName',
|
||||
true
|
||||
)
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('VAE Decode')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible()
|
||||
await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode')
|
||||
// ArrowUp on first item should keep first selected
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
drawStroke,
|
||||
hasCanvasContent,
|
||||
triggerSerialization
|
||||
} from '@e2e/fixtures/utils/painter'
|
||||
} from '@e2e/helpers/painter'
|
||||
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||
|
||||
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
@@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
|
||||
.not.toBe(transform1)
|
||||
|
||||
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(cursor).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -187,10 +187,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width + 20,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
@@ -411,7 +408,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
// default 512 + slider step 64 = 576
|
||||
.toBe(576)
|
||||
})
|
||||
|
||||
@@ -497,29 +493,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should start empty'
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
await painterWidget
|
||||
.getByTestId('painter-clear-button')
|
||||
.dispatchEvent('click')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should still be empty after clearing empty canvas'
|
||||
})
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Serialization', () => {
|
||||
@@ -587,6 +560,36 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
test.describe('Eraser', () => {
|
||||
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cx = Math.floor(el.width / 2)
|
||||
const cy = Math.floor(el.height / 2)
|
||||
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
|
||||
return data.every((v, i) => i % 4 !== 3 || v === 0)
|
||||
}),
|
||||
{ message: 'erased area should be transparent' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
@@ -601,318 +604,18 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Serialization — unchanged canvas', () => {
|
||||
test(
|
||||
'Unchanged canvas does not re-upload on second serialization',
|
||||
{ tag: '@slow' },
|
||||
async ({ comfyPage }) => {
|
||||
let uploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
uploadCount++
|
||||
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await triggerSerialization(comfyPage.page)
|
||||
expect(uploadCount, 'first serialization should upload once').toBe(1)
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
expect(
|
||||
uploadCount,
|
||||
'second serialization without new drawing should not re-upload'
|
||||
).toBe(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Settings persistence', () => {
|
||||
test('Tool selection is saved to node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
|
||||
| string
|
||||
| undefined
|
||||
}),
|
||||
{ message: 'painterTool property should update to eraser' }
|
||||
)
|
||||
.toBe('eraser')
|
||||
})
|
||||
|
||||
test('Brush size change is saved to node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const sizeRow = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
.getByTestId('painter-size-row')
|
||||
const sizeSlider = sizeRow.getByRole('slider')
|
||||
|
||||
await expect(
|
||||
sizeRow.getByTestId('painter-size-value'),
|
||||
'brush size should start at default 20'
|
||||
).toHaveText('20')
|
||||
|
||||
await sizeSlider.focus()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sizeSlider.press('ArrowRight')
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
return graph?._nodes_by_id?.['1']?.properties
|
||||
?.painterBrushSize as number | undefined
|
||||
}),
|
||||
{ message: 'painterBrushSize property should update to 30' }
|
||||
)
|
||||
.toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
if (node) {
|
||||
node.size = [200, 400]
|
||||
window.app!.canvas.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
const hasContentAtRow = (yFraction: number) =>
|
||||
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cy = Math.floor(el.height * y)
|
||||
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
}, yFraction)
|
||||
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.25), {
|
||||
message: 'top stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.5), {
|
||||
message: 'middle stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.75), {
|
||||
message: 'bottom stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Painter — input image connection',
|
||||
{ tag: ['@widget', '@vue-nodes', '@slow'] },
|
||||
() => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
|
||||
})
|
||||
|
||||
test('Width, height, and bg_color controls hide when input is connected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-width-row'),
|
||||
'width row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-height-row'),
|
||||
'height row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-bg-color-row'),
|
||||
'background color row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-dimension-text'),
|
||||
'dimension text should be visible when input is connected'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Canvas resizes to match input image dimensions after execution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const img = node.locator('.widget-expands img')
|
||||
await expect(
|
||||
img,
|
||||
'input image should appear after execution'
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
),
|
||||
{
|
||||
message: 'input image should be fully decoded',
|
||||
timeout: 30_000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
|
||||
nw: el.naturalWidth,
|
||||
nh: el.naturalHeight
|
||||
}))
|
||||
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
||||
message: 'canvas width should match input image natural width'
|
||||
})
|
||||
.toBe(nw)
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
|
||||
message: 'canvas height should match input image natural height'
|
||||
})
|
||||
.toBe(nh)
|
||||
})
|
||||
|
||||
test('Drawing over input image produces content on canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const img = node.locator('.widget-expands img')
|
||||
await expect(
|
||||
img,
|
||||
'input image should appear after execution'
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
),
|
||||
{ message: 'input image should be fully decoded', timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
||||
message: 'canvas should resize to match input image width',
|
||||
timeout: 15_000
|
||||
})
|
||||
.toBe(nw)
|
||||
|
||||
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
|
||||
// intercepts coordinate-based hit testing from page.mouse
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
const startX = box.x + box.width * 0.3
|
||||
const endX = box.x + box.width * 0.7
|
||||
const midY = box.y + box.height * 0.5
|
||||
const pointerOpts = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
isPrimary: true
|
||||
}
|
||||
await canvas.dispatchEvent('pointerdown', {
|
||||
...pointerOpts,
|
||||
clientX: startX,
|
||||
clientY: midY
|
||||
})
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await canvas.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + (endX - startX) * (i / 10),
|
||||
clientY: midY
|
||||
})
|
||||
}
|
||||
await canvas.dispatchEvent('pointerup', {
|
||||
...pointerOpts,
|
||||
clientX: endX,
|
||||
clientY: midY
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'drawing over input image should produce canvas content'
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
logMeasurement,
|
||||
recordMeasurement
|
||||
} from '@e2e/fixtures/utils/perfReporter'
|
||||
import { logMeasurement, recordMeasurement } from '@e2e/helpers/perfReporter'
|
||||
|
||||
test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
test('canvas idle style recalculations', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class PropertiesPanelHelper {
|
||||
@@ -9,14 +8,12 @@ export class PropertiesPanelHelper {
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly closeButton: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.closeButton = this.root.locator('button[aria-pressed]')
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
}
|
||||
|
||||
get tabs(): Locator {
|
||||
@@ -31,6 +28,10 @@ export class PropertiesPanelHelper {
|
||||
return this.panelTitle.locator('i[class*="lucide--pencil"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.root.getByTestId(TestIds.node.titleInput)
|
||||
}
|
||||
|
||||
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
|
||||
return this.root.locator('button', { hasText: state })
|
||||
}
|
||||
@@ -85,8 +86,8 @@ export class PropertiesPanelHelper {
|
||||
|
||||
async editTitle(newTitle: string): Promise<void> {
|
||||
await this.titleEditIcon.click()
|
||||
await this.titleEditor.expectVisible()
|
||||
await this.titleEditor.setTitle(newTitle)
|
||||
await this.titleInput.fill(newTitle)
|
||||
await this.titleInput.press('Enter')
|
||||
}
|
||||
|
||||
async searchWidgets(query: string): Promise<void> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '@e2e/fixtures/utils/clipboardSpy'
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
@@ -99,58 +99,5 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should render Download all and Refresh actions for one downloadable model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should clear resolved missing model when Refresh is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await comfyPage.page.route(/\/object_info$/, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Properties panel - Title editing', () => {
|
||||
|
||||
test('should enter edit mode on pencil click', async () => {
|
||||
await panel.titleEditIcon.click()
|
||||
await panel.titleEditor.expectVisible()
|
||||
await expect(panel.titleInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('should update node title on edit', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
|
||||
test('Properties panel opens with workflow overview', async ({
|
||||
@@ -34,8 +35,11 @@ test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
|
||||
|
||||
// Click on the title to enter edit mode
|
||||
await propertiesPanel.panelTitle.click()
|
||||
await propertiesPanel.titleEditor.expectVisible()
|
||||
await propertiesPanel.titleEditor.setTitle('My Custom Sampler')
|
||||
const titleInput = propertiesPanel.root.getByTestId(TestIds.node.titleInput)
|
||||
await expect(titleInput).toBeVisible()
|
||||
|
||||
await titleInput.fill('My Custom Sampler')
|
||||
await titleInput.press('Enter')
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('My Custom Sampler')
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { measureSelectionBounds } from '@e2e/fixtures/utils/boundsUtils'
|
||||
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const SUBGRAPH_ID = '2'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
@@ -65,37 +62,6 @@ const SAMPLE_IMPORTED_FILES = [
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
const JOB_GAMMA_DETAIL: JobDetail = {
|
||||
...SAMPLE_JOBS[2],
|
||||
outputs: {
|
||||
'3': {
|
||||
images: [
|
||||
{
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
},
|
||||
{
|
||||
filename: 'abstract_art_alt.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cloudTest = test.extend<{ mockCloudAssetSidebarData: void }>({
|
||||
mockCloudAssetSidebarData: async ({ comfyPage }, use) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockEmptyCloudAssets()
|
||||
|
||||
await use()
|
||||
|
||||
await comfyPage.assets.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
@@ -667,96 +633,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
|
||||
cloudTest(
|
||||
'Single job selection uses preserve naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Multiple selected assets from one job use preserve naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards
|
||||
.first()
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect.poll(() => tab.assetCards.count()).toBe(2)
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters?.['job-gamma']?.toSorted()).toEqual(
|
||||
['abstract_art.png', 'abstract_art_alt.png']
|
||||
)
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Multiple selected jobs use job-time naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.assetCards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids?.toSorted()).toEqual(['job-alpha', 'job-beta'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('group_by_job_time')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
@@ -896,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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
} from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
async function expectPromotedWidgetNamesToContain(
|
||||
comfyPage: ComfyPage,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
|
||||
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
|
||||
|
||||
@@ -1,73 +1,39 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import {
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) {
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
}
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -120,434 +86,54 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Deterministic proxyWidgets Hydrate', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
let previousUseNewMenu: unknown
|
||||
|
||||
test.describe('Legacy prefixed proxyWidget normalization', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousUseNewMenu =
|
||||
await comfyPage.settings.getSetting('Comfy.UseNewMenu')
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.UseNewMenu',
|
||||
previousUseNewMenu
|
||||
)
|
||||
})
|
||||
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const workflowName =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
try {
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', () => {
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getHostPromotedTupleSnapshot(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const SENTINEL_IDS = new Set([-1, -10, -20])
|
||||
const isSentinelNodeId = (id: number | string): id is number =>
|
||||
typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
|
||||
const checkEndpoint = (
|
||||
label: string,
|
||||
kind: 'origin_id' | 'target_id',
|
||||
id: number | string,
|
||||
g: typeof graph
|
||||
): string | null => {
|
||||
if (isSentinelNodeId(id)) return null
|
||||
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
||||
return `${label}: ${kind} ${id} invalid or not found`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
checkEndpoint(label, 'origin_id', link.origin_id, g),
|
||||
checkEndpoint(label, 'target_id', link.target_id, g)
|
||||
].filter((e): e is string => e !== null)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
let previousVueNodesEnabled: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousVueNodesEnabled = await comfyPage.settings.getSetting(
|
||||
'Comfy.VueNodes.Enabled'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
previousVueNodesEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
|
||||
comfyExpect(warnings).toEqual([])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
|
||||
test.describe(
|
||||
'Vue Node Bring to Front',
|
||||
|
||||
@@ -66,8 +66,10 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
await comfyPage.titleEditor.expectVisible()
|
||||
await comfyPage.titleEditor.setTitle('My Renamed Sampler')
|
||||
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
|
||||
await titleInput.waitFor({ state: 'visible' })
|
||||
await titleInput.fill('My Renamed Sampler')
|
||||
await titleInput.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const renamedNode =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user