Compare commits
31 Commits
litegraph/
...
bl/fix-ply
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da4f0dab5 | ||
|
|
3e31de5bbb | ||
|
|
0558740c78 | ||
|
|
8562816ffa | ||
|
|
5133ab6cf7 | ||
|
|
058acfe592 | ||
|
|
3b79917011 | ||
|
|
d955625c20 | ||
|
|
861d737041 | ||
|
|
7160a9ee3f | ||
|
|
71092b2011 | ||
|
|
d05ec230bf | ||
|
|
d6f632477f | ||
|
|
1ab63807e9 | ||
|
|
e35bea51d6 | ||
|
|
f429e1e0c4 | ||
|
|
d6b4137eec | ||
|
|
b578147714 | ||
|
|
06e09df673 | ||
|
|
e972d658d3 | ||
|
|
f090ea3d28 | ||
|
|
daab936d15 | ||
|
|
f176d18fe0 | ||
|
|
01742672bb | ||
|
|
b7fe0365af | ||
|
|
bdb92c845e | ||
|
|
d96be3d668 | ||
|
|
1869416185 | ||
|
|
a3106c4d53 | ||
|
|
b59b342a43 | ||
|
|
95e616b894 |
19
.github/actions/cloud-nodes-pull/action.yaml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Cloud Nodes Pull
|
||||
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
|
||||
inputs:
|
||||
api_key:
|
||||
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
|
||||
required: true
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
shell: bash
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
|
||||
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
9
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -106,19 +106,12 @@ jobs:
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
|
||||
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></body></html>" > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source
|
||||
--ignore-errors source,unmapped
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
11
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -58,6 +58,7 @@ jobs:
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
@@ -151,10 +152,20 @@ jobs:
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
|
||||
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
|
||||
37
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles and
|
||||
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
|
||||
# Vercel website production deploy via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshot:
|
||||
refresh-snapshots:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,28 +31,39 @@ jobs:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
uses: ./.github/actions/cloud-nodes-pull
|
||||
with:
|
||||
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
body: |
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
Automated refresh of remote-data snapshots used by the website
|
||||
build:
|
||||
|
||||
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
|
||||
board API
|
||||
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
|
||||
`/api/object_info`
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
2. This PR opens with the regenerated snapshots.
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
|
||||
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
|
||||
without the respective API keys continue to use the committed
|
||||
snapshot (with a warning annotation in CI).
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
branch: chore/refresh-website-snapshots-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-neutral-700 border-solid border-y"
|
||||
class="border-y border-solid border-neutral-700"
|
||||
:class="{
|
||||
'opacity-50': runner.resolved,
|
||||
'opacity-75': isLoading && runner.resolved
|
||||
}"
|
||||
>
|
||||
<td class="text-center w-16">
|
||||
<td class="w-16 text-center">
|
||||
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
|
||||
</td>
|
||||
<td>
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ task.name }}
|
||||
</p>
|
||||
<Button
|
||||
class="inline-block mx-2"
|
||||
class="mx-2 inline-block"
|
||||
type="button"
|
||||
:icon="PrimeIcons.INFO_CIRCLE"
|
||||
severity="secondary"
|
||||
@@ -22,11 +22,11 @@
|
||||
@click="toggle"
|
||||
/>
|
||||
|
||||
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
|
||||
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
|
||||
<span class="whitespace-pre-line">{{ task.description }}</span>
|
||||
</Popover>
|
||||
</td>
|
||||
<td class="text-right px-4">
|
||||
<td class="px-4 text-right">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
|
||||
@@ -119,6 +119,44 @@ snapshots can't be accidentally committed.
|
||||
|
||||
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
|
||||
|
||||
### Production strictness
|
||||
|
||||
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
|
||||
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
|
||||
prevents the production deploy from silently shipping an out-of-date snapshot
|
||||
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
|
||||
and local builds continue to use the committed snapshot with a warning
|
||||
annotation.
|
||||
|
||||
### Required GitHub Actions / Vercel secrets
|
||||
|
||||
| Name | Where | Purpose |
|
||||
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
|
||||
|
||||
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
|
||||
`apps/website/src/data/cloud-nodes.snapshot.json` via
|
||||
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
|
||||
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
|
||||
`deploy-production` job hard-fails before `vercel build --prod` if the secret
|
||||
is missing.
|
||||
|
||||
### Refreshing the snapshot
|
||||
|
||||
To update the committed snapshot manually (e.g. after onboarding new packs
|
||||
to Comfy Cloud):
|
||||
|
||||
```bash
|
||||
WEBSITE_CLOUD_API_KEY=… \
|
||||
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
git commit apps/website/src/data/cloud-nodes.snapshot.json
|
||||
```
|
||||
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
|
||||
can't be accidentally committed. Otherwise the `Release: Website` GitHub
|
||||
Actions workflow runs the same step on every manual dispatch and opens a PR
|
||||
with the refreshed snapshot.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
|
||||
@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const contactColumn = {
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
@@ -91,6 +91,11 @@ const contactColumn = {
|
||||
href: externalLinks.support,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.cloudStatus', locale),
|
||||
href: externalLinks.cloudStatus,
|
||||
external: true
|
||||
},
|
||||
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const externalLinks = {
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
|
||||
@@ -1773,6 +1773,7 @@ const translations = {
|
||||
'footer.support': { en: 'Support', 'zh-CN': '支持' },
|
||||
'footer.sales': { en: 'Sales', 'zh-CN': '销售' },
|
||||
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
|
||||
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.location': {
|
||||
en: 'San Francisco, USA',
|
||||
|
||||
128
apps/website/src/utils/cloudNodes.build.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
const fetchCloudNodesMock = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<FetchOutcome>>()
|
||||
)
|
||||
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes', () => ({
|
||||
fetchCloudNodesForBuild: fetchCloudNodesMock
|
||||
}))
|
||||
|
||||
vi.mock('./cloudNodes.ci', () => ({
|
||||
reportCloudNodesOutcome: reportCloudNodesOutcomeMock
|
||||
}))
|
||||
|
||||
import { loadPacksForBuild } from './cloudNodes.build'
|
||||
|
||||
const SNAPSHOT: NodesSnapshot = {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs: [
|
||||
{
|
||||
id: 'snapshot-pack',
|
||||
displayName: 'Snapshot Pack',
|
||||
nodes: [
|
||||
{ name: 'SnapshotNode', displayName: 'Snapshot Node', category: 'x' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('loadPacksForBuild', () => {
|
||||
const savedVercelEnv = process.env.VERCEL_ENV
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCloudNodesMock.mockReset()
|
||||
reportCloudNodesOutcomeMock.mockReset()
|
||||
delete process.env.VERCEL_ENV
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (savedVercelEnv === undefined) {
|
||||
delete process.env.VERCEL_ENV
|
||||
return
|
||||
}
|
||||
process.env.VERCEL_ENV = savedVercelEnv
|
||||
})
|
||||
|
||||
it('returns packs when fetch is fresh', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'fresh',
|
||||
snapshot: SNAPSHOT,
|
||||
droppedCount: 0,
|
||||
droppedNodes: []
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns snapshot packs when outcome is stale outside production', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns snapshot packs when outcome is stale on Vercel preview', async () => {
|
||||
process.env.VERCEL_ENV = 'preview'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'HTTP 503'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when outcome is stale on Vercel production', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/stale data in a production build/
|
||||
)
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/missing WEBSITE_CLOUD_API_KEY/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when outcome is failed regardless of environment', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'failed',
|
||||
reason: 'network error: ECONNREFUSED'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/Cloud nodes fetch failed and no snapshot is available/
|
||||
)
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(/ECONNREFUSED/)
|
||||
})
|
||||
|
||||
it('still reports outcome before throwing on stale-in-production', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'HTTP 503'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow()
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,14 @@ import type { Pack } from '../data/cloudNodes'
|
||||
import { fetchCloudNodesForBuild } from './cloudNodes'
|
||||
import { reportCloudNodesOutcome } from './cloudNodes.ci'
|
||||
|
||||
const REFRESH_HINT =
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
|
||||
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
|
||||
|
||||
function isProductionBuild(): boolean {
|
||||
return process.env.VERCEL_ENV === 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the list of packs to render at build time.
|
||||
*
|
||||
@@ -11,6 +19,10 @@ import { reportCloudNodesOutcome } from './cloudNodes.ci'
|
||||
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
|
||||
* `inflight` promise, so repeated calls in the same build process share a
|
||||
* single network round-trip and the same outcome.
|
||||
*
|
||||
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
|
||||
* to prevent silently shipping out-of-date snapshot data. Preview and
|
||||
* local builds continue to use the committed snapshot.
|
||||
*/
|
||||
export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
@@ -18,8 +30,14 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
|
||||
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
)
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale' && isProductionBuild()) {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
|
||||
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ function jsonResponse(
|
||||
}
|
||||
|
||||
describe('fetchRegistryPacks', () => {
|
||||
it('requests node ids in batches of 50', async () => {
|
||||
it('requests node ids in batches of 50 with matching limit param', async () => {
|
||||
const ids = Array.from({ length: 120 }, (_, i) => `pack-${i}`)
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
@@ -40,6 +40,73 @@ describe('fetchRegistryPacks', () => {
|
||||
expect(firstCallUrl.origin).toBe(DEFAULT_REGISTRY_BASE_URL)
|
||||
expect(firstCallUrl.pathname).toBe('/nodes')
|
||||
expect(firstCallUrl.searchParams.getAll('node_id')).toHaveLength(50)
|
||||
expect(firstCallUrl.searchParams.get('limit')).toBe('50')
|
||||
const lastCallUrl = new URL(String(fetchImpl.mock.calls[2]?.[0]))
|
||||
expect(lastCallUrl.searchParams.getAll('node_id')).toHaveLength(20)
|
||||
expect(lastCallUrl.searchParams.get('limit')).toBe('20')
|
||||
})
|
||||
|
||||
it('survives the server defaulting to a small page size (regression for missing limit)', async () => {
|
||||
// Mock applies the server's pre-fix behavior: default limit=10 silently
|
||||
// truncates batches with more node_id filters than the page size.
|
||||
const ids = Array.from({ length: 30 }, (_, i) => `pack-${i}`)
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
const requestedLimit = Number(url.searchParams.get('limit') ?? '10')
|
||||
const batchIds = url.searchParams
|
||||
.getAll('node_id')
|
||||
.slice(0, requestedLimit)
|
||||
return jsonResponse({
|
||||
nodes: batchIds.map((id) => ({ id, name: id })),
|
||||
total: batchIds.length,
|
||||
page: 1,
|
||||
limit: requestedLimit
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacks(ids, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const enriched = [...result.values()].filter((pack) => pack !== null)
|
||||
expect(enriched).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('accepts null values for optional registry fields and normalizes them to undefined', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'pack-with-nulls',
|
||||
name: 'Pack With Nulls',
|
||||
description: null,
|
||||
icon: null,
|
||||
banner_url: null,
|
||||
supported_os: null,
|
||||
supported_accelerators: null,
|
||||
publisher: null,
|
||||
latest_version: null,
|
||||
downloads: 42
|
||||
}
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchRegistryPacks(['pack-with-nulls'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const pack = result.get('pack-with-nulls')
|
||||
expect(pack).not.toBeNull()
|
||||
expect(pack?.downloads).toBe(42)
|
||||
expect(pack?.description).toBeUndefined()
|
||||
expect(pack?.supported_os).toBeUndefined()
|
||||
expect(pack?.supported_accelerators).toBeUndefined()
|
||||
expect(pack?.publisher).toBeUndefined()
|
||||
expect(pack?.latest_version).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retries a failed batch once and then succeeds', async () => {
|
||||
|
||||
@@ -8,34 +8,47 @@ const BATCH_SIZE = 50
|
||||
|
||||
export type RegistryPack = components['schemas']['Node']
|
||||
|
||||
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
|
||||
return value ?? undefined
|
||||
}
|
||||
|
||||
const optionalString = z.string().nullish().transform(nullToUndefined)
|
||||
const optionalNumber = z.number().nullish().transform(nullToUndefined)
|
||||
const optionalStringArray = z
|
||||
.array(z.string())
|
||||
.nullish()
|
||||
.transform(nullToUndefined)
|
||||
|
||||
const RegistryPackSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
banner_url: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
license: z.string().optional(),
|
||||
downloads: z.number().optional(),
|
||||
github_stars: z.number().optional(),
|
||||
created_at: z.string().optional(),
|
||||
supported_os: z.array(z.string()).optional(),
|
||||
supported_accelerators: z.array(z.string()).optional(),
|
||||
id: optionalString,
|
||||
name: optionalString,
|
||||
description: optionalString,
|
||||
icon: optionalString,
|
||||
banner_url: optionalString,
|
||||
repository: optionalString,
|
||||
license: optionalString,
|
||||
downloads: optionalNumber,
|
||||
github_stars: optionalNumber,
|
||||
created_at: optionalString,
|
||||
supported_os: optionalStringArray,
|
||||
supported_accelerators: optionalStringArray,
|
||||
publisher: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional()
|
||||
id: optionalString,
|
||||
name: optionalString
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
.nullish()
|
||||
.transform(nullToUndefined),
|
||||
latest_version: z
|
||||
.object({
|
||||
version: z.string().optional(),
|
||||
createdAt: z.string().optional()
|
||||
version: optionalString,
|
||||
createdAt: optionalString
|
||||
})
|
||||
.passthrough()
|
||||
.optional()
|
||||
.nullish()
|
||||
.transform(nullToUndefined)
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -141,6 +154,7 @@ async function fetchBatch(
|
||||
timeoutMs: number
|
||||
): Promise<BatchResponse> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(packIds.length))
|
||||
for (const packId of packIds) {
|
||||
params.append('node_id', packId)
|
||||
}
|
||||
|
||||
51
browser_tests/assets/widgets/curve_widget.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CurveEditor",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 400],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "histogram",
|
||||
"type": "HISTOGRAM",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "curve",
|
||||
"type": "CURVE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CurveEditor"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"points": [
|
||||
[0.2, 0.7],
|
||||
[0.8, 0.3]
|
||||
],
|
||||
"interpolation": "linear"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'cloud-stable',
|
||||
title: 'Cloud Stable',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
|
||||
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'desktop-stable',
|
||||
title: 'Desktop Stable',
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
|
||||
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'local-stable',
|
||||
title: 'Local Stable',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
|
||||
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'unrestricted-stable',
|
||||
title: 'Unrestricted Stable'
|
||||
})
|
||||
|
||||
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
|
||||
STABLE_CLOUD_TEMPLATE,
|
||||
STABLE_DESKTOP_TEMPLATE,
|
||||
STABLE_LOCAL_TEMPLATE,
|
||||
STABLE_UNRESTRICTED_TEMPLATE
|
||||
]
|
||||
|
||||
@@ -6,6 +6,71 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
function readFilePayload(filePath: string) {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
return { bufferArray, fileName, fileType }
|
||||
}
|
||||
|
||||
async function dispatchFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const target = document.activeElement ?? document
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
async function interceptNextFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
document.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
type PasteFileOptions = {
|
||||
mode?: 'keyboard' | 'direct'
|
||||
}
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
@@ -20,43 +85,20 @@ export class ClipboardHelper {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
async pasteFile(
|
||||
filePath: string,
|
||||
{ mode = 'keyboard' }: PasteFileOptions = {}
|
||||
): Promise<void> {
|
||||
const payload = readFilePayload(filePath)
|
||||
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
if (mode === 'keyboard') {
|
||||
await interceptNextFilePaste(this.page, payload)
|
||||
await this.paste()
|
||||
return
|
||||
}
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
|
||||
// Dispatch the app-level paste event with file clipboardData directly.
|
||||
await dispatchFilePaste(this.page, payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
const defaultJobsListLimit = 100
|
||||
|
||||
export type MockJobRecord = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
|
||||
const value = Number(url.searchParams.get(name))
|
||||
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class JobsApiMock {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private jobsById = new Map<string, MockJobRecord>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
|
||||
this.jobsById = new Map(
|
||||
jobs.map(
|
||||
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
|
||||
)
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobsById.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.jobsById.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
|
||||
const limit =
|
||||
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
|
||||
const total = filteredJobs.length
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.jobsById.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.jobsById = new Map(
|
||||
Array.from(this.jobsById).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.jobsById.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
198
browser_tests/fixtures/helpers/TemplateHelper.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
/**
|
||||
* Generate N deterministic templates, optionally restricted to a distribution.
|
||||
*
|
||||
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
|
||||
* for static test data with no executable fixture logic.
|
||||
*/
|
||||
function generateTemplates(
|
||||
count: number,
|
||||
distribution?: TemplateIncludeOnDistributionEnum
|
||||
): TemplateInfo[] {
|
||||
const slug = distribution ?? 'unrestricted'
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeTemplate({
|
||||
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `Generated ${slug} ${i + 1}`,
|
||||
...(distribution ? { includeOnDistributions: [distribution] } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
|
||||
function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
}
|
||||
|
||||
function cloneIndex(
|
||||
index: readonly WorkflowTemplates[] | null
|
||||
): WorkflowTemplates[] | null {
|
||||
return index ? index.map((m) => structuredClone(m)) : null
|
||||
}
|
||||
|
||||
function addTemplates(
|
||||
config: TemplateConfig,
|
||||
templates: TemplateInfo[]
|
||||
): TemplateConfig {
|
||||
return { ...config, templates: [...config.templates, ...templates] }
|
||||
}
|
||||
|
||||
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
return (config) => addTemplates(config, templates)
|
||||
}
|
||||
|
||||
export function withTemplate(template: TemplateInfo): TemplateOperator {
|
||||
return (config) => addTemplates(config, [template])
|
||||
}
|
||||
|
||||
export function withCloudTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
|
||||
)
|
||||
}
|
||||
|
||||
export function withDesktopTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
|
||||
)
|
||||
}
|
||||
|
||||
export function withLocalTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
|
||||
)
|
||||
}
|
||||
|
||||
export function withUnrestrictedTemplates(count: number): TemplateOperator {
|
||||
return (config) => addTemplates(config, generateTemplates(count))
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the index payload entirely. Useful when a test needs a custom
|
||||
* `WorkflowTemplates[]` shape (e.g. multiple modules).
|
||||
*/
|
||||
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
|
||||
return (config) => ({ ...config, index })
|
||||
}
|
||||
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: TemplateConfig = emptyConfig()
|
||||
) {
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
configure(...operators: TemplateOperator[]): void {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
const thumbnailHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
return cloneTemplates(this.templates)
|
||||
}
|
||||
|
||||
get templateCount(): number {
|
||||
return this.templates.length
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.templates = []
|
||||
this.index = null
|
||||
}
|
||||
}
|
||||
|
||||
export function createTemplateHelper(
|
||||
page: Page,
|
||||
...operators: TemplateOperator[]
|
||||
): TemplateHelper {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new TemplateHelper(page, config)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export const jobsApiMockFixture = base.extend<{
|
||||
jobsApi: JobsApiMock
|
||||
}>({
|
||||
jobsApi: async ({ page }, use) => {
|
||||
const jobsApi = new JobsApiMock(page)
|
||||
|
||||
await use(jobsApi)
|
||||
|
||||
await jobsApi.clear()
|
||||
}
|
||||
})
|
||||
173
browser_tests/fixtures/jobsRouteFixture.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type {
|
||||
JobStatus,
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
|
||||
const terminalJobStatuses = [
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
] as const satisfies readonly JobStatus[]
|
||||
const activeJobStatuses = [
|
||||
'in_progress',
|
||||
'pending'
|
||||
] as const satisfies readonly JobStatus[]
|
||||
const defaultJobsListLimit = 200
|
||||
const defaultScenarioHistoryLimit = 64
|
||||
const defaultJobsListOffset = 0
|
||||
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
interface JobsListRoute {
|
||||
statuses: readonly JobStatus[]
|
||||
jobs: readonly RawJobListItem[]
|
||||
limit?: number
|
||||
offset?: number
|
||||
responseLimit?: number
|
||||
}
|
||||
|
||||
interface JobsScenario {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
}
|
||||
|
||||
function hasExactStatuses(url: URL, statuses: readonly JobStatus[]): boolean {
|
||||
const requestedStatuses = new Set(
|
||||
url.searchParams.get('status')?.split(',') ?? []
|
||||
)
|
||||
|
||||
return (
|
||||
requestedStatuses.size === statuses.length &&
|
||||
statuses.every((status) => requestedStatuses.has(status))
|
||||
)
|
||||
}
|
||||
|
||||
function searchParamNumber(url: URL, name: string, fallback: number): number {
|
||||
const value = url.searchParams.get(name)
|
||||
return value === null ? fallback : Number(value)
|
||||
}
|
||||
|
||||
function hasJobsListPageParams(
|
||||
url: URL,
|
||||
{ limit, offset }: Pick<JobsListRoute, 'limit' | 'offset'>
|
||||
): boolean {
|
||||
return (
|
||||
searchParamNumber(url, 'limit', defaultJobsListLimit) ===
|
||||
(limit ?? defaultJobsListLimit) &&
|
||||
searchParamNumber(url, 'offset', defaultJobsListOffset) ===
|
||||
(offset ?? defaultJobsListOffset)
|
||||
)
|
||||
}
|
||||
|
||||
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
|
||||
return (
|
||||
url.pathname.endsWith('/api/jobs') &&
|
||||
hasExactStatuses(url, route.statuses) &&
|
||||
hasJobsListPageParams(url, route)
|
||||
)
|
||||
}
|
||||
|
||||
function createJobsListResponse({
|
||||
jobs,
|
||||
limit = defaultJobsListLimit,
|
||||
offset = defaultJobsListOffset,
|
||||
responseLimit = limit
|
||||
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
|
||||
const pageJobs = jobs.slice(offset, offset + responseLimit)
|
||||
|
||||
return {
|
||||
jobs: pageJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit: responseLimit,
|
||||
total: jobs.length,
|
||||
has_more: offset + pageJobs.length < jobs.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouteMockJob({
|
||||
id,
|
||||
...overrides
|
||||
}: { id: string } & Partial<Omit<RawJobListItem, 'id'>>): RawJobListItem {
|
||||
return {
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: defaultRouteMockJobTimestamp,
|
||||
execution_start_time: defaultRouteMockJobTimestamp,
|
||||
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export class JobsRouteMocker {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobsHistory(
|
||||
jobs: readonly RawJobListItem[],
|
||||
limit = defaultJobsListLimit,
|
||||
options: Pick<JobsListRoute, 'responseLimit'> = {}
|
||||
): Promise<void> {
|
||||
await this.mockJobsList({
|
||||
statuses: terminalJobStatuses,
|
||||
jobs,
|
||||
limit,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
async mockJobsQueue(jobs: readonly RawJobListItem[]): Promise<void> {
|
||||
await this.mockJobsList({
|
||||
statuses: activeJobStatuses,
|
||||
jobs
|
||||
})
|
||||
}
|
||||
|
||||
async mockJobsScenario({ history, queue }: JobsScenario): Promise<void> {
|
||||
if (history) {
|
||||
await this.mockJobsHistory(history, defaultScenarioHistoryLimit)
|
||||
}
|
||||
if (queue) {
|
||||
await this.mockJobsQueue(queue)
|
||||
}
|
||||
}
|
||||
|
||||
async mockJobsList(route: JobsListRoute): Promise<void> {
|
||||
const response = createJobsListResponse(route)
|
||||
|
||||
await this.page.route(
|
||||
(url) => isJobsListRequest(url, route),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'GET') {
|
||||
await requestRoute.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
await requestRoute.fulfill({ json: response })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const jobsRouteFixture = base.extend<{
|
||||
jobsRoutes: JobsRouteMocker
|
||||
}>({
|
||||
jobsRoutes: async ({ page }, use) => {
|
||||
await use(new JobsRouteMocker(page))
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
}
|
||||
})
|
||||
@@ -145,7 +145,9 @@ export const TestIds = {
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
subgraphEnterButton: 'subgraph-enter-button',
|
||||
selectDefaultSearchInput: 'widget-select-default-search-input',
|
||||
selectDefaultViewport: 'widget-select-default-viewport'
|
||||
},
|
||||
linear: {
|
||||
centerPanel: 'linear-center-panel',
|
||||
|
||||
16
browser_tests/fixtures/templateApiFixture.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
|
||||
export const templateApiFixture = base.extend<{
|
||||
templateApi: TemplateHelper
|
||||
}>({
|
||||
templateApi: async ({ page }, use) => {
|
||||
const templateApi = createTemplateHelper(page)
|
||||
|
||||
await use(templateApi)
|
||||
|
||||
await templateApi.clearMocks()
|
||||
}
|
||||
})
|
||||
@@ -122,3 +122,19 @@ export async function saveAndReopenInAppMode(
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
export async function saveCloseAndReopenInBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await expect(appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: JobEntry['status']) {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
|
||||
const updateTime =
|
||||
listItem.execution_end_time ??
|
||||
listItem.execution_start_time ??
|
||||
listItem.create_time
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
update_time: updateTime,
|
||||
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
listItem,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobRecords(
|
||||
listItems: readonly JobEntry[]
|
||||
): MockJobRecord[] {
|
||||
return listItems.map(createMockJobRecord)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
|
||||
@@ -97,8 +98,9 @@ test.describe('App mode usage', () => {
|
||||
})
|
||||
await sampler.click()
|
||||
|
||||
await comfyPage.page.getByRole('searchbox').fill('uni')
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.widgets.selectDefaultSearchInput)
|
||||
.fill('uni')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(sampler).toHaveText('uni_pc')
|
||||
|
||||
|
||||
@@ -87,7 +87,9 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
|
||||
await codecSelect.click()
|
||||
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||
const overlay = comfyPage.page
|
||||
.getByTestId('widget-select-default-overlay')
|
||||
.first()
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await expect
|
||||
|
||||
44
browser_tests/tests/appModeInputPersistence.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
saveCloseAndReopenInBuilder,
|
||||
setupBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
test.describe(
|
||||
'App builder input persistence after reload',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('persists selected inputs after save and reopen without visibility errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
const workflowName = `${Date.now()} input-persistence`
|
||||
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
for (const widget of WIDGETS) {
|
||||
await expect(
|
||||
appMode.select.getInputItemSubtitle(widget)
|
||||
).not.toContainText('Widget not visible')
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -5,26 +5,18 @@ import {
|
||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
saveCloseAndReopenInBuilder,
|
||||
setupBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
|
||||
async function saveCloseAndReopenAsApp(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await expect(appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
|
||||
await appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,9 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await test.step('Capture HUD region with setting on', async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
// FPS value varies per run; allow ~1% pixel variance in the 180×160 clip
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-on.png',
|
||||
{ clip: hudClip, maxDiffPixels: 350 }
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.3 KiB |
78
browser_tests/tests/curveWidget.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Curve Widget', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/curve_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'Loads control points and interpolation from workflow',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const svg = node.getByTestId('curve-editor')
|
||||
await expect(svg).toBeVisible()
|
||||
|
||||
const points = svg.getByTestId('curve-point')
|
||||
await expect(points).toHaveCount(2)
|
||||
|
||||
const [cxs, cys] = await Promise.all([
|
||||
points.evaluateAll((els) =>
|
||||
els.map((e) => Number(e.getAttribute('cx')))
|
||||
),
|
||||
points.evaluateAll((els) =>
|
||||
els.map((e) => Number(e.getAttribute('cy')))
|
||||
)
|
||||
])
|
||||
expect(cxs[0]).toBeCloseTo(0.2, 5)
|
||||
expect(cxs[1]).toBeCloseTo(0.8, 5)
|
||||
expect(cys[0]).toBeCloseTo(0.3, 5)
|
||||
expect(cys[1]).toBeCloseTo(0.7, 5)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Interpolation selector reflects loaded value (Linear)',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('Linear', { exact: true })).toBeVisible()
|
||||
await expect(node.getByText('Smooth', { exact: true })).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Click on SVG canvas adds a control point',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const svg = node.getByTestId('curve-editor')
|
||||
await expect(svg).toBeVisible()
|
||||
await expect(svg.getByTestId('curve-point')).toHaveCount(2)
|
||||
|
||||
const position = await svg.evaluate((el) => {
|
||||
const svgEl = el as SVGSVGElement
|
||||
const ctm = svgEl.getScreenCTM()
|
||||
if (!ctm) throw new Error('SVG has no screen CTM')
|
||||
const pt = svgEl.createSVGPoint()
|
||||
pt.x = 0.5
|
||||
pt.y = 1 - 0.5 // curve-Y is inverted vs SVG-Y
|
||||
const screen = pt.matrixTransform(ctm)
|
||||
const rect = svgEl.getBoundingClientRect()
|
||||
return { x: screen.x - rect.left, y: screen.y - rect.top }
|
||||
})
|
||||
|
||||
await svg.click({ position })
|
||||
|
||||
await expect(svg.getByTestId('curve-point')).toHaveCount(3)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -247,6 +247,14 @@ test.describe('Cloud notification dialog', () => {
|
||||
await dialog.back.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should not advertise free monthly credits', async ({ comfyPage }) => {
|
||||
const dialog = new CloudNotification(comfyPage.page)
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.root.getByText(/Free Credits/i)).toHaveCount(0)
|
||||
await expect(dialog.root.getByText(/400/)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
@@ -213,9 +213,9 @@ async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function outputHistoryJobs() {
|
||||
return createMockJobRecords([
|
||||
createMockJob({
|
||||
function outputHistoryJobs(): RawJobListItem[] {
|
||||
return [
|
||||
createRouteMockJob({
|
||||
id: 'history-output-image',
|
||||
preview_output: {
|
||||
filename: 'ComfyUI_00001_.png',
|
||||
@@ -225,7 +225,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'history-output-video',
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
@@ -235,7 +235,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'video'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'history-output-audio',
|
||||
preview_output: {
|
||||
filename: 'sound.wav',
|
||||
@@ -245,7 +245,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'audio'
|
||||
}
|
||||
})
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
ossTest.describe(
|
||||
@@ -258,8 +258,9 @@ ossTest.describe(
|
||||
|
||||
ossTest(
|
||||
'resolves annotated output media from job history',
|
||||
async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(outputHistoryJobs())
|
||||
async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsHistory(outputHistoryJobs())
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_output_annotations'
|
||||
|
||||
@@ -1,56 +1,54 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const mockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: now - 60_000,
|
||||
execution_start_time: now - 60_000,
|
||||
execution_end_time: now - 50_000,
|
||||
create_time: mockJobTimestamp - 60_000,
|
||||
execution_start_time: mockJobTimestamp - 60_000,
|
||||
execution_end_time: mockJobTimestamp - 50_000,
|
||||
outputs_count: 2
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-completed-2',
|
||||
status: 'completed',
|
||||
create_time: now - 120_000,
|
||||
execution_start_time: now - 120_000,
|
||||
execution_end_time: now - 115_000,
|
||||
create_time: mockJobTimestamp - 120_000,
|
||||
execution_start_time: mockJobTimestamp - 120_000,
|
||||
execution_end_time: mockJobTimestamp - 115_000,
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: now - 30_000,
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
create_time: mockJobTimestamp - 30_000,
|
||||
execution_start_time: mockJobTimestamp - 30_000,
|
||||
execution_end_time: mockJobTimestamp - 28_000,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-failed-bottom',
|
||||
status: 'failed',
|
||||
create_time: now - 180_000,
|
||||
execution_start_time: now - 180_000,
|
||||
execution_end_time: now - 178_000,
|
||||
create_time: mockJobTimestamp - 180_000,
|
||||
execution_start_time: mockJobTimestamp - 180_000,
|
||||
execution_end_time: mockJobTimestamp - 178_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
test.beforeEach(async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsScenario({ history: MOCK_JOBS, queue: [] })
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import type { Locator, Page, Request } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture, jobsRouteFixture)
|
||||
|
||||
const TOTAL_MOCK_JOBS = 20
|
||||
const MAX_HISTORY_ITEMS_SETTING = 'Comfy.Queue.MaxHistoryItems'
|
||||
const overflowJobsListRoutePattern = '**/api/jobs?*'
|
||||
|
||||
function createMockJobs(count: number): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createRouteMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function isHistoryJobsRequest(url: string): boolean {
|
||||
if (!url.includes('/api/jobs')) return false
|
||||
@@ -44,21 +58,16 @@ function getJobListResults(page: Page): Locator {
|
||||
test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Queue.MaxHistoryItems', () => {
|
||||
test.describe('limit query parameter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(
|
||||
createMockJobs(TOTAL_MOCK_JOBS)
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('limit query parameter on /api/jobs reflects the setting', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
getWebSocket,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
const TARGET_LIMIT = 6
|
||||
await jobsRoutes.mockJobsHistory(
|
||||
createMockJobs(TOTAL_MOCK_JOBS),
|
||||
TARGET_LIMIT
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
MAX_HISTORY_ITEMS_SETTING,
|
||||
TARGET_LIMIT
|
||||
@@ -73,39 +82,14 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
|
||||
test('queue panel caps history items to the configured number', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
getWebSocket,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
// Add a mock route that returns all jobs regardless of the request's `limit` param
|
||||
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
|
||||
await comfyPage.page.route(
|
||||
overflowJobsListRoutePattern,
|
||||
async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (!url.searchParams.get('status')?.includes('completed')) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
const response = {
|
||||
jobs: overflowJobs,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: overflowJobs.length,
|
||||
total: overflowJobs.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const VISIBLE_LIMIT = 6
|
||||
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
|
||||
await jobsRoutes.mockJobsHistory(overflowJobs, VISIBLE_LIMIT, {
|
||||
responseLimit: overflowJobs.length
|
||||
})
|
||||
await comfyPage.settings.setSetting(
|
||||
MAX_HISTORY_ITEMS_SETTING,
|
||||
VISIBLE_LIMIT
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -54,14 +54,44 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('info button opens properties panel', async ({ comfyPage }) => {
|
||||
test('info button opens the right-side info tab in new menu mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(panel).toContainText('KSampler')
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('info button is hidden when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
|
||||
@@ -74,14 +74,16 @@ test.describe(
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
|
||||
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||
test('hides Node Info from More Options menu when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||
exact: true
|
||||
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'Node Info'
|
||||
})
|
||||
await expect(nodeInfoButton).toBeVisible()
|
||||
await nodeInfoButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(nodeInfoButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, templateApiFixture)
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
@@ -17,7 +17,7 @@ test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
test.beforeEach(async ({ comfyPage, templateApi }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -26,53 +26,37 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
await templateApi.mockThumbnails()
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -86,45 +70,38 @@ test.describe(
|
||||
})
|
||||
|
||||
test('filtered count reflects distribution + model filter together', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -144,36 +121,29 @@ test.describe(
|
||||
})
|
||||
|
||||
test('desktop-only templates never leak into DOM on cloud distribution', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -200,28 +170,21 @@ test.describe(
|
||||
})
|
||||
|
||||
test('templates without includeOnDistributions are visible on cloud', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -234,39 +197,32 @@ test.describe(
|
||||
})
|
||||
|
||||
test('clear filters button resets to correct distribution-filtered total', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
@@ -75,6 +75,24 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should open node info in the right side panel via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Node Info')
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
@@ -4,6 +4,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
@@ -22,6 +25,61 @@ const ERROR_CLASS = /ring-destructive-background/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
const LOAD_IMAGE_INPUT_NAME = 'image'
|
||||
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
|
||||
|
||||
function buildLoadImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'LoadImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
|
||||
details: '',
|
||||
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function surfaceLoadImageMissingInputError(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[loadImageId]: buildLoadImageRequiredInputError()
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
}
|
||||
|
||||
async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const loadImageId = String(loadImageNode.id)
|
||||
|
||||
return {
|
||||
loadImageId,
|
||||
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
|
||||
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -191,6 +249,74 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user drops an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('drop an image onto the Load Image node', async () => {
|
||||
const dropPosition =
|
||||
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
|
||||
if (!dropPosition) {
|
||||
throw new Error('Load Image node center must be available for drop')
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
|
||||
dropPosition,
|
||||
waitForUpload: true
|
||||
})
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user pastes an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('paste an image while Load Image is selected', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
|
||||
)
|
||||
.toBe('LoadImage')
|
||||
|
||||
const uploadResponse = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
// File clipboard contents cannot be seeded reliably in Playwright;
|
||||
// use the direct document paste mode to exercise usePaste.
|
||||
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
|
||||
mode: 'direct'
|
||||
})
|
||||
await uploadResponse
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -2,9 +2,12 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
test.describe('Vue Combo Widget', { tag: '@vue-nodes' }, () => {
|
||||
test('opens a dropdown that lists sampler options', async ({ comfyPage }) => {
|
||||
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
async function openSamplerDropdown(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('vueNodes/linked-int-widget')
|
||||
|
||||
const samplerCombo = comfyPage.vueNodes
|
||||
@@ -13,6 +16,120 @@ test.describe('Vue Combo Widget', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await samplerCombo.click()
|
||||
|
||||
const viewport = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.selectDefaultViewport
|
||||
)
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
return viewport
|
||||
}
|
||||
|
||||
async function pressDropdownScrollbar(
|
||||
comfyPage: ComfyPage,
|
||||
viewport: Locator
|
||||
) {
|
||||
const { x, y } = await getScrollbarPressPoint(viewport)
|
||||
|
||||
await comfyPage.page.mouse.move(x, y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await expect(viewport).toBeVisible()
|
||||
await comfyPage.page.mouse.up()
|
||||
}
|
||||
|
||||
async function getCanvasViewport(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.evaluate(() => ({
|
||||
scale: window.app!.canvas.ds.scale,
|
||||
offset: [...window.app!.canvas.ds.offset]
|
||||
}))
|
||||
}
|
||||
|
||||
async function getViewportBox(viewport: Locator) {
|
||||
await expect.poll(() => viewport.boundingBox()).not.toBeNull()
|
||||
|
||||
const box = await viewport.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error('Widget select viewport is not visible')
|
||||
}
|
||||
|
||||
return box
|
||||
}
|
||||
|
||||
async function getScrollbarPressPoint(viewport: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
viewport.evaluate(
|
||||
(element) => element.scrollHeight > element.clientHeight
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
return viewport.evaluate((element) => {
|
||||
const viewportElement = element as HTMLElement
|
||||
const rect = viewportElement.getBoundingClientRect()
|
||||
const scrollbarWidth =
|
||||
viewportElement.offsetWidth - viewportElement.clientWidth
|
||||
const scrollbarInset = scrollbarWidth > 0 ? scrollbarWidth / 2 : 2
|
||||
|
||||
return {
|
||||
x: rect.right - scrollbarInset,
|
||||
y: rect.top + Math.min(rect.height / 2, 20)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function getMixedGraphSamplerCombos(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
|
||||
await comfyPage.vueNodes.waitForNodes(3)
|
||||
|
||||
const nodes = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodes).toHaveCount(3)
|
||||
|
||||
return {
|
||||
firstSamplerCombo: nodes
|
||||
.nth(0)
|
||||
.getByRole('combobox', { name: 'sampler_name', exact: true }),
|
||||
secondSamplerCombo: nodes
|
||||
.nth(2)
|
||||
.getByRole('combobox', { name: 'sampler_name', exact: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function getActiveWidgetSelectViewport(comfyPage: ComfyPage) {
|
||||
const viewport = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.selectDefaultViewport
|
||||
)
|
||||
await expect(viewport).toBeVisible()
|
||||
return viewport
|
||||
}
|
||||
|
||||
async function expectWheelScrollsDropdownWithoutMovingCanvas(
|
||||
comfyPage: ComfyPage,
|
||||
viewport: Locator
|
||||
) {
|
||||
const canvasViewportBefore = await getCanvasViewport(comfyPage)
|
||||
await viewport.evaluate((el) => {
|
||||
el.scrollTop = 0
|
||||
})
|
||||
const scrollBefore = 0
|
||||
const box = await getViewportBox(viewport)
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width / 2,
|
||||
box.y + Math.min(box.height / 2, 40)
|
||||
)
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(scrollBefore)
|
||||
|
||||
const canvasViewportAfter = await getCanvasViewport(comfyPage)
|
||||
expect(canvasViewportAfter).toEqual(canvasViewportBefore)
|
||||
}
|
||||
|
||||
test('opens a dropdown that lists sampler options', async ({ comfyPage }) => {
|
||||
await openSamplerDropdown(comfyPage)
|
||||
|
||||
// The option list should include at least a few known samplers
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: 'euler', exact: true })
|
||||
@@ -40,6 +157,99 @@ test.describe('Vue Combo Widget', { tag: '@vue-nodes' }, () => {
|
||||
await expect(samplerCombo).toContainText('dpmpp_2m')
|
||||
})
|
||||
|
||||
test('mouse wheel scrolls the dropdown list instead of zooming the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const viewport = await openSamplerDropdown(comfyPage)
|
||||
|
||||
await expectWheelScrollsDropdownWithoutMovingCanvas(comfyPage, viewport)
|
||||
})
|
||||
|
||||
test('keeps the dropdown open when the scrollbar is pressed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const viewport = await openSamplerDropdown(comfyPage)
|
||||
|
||||
await pressDropdownScrollbar(comfyPage, viewport)
|
||||
await expect(viewport).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes the dropdown when clicking outside', async ({ comfyPage }) => {
|
||||
const viewport = await openSamplerDropdown(comfyPage)
|
||||
|
||||
await comfyPage.page.mouse.click(10, 10)
|
||||
|
||||
await expect(viewport).toBeHidden()
|
||||
})
|
||||
|
||||
test('keeps wheel scrolling captured after the scrollbar is pressed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const viewport = await openSamplerDropdown(comfyPage)
|
||||
|
||||
await pressDropdownScrollbar(comfyPage, viewport)
|
||||
|
||||
await expectWheelScrollsDropdownWithoutMovingCanvas(comfyPage, viewport)
|
||||
})
|
||||
|
||||
test('closes the previous dropdown when another node widget opens', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { firstSamplerCombo, secondSamplerCombo } =
|
||||
await getMixedGraphSamplerCombos(comfyPage)
|
||||
|
||||
await firstSamplerCombo.click()
|
||||
const viewport = await getActiveWidgetSelectViewport(comfyPage)
|
||||
|
||||
await pressDropdownScrollbar(comfyPage, viewport)
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
await secondSamplerCombo.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.selectDefaultViewport)
|
||||
).toHaveCount(1)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.selectDefaultViewport)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('preserves dropdown scroll capture when switching between node widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { firstSamplerCombo, secondSamplerCombo } =
|
||||
await getMixedGraphSamplerCombos(comfyPage)
|
||||
|
||||
await firstSamplerCombo.click()
|
||||
const viewport = await getActiveWidgetSelectViewport(comfyPage)
|
||||
|
||||
await expectWheelScrollsDropdownWithoutMovingCanvas(comfyPage, viewport)
|
||||
|
||||
await pressDropdownScrollbar(comfyPage, viewport)
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
await expectWheelScrollsDropdownWithoutMovingCanvas(comfyPage, viewport)
|
||||
|
||||
await secondSamplerCombo.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.selectDefaultViewport)
|
||||
).toHaveCount(1)
|
||||
|
||||
const secondViewport = await getActiveWidgetSelectViewport(comfyPage)
|
||||
|
||||
await expectWheelScrollsDropdownWithoutMovingCanvas(
|
||||
comfyPage,
|
||||
secondViewport
|
||||
)
|
||||
|
||||
await pressDropdownScrollbar(comfyPage, secondViewport)
|
||||
await expect(secondViewport).toBeVisible()
|
||||
|
||||
await expectWheelScrollsDropdownWithoutMovingCanvas(
|
||||
comfyPage,
|
||||
secondViewport
|
||||
)
|
||||
})
|
||||
|
||||
test('persists the selected combo value across a serialize and reload round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.6",
|
||||
"version": "1.45.9",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./mediaExtensions": "./src/mediaExtensions.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isCivitaiUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
@@ -111,6 +110,11 @@ describe('formatUtil', () => {
|
||||
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('mesh.stl')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('pointcloud.spz')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('scene.splat')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('scene.ksplat')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
|
||||
})
|
||||
})
|
||||
@@ -410,20 +414,6 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCivitaiUrl', () => {
|
||||
it.for([
|
||||
{ url: 'https://civitai.com/models/123', expected: true },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import type { operations } from '@comfyorg/registry-types'
|
||||
import { isThreeDMediaExtension } from '@comfyorg/shared-frontend-utils/mediaExtensions'
|
||||
|
||||
export function formatCamelCase(str: string): string {
|
||||
// Check if the string is camel case
|
||||
@@ -591,7 +592,6 @@ const IMAGE_EXTENSIONS = [
|
||||
] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
@@ -611,7 +611,6 @@ export type MediaType = (typeof MEDIA_TYPES)[number]
|
||||
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
||||
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
||||
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
|
||||
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
|
||||
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
|
||||
|
||||
/**
|
||||
@@ -655,26 +654,22 @@ export function getMediaTypeFromFilename(
|
||||
filename: string | null | undefined
|
||||
): MediaType {
|
||||
if (!filename) return 'other'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
const ext = getFileExtension(filename)
|
||||
if (!ext) return 'other'
|
||||
|
||||
// Type-safe array includes check using type assertion
|
||||
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
|
||||
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
|
||||
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
|
||||
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
|
||||
if (isThreeDMediaExtension(ext)) return '3D'
|
||||
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
|
||||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
function getFileExtension(filename: string | null | undefined): string | null {
|
||||
if (!filename) return null
|
||||
return filename.split('.').pop()?.toLowerCase() ?? null
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
|
||||
44
packages/shared-frontend-utils/src/mediaExtensions.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const THREE_D_LOADABLE_EXTENSIONS = [
|
||||
'gltf',
|
||||
'glb',
|
||||
'obj',
|
||||
'fbx',
|
||||
'stl',
|
||||
'spz',
|
||||
'splat',
|
||||
'ply',
|
||||
'ksplat'
|
||||
] as const
|
||||
|
||||
const THREE_D_NON_LOADABLE_MEDIA_EXTENSIONS = ['usdz'] as const
|
||||
|
||||
// Classification is broader than viewer support: some valid 3D assets, such
|
||||
// as USDZ, can be listed/downloaded but cannot be loaded by the in-app viewer.
|
||||
export const THREE_D_MEDIA_EXTENSIONS = [
|
||||
...THREE_D_LOADABLE_EXTENSIONS,
|
||||
...THREE_D_NON_LOADABLE_MEDIA_EXTENSIONS
|
||||
] as const
|
||||
|
||||
export type ThreeDLoadableExtension =
|
||||
(typeof THREE_D_LOADABLE_EXTENSIONS)[number]
|
||||
export type ThreeDMediaExtension = (typeof THREE_D_MEDIA_EXTENSIONS)[number]
|
||||
|
||||
export function isThreeDLoadableExtension(
|
||||
extension: string | null | undefined
|
||||
): boolean {
|
||||
const normalized = extension?.toLowerCase()
|
||||
return (
|
||||
typeof normalized === 'string' &&
|
||||
THREE_D_LOADABLE_EXTENSIONS.includes(normalized as ThreeDLoadableExtension)
|
||||
)
|
||||
}
|
||||
|
||||
export function isThreeDMediaExtension(
|
||||
extension: string | null | undefined
|
||||
): boolean {
|
||||
const normalized = extension?.toLowerCase()
|
||||
return (
|
||||
typeof normalized === 'string' &&
|
||||
THREE_D_MEDIA_EXTENSIONS.includes(normalized as ThreeDMediaExtension)
|
||||
)
|
||||
}
|
||||
723
pnpm-lock.yaml
generated
@@ -36,7 +36,7 @@ catalog:
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tailwindcss/vite': ^4.3.0
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
@@ -112,7 +112,7 @@ catalog:
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.2.0
|
||||
tailwindcss: ^4.3.0
|
||||
three: ^0.170.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
|
||||
91
src/base/assert.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assert, setAssertReporter } from '@/base/assert'
|
||||
|
||||
describe('assert', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
setAssertReporter(null)
|
||||
})
|
||||
|
||||
it('does nothing when condition is true', () => {
|
||||
expect(() => assert(true, 'should not throw')).not.toThrow()
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs console.error when condition is false', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
assert(false, 'test message')
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: test message'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws in DEV mode when condition is false', () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
const reporter = vi.fn()
|
||||
setAssertReporter(reporter)
|
||||
expect(() => assert(false, 'dev error')).toThrow(
|
||||
'[Assertion failed]: dev error'
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: dev error'
|
||||
)
|
||||
expect(reporter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw in non-DEV mode when condition is false', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
expect(() => assert(false, 'non-dev error')).not.toThrow()
|
||||
})
|
||||
|
||||
it('calls registered reporter in non-DEV mode with formatted message', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
const reporter = vi.fn()
|
||||
setAssertReporter(reporter)
|
||||
assert(false, 'reporter message')
|
||||
expect(reporter).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: reporter message'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call reporter when condition is true', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
const reporter = vi.fn()
|
||||
setAssertReporter(reporter)
|
||||
assert(true, 'no call')
|
||||
expect(reporter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles null reporter gracefully in non-DEV mode', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
setAssertReporter(null)
|
||||
expect(() => assert(false, 'null reporter')).not.toThrow()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: null reporter'
|
||||
)
|
||||
})
|
||||
|
||||
it('swallows reporter exceptions in non-DEV mode', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
const reporter = vi.fn(() => {
|
||||
throw new Error('reporter blew up')
|
||||
})
|
||||
setAssertReporter(reporter)
|
||||
expect(() => assert(false, 'safe under reporter failure')).not.toThrow()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: safe under reporter failure'
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion reporter failed]',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
36
src/base/assert.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
type AssertReporter = (message: string) => void
|
||||
|
||||
let reporter: AssertReporter | null = null
|
||||
|
||||
/**
|
||||
* Register a reporter for assertion failures in non-DEV environments.
|
||||
* Called once at app startup by platform/ or higher layers to wire in
|
||||
* Sentry, toast notifications, etc.
|
||||
*/
|
||||
export function setAssertReporter(fn: AssertReporter | null): void {
|
||||
reporter = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized invariant assertion.
|
||||
*
|
||||
* - Always: console.error
|
||||
* - DEV: throws (surfaces bugs immediately)
|
||||
* - Otherwise: delegates to registered reporter (Sentry, toast, etc.)
|
||||
*/
|
||||
export function assert(condition: unknown, message: string): asserts condition {
|
||||
if (condition) return
|
||||
|
||||
const formatted = `[Assertion failed]: ${message}`
|
||||
console.error(formatted)
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
throw new Error(formatted)
|
||||
}
|
||||
|
||||
try {
|
||||
reporter?.(formatted)
|
||||
} catch (error) {
|
||||
console.error('[Assertion reporter failed]', error)
|
||||
}
|
||||
}
|
||||
20
src/base/wheelGestures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -16,14 +16,6 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/dialog', () => ({
|
||||
default: {
|
||||
name: 'Dialog',
|
||||
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
|
||||
props: ['visible']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
name: 'SelectButton',
|
||||
@@ -32,8 +24,29 @@ vi.mock('primevue/selectbutton', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: { name: 'Divider', template: '<hr />' }
|
||||
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
|
||||
default: { name: 'Dialog', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
|
||||
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
|
||||
default: { name: 'DialogOverlay', template: '<div />' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
|
||||
default: { name: 'DialogContent', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
|
||||
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogFooter.vue', () => ({
|
||||
default: { name: 'DialogFooter', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
|
||||
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
|
||||
default: { name: 'DialogClose', template: '<button />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
|
||||
|
||||
@@ -1,72 +1,111 @@
|
||||
<template>
|
||||
<Dialog v-model:visible="visible" :header="$t('g.customizeFolder')">
|
||||
<div class="p-fluid">
|
||||
<div class="field icon-field">
|
||||
<label for="icon">{{ $t('g.icon') }}</label>
|
||||
<SelectButton
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
<Dialog v-model:open="visible" :modal="false">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
size="md"
|
||||
:aria-labelledby="titleId"
|
||||
@pointer-down-outside="onPointerDownOutside"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle :id="titleId">
|
||||
{{ $t('g.customizeFolder') }}
|
||||
</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 px-4 py-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customization-icon" class="text-sm font-medium">
|
||||
{{ $t('g.icon') }}
|
||||
</label>
|
||||
<SelectButton
|
||||
id="customization-icon"
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customization-color" class="text-sm font-medium">
|
||||
{{ $t('g.color') }}
|
||||
</label>
|
||||
<ColorCustomizationSelector
|
||||
id="customization-color"
|
||||
v-model="finalColor"
|
||||
:color-options="colorOptions"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<div class="field color-field">
|
||||
<label for="color">{{ $t('g.color') }}</label>
|
||||
<ColorCustomizationSelector
|
||||
v-model="finalColor"
|
||||
:color-options="colorOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, useId, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorCustomizationSelector from '@/components/common/ColorCustomizationSelector.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
const { initialIcon, initialColor } = defineProps<{
|
||||
initialIcon?: string
|
||||
initialColor?: string
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', icon: string, color: string): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const titleId = useId()
|
||||
|
||||
// PrimeVue ColorPicker overlay teleports to body. Reka treats clicks on it as
|
||||
// outside and would dismiss the dialog mid-color-pick. Treat any PrimeVue
|
||||
// overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-colorpicker-panel, .p-overlay, .p-overlay-mask'
|
||||
|
||||
function onPointerDownOutside(
|
||||
event: CustomEvent<{ originalEvent: PointerEvent }>
|
||||
) {
|
||||
const target = event.detail.originalEvent.target
|
||||
if (target instanceof Element && target.closest(PRIMEVUE_OVERLAY_SELECTORS)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
@@ -95,30 +134,22 @@ const defaultIcon = iconOptions.find(
|
||||
)
|
||||
|
||||
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
|
||||
const finalColor = ref(
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
)
|
||||
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
|
||||
|
||||
const resetCustomization = () => {
|
||||
selectedIcon.value =
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ??
|
||||
iconOptions[0]
|
||||
finalColor.value =
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
iconOptions.find((option) => option.value === initialIcon) ?? iconOptions[0]
|
||||
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
}
|
||||
|
||||
const confirmCustomization = () => {
|
||||
emit('confirm', selectedIcon.value.value, finalColor.value)
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue: boolean) => {
|
||||
visible,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
resetCustomization()
|
||||
}
|
||||
@@ -135,10 +166,4 @@ watch(
|
||||
.p-selectbutton .p-button .pi {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { t } = useI18n()
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] -translate-1/2 rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface) h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
|
||||
class="h-full scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable overflow-y-auto [overflow-anchor:none]"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
data-testid="curve-editor"
|
||||
viewBox="-0.04 -0.04 1.08 1.08"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
:class="
|
||||
@@ -68,6 +69,7 @@
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
data-testid="curve-point"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchInput"
|
||||
:searcher="applySearchQuery"
|
||||
:debounce-ms="400"
|
||||
@@ -412,7 +412,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<Divider />
|
||||
<ScrollPanel class="h-[400px] w-full max-w-[80vw]">
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="h-[400px] w-full max-w-[80vw] overflow-auto">
|
||||
<pre class="wrap-break-word whitespace-pre-wrap">{{
|
||||
reportContent
|
||||
}}</pre>
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
</template>
|
||||
<div class="flex justify-end gap-4">
|
||||
<FindIssueButton
|
||||
@@ -62,8 +62,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fireEvent, render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -29,6 +30,26 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
|
||||
>
|
||||
}
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
const defaultSettingValues: Record<string, unknown> = {
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': true,
|
||||
'Comfy.Load3D.3DViewerEnable': true
|
||||
}
|
||||
|
||||
function mockSettingValues(overrides: Record<string, unknown> = {}) {
|
||||
const settingValues = {
|
||||
...defaultSettingValues,
|
||||
...overrides
|
||||
}
|
||||
settingGetMock.mockImplementation(
|
||||
(key: string): unknown => settingValues[key] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
// Mock the composables and services
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: vi.fn(() => ({
|
||||
@@ -79,10 +100,7 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Load3D.3DViewerEnable') return true
|
||||
return null
|
||||
})
|
||||
get: settingGetMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -128,7 +146,7 @@ describe('SelectionToolbox', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
|
||||
canvasStore = useCanvasStore()
|
||||
nodeDefMock = {
|
||||
type: 'TestNode',
|
||||
@@ -139,6 +157,7 @@ describe('SelectionToolbox', () => {
|
||||
canvasStore.canvas = createMockCanvas()
|
||||
|
||||
vi.resetAllMocks()
|
||||
mockSettingValues()
|
||||
})
|
||||
|
||||
function renderComponent(props = {}): { container: Element } {
|
||||
@@ -231,6 +250,42 @@ describe('SelectionToolbox', () => {
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the new node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': true
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should show info button when new menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should show color picker for all selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<DeleteButton v-if="showDelete" />
|
||||
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="showInfoButton" />
|
||||
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
@@ -105,9 +105,8 @@ const {
|
||||
isSingleImageNode,
|
||||
hasAny3DNodeSelected,
|
||||
hasOutputNodesSelected,
|
||||
nodeDef
|
||||
canOpenNodeInfo
|
||||
} = useSelectionState()
|
||||
const showInfoButton = computed(() => !!nodeDef.value)
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -9,19 +8,20 @@ import { createI18n } from 'vue-i18n'
|
||||
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { openPanelMock } = vi.hoisted(() => ({
|
||||
openPanelMock: vi.fn()
|
||||
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
|
||||
openNodeInfoMock: vi.fn(),
|
||||
trackUiButtonClickedMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({
|
||||
openPanel: openPanelMock
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => ({
|
||||
openNodeInfo: openNodeInfoMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: vi.fn()
|
||||
trackUiButtonClicked: trackUiButtonClickedMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
openNodeInfoMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
const renderComponent = () => {
|
||||
@@ -53,12 +53,29 @@ describe('InfoButton', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('should open the info panel on click', async () => {
|
||||
const clickNodeInfoButton = async () => {
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
}
|
||||
|
||||
it('should open the node info panel on click', async () => {
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not track the click when the node info panel is unavailable', async () => {
|
||||
openNodeInfoMock.mockReturnValue(false)
|
||||
renderComponent()
|
||||
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,18 +15,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { openNodeInfo } = useSelectionState()
|
||||
|
||||
/**
|
||||
* Track node info button click and toggle node help.
|
||||
*/
|
||||
const onInfoClick = () => {
|
||||
if (!openNodeInfo()) return
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
rightSidePanelStore.openPanel('info')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-current"
|
||||
class="pointer-events-none size-6.25 fill-current"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
@@ -26,7 +26,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
class="cls-1"
|
||||
@@ -44,7 +44,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="-6 -7 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
@@ -59,7 +59,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="-9 -7 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<g transform="scale(-1, 1)">
|
||||
<path
|
||||
@@ -76,7 +76,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
|
||||
@@ -92,7 +92,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<script>
|
||||
import Select from 'primevue/select'
|
||||
|
||||
export default {
|
||||
name: 'SelectPlus',
|
||||
extends: Select,
|
||||
emits: ['hide'],
|
||||
methods: {
|
||||
onOverlayLeave() {
|
||||
this.unbindOutsideClickListener()
|
||||
this.unbindScrollListener()
|
||||
this.unbindResizeListener()
|
||||
|
||||
this.$emit('hide')
|
||||
this.overlay = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -6,8 +6,15 @@ import { defineComponent } from 'vue'
|
||||
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import {
|
||||
ResultItemImpl,
|
||||
TaskItemImpl,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -20,28 +27,58 @@ const QueueOverlayExpandedStub = defineComponent({
|
||||
headerTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
displayedJobGroups: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div data-testid="expanded-title">{{ headerTitle }}</div>
|
||||
<button data-testid="show-assets-button" @click="$emit('show-assets')" />
|
||||
<button
|
||||
v-if="displayedJobGroups[0]?.items[0]"
|
||||
data-testid="view-first-job"
|
||||
@click="$emit('view-item', displayedJobGroups[0].items[0])"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl({
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
priority: 0
|
||||
function createTask(
|
||||
id: string,
|
||||
status: JobStatus,
|
||||
outputs: ResultItemImpl[] = []
|
||||
): TaskItemImpl {
|
||||
return new TaskItemImpl(
|
||||
{
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
execution_start_time: 0,
|
||||
execution_end_time: 1000,
|
||||
priority: 0
|
||||
},
|
||||
{},
|
||||
outputs
|
||||
)
|
||||
}
|
||||
|
||||
function createOutput(filename: string): ResultItemImpl {
|
||||
return new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType: '3D'
|
||||
})
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
runningTasks: TaskItemImpl[],
|
||||
pendingTasks: TaskItemImpl[]
|
||||
pendingTasks: TaskItemImpl[],
|
||||
historyTasks: TaskItemImpl[] = []
|
||||
) {
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
@@ -49,8 +86,11 @@ function renderComponent(
|
||||
})
|
||||
const queueStore = useQueueStore(pinia)
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
const assetsStore = useAssetsStore(pinia)
|
||||
const dialogStore = useDialogStore(pinia)
|
||||
queueStore.runningTasks = runningTasks
|
||||
queueStore.pendingTasks = pendingTasks
|
||||
queueStore.historyTasks = historyTasks
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -71,7 +111,7 @@ function renderComponent(
|
||||
}
|
||||
})
|
||||
|
||||
return { sidebarTabStore, user }
|
||||
return { assetsStore, dialogStore, sidebarTabStore, user }
|
||||
}
|
||||
|
||||
describe('QueueProgressOverlay', () => {
|
||||
@@ -116,4 +156,22 @@ describe('QueueProgressOverlay', () => {
|
||||
await user.click(screen.getByTestId('show-assets-button'))
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
})
|
||||
|
||||
it('opens loadable 3D outputs in the 3D viewer dialog', async () => {
|
||||
const task = createTask('completed-3d', 'completed', [
|
||||
createOutput('model.ply')
|
||||
])
|
||||
const { assetsStore, dialogStore, user } = renderComponent([], [], [task])
|
||||
vi.spyOn(assetsStore, 'updateHistory').mockResolvedValue()
|
||||
assetsStore.historyAssets = [{ id: 'completed-3d' } as AssetItem]
|
||||
|
||||
await user.click(screen.getByTestId('view-first-job'))
|
||||
|
||||
expect(dialogStore.dialogStack[0]).toMatchObject({
|
||||
key: 'asset-3d-viewer',
|
||||
contentProps: {
|
||||
modelUrl: expect.stringContaining('model.ply')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,15 +64,18 @@ import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHis
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useLoad3dViewerDialog } from '@/composables/useLoad3dViewerDialog'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getInspectionTargetsForTask } from '@/services/jobOutputCache'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { getPreferredInspectionTarget } from '@/utils/inspectionTarget'
|
||||
|
||||
type OverlayState = 'hidden' | 'active' | 'expanded'
|
||||
|
||||
@@ -92,6 +95,7 @@ const executionStore = useExecutionStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetSelectionStore = useAssetSelectionStore()
|
||||
const { openLoad3dViewer } = useLoad3dViewerDialog()
|
||||
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
|
||||
@@ -244,8 +248,7 @@ const openAssetsSidebar = () => {
|
||||
const focusAssetInSidebar = async (item: JobListItem) => {
|
||||
const task = item.taskRef
|
||||
const jobId = task?.jobId
|
||||
const preview = task?.previewOutput
|
||||
if (!jobId || !preview) return
|
||||
if (!jobId) return
|
||||
|
||||
const assetId = String(jobId)
|
||||
openAssetsSidebar()
|
||||
@@ -264,7 +267,24 @@ const focusAssetInSidebar = async (item: JobListItem) => {
|
||||
const inspectJobAsset = wrapWithErrorHandlingAsync(
|
||||
async (item: JobListItem) => {
|
||||
trackFeatureUsed()
|
||||
await openResultGallery(item)
|
||||
const task = item.taskRef
|
||||
if (!task) return
|
||||
|
||||
const targets = await getInspectionTargetsForTask(task)
|
||||
if (targets === null) return
|
||||
|
||||
const target = getPreferredInspectionTarget(targets)
|
||||
if (!target) return
|
||||
|
||||
if (target.kind === 'load3d') {
|
||||
openLoad3dViewer({
|
||||
title: item.title,
|
||||
modelUrl: target.output.url
|
||||
})
|
||||
} else {
|
||||
await openResultGallery(item)
|
||||
}
|
||||
|
||||
await focusAssetInSidebar(item)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -151,15 +151,21 @@ vi.mock('vue-i18n', () => {
|
||||
})
|
||||
|
||||
type TestPreviewOutput = {
|
||||
filename: string
|
||||
type: string
|
||||
url: string
|
||||
previewUrl: string
|
||||
isImage: boolean
|
||||
isVideo: boolean
|
||||
isAudio: boolean
|
||||
is3D: boolean
|
||||
}
|
||||
|
||||
type TestTaskRef = {
|
||||
workflowId?: string
|
||||
previewOutput?: TestPreviewOutput
|
||||
flatOutputs: TestPreviewOutput[]
|
||||
outputsCount?: number
|
||||
}
|
||||
|
||||
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
|
||||
@@ -176,16 +182,42 @@ const createPreviewOutput = (
|
||||
): TestPreviewOutput => {
|
||||
const url = `/api/view/${filename}`
|
||||
return {
|
||||
filename,
|
||||
type: 'output',
|
||||
url,
|
||||
previewUrl: mediaType === 'images' ? `${url}?res=512` : url,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video'
|
||||
isVideo: mediaType === 'video',
|
||||
isAudio: mediaType === 'audio',
|
||||
is3D: [
|
||||
'glb',
|
||||
'gltf',
|
||||
'obj',
|
||||
'fbx',
|
||||
'stl',
|
||||
'spz',
|
||||
'splat',
|
||||
'ply',
|
||||
'ksplat',
|
||||
'usdz'
|
||||
].some((extension) => filename.endsWith(`.${extension}`))
|
||||
}
|
||||
}
|
||||
|
||||
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
|
||||
const createTaskRef = (
|
||||
preview?: TestPreviewOutput,
|
||||
inspectionOutput: TestPreviewOutput | null = preview ?? null
|
||||
): TestTaskRef => ({
|
||||
workflowId: 'workflow-1',
|
||||
...(preview && { previewOutput: preview })
|
||||
...(preview && { previewOutput: preview }),
|
||||
flatOutputs:
|
||||
inspectionOutput && inspectionOutput !== preview
|
||||
? [preview, inspectionOutput].filter(
|
||||
(output): output is TestPreviewOutput => !!output
|
||||
)
|
||||
: preview
|
||||
? [preview]
|
||||
: []
|
||||
})
|
||||
|
||||
const buildJob = (
|
||||
@@ -368,10 +400,28 @@ describe('JobAssetsList', () => {
|
||||
expect(onViewItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits viewItem from the View button for completed jobs without preview output', async () => {
|
||||
it('does not emit viewItem for completed jobs without an inspection target', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef()
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.usdz', 'model'), null)
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
|
||||
expect(screen.queryByText('menuLabels.View')).not.toBeInTheDocument()
|
||||
await nextTick()
|
||||
|
||||
expect(onViewItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits viewItem from the View button for completed jobs with an inspection target', async () => {
|
||||
const inspectionTarget = createPreviewOutput('job-1.ply', 'model')
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(undefined, inspectionTarget)
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="row.job.state === 'completed'"
|
||||
v-else-if="canInspectCompletedAsset(row.job)"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(row.job)"
|
||||
@@ -115,6 +115,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { canAttemptTaskInspection } from '@/utils/inspectionTarget'
|
||||
|
||||
import { buildVirtualJobRows } from './buildVirtualJobRows'
|
||||
import type { VirtualJobRow } from './buildVirtualJobRows'
|
||||
@@ -346,12 +347,16 @@ function isVideoPreviewJob(job: JobListItem) {
|
||||
return job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
|
||||
}
|
||||
|
||||
function isPreviewableCompletedJob(job: JobListItem) {
|
||||
return job.state === 'completed' && !!getPreviewOutput(job)
|
||||
function canInspectCompletedAsset(job: JobListItem) {
|
||||
return (
|
||||
job.state === 'completed' &&
|
||||
!!job.taskRef &&
|
||||
canAttemptTaskInspection(job.taskRef)
|
||||
)
|
||||
}
|
||||
|
||||
function emitViewItem(job: JobListItem) {
|
||||
if (isPreviewableCompletedJob(job)) {
|
||||
if (canInspectCompletedAsset(job)) {
|
||||
resetActiveDetails()
|
||||
emit('viewItem', job)
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ function handleTitleCancel() {
|
||||
</section>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<div class="flex-1 scrollbar-thin overflow-y-auto">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<template v-else-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
FormSearchInput: {
|
||||
AsyncSearchInput: {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<CollapseToggleButton
|
||||
v-model="isAllCollapsed"
|
||||
:show="!isSearching && tabErrorGroups.length > 1"
|
||||
@@ -260,7 +260,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
@@ -119,7 +119,7 @@ function onCollapseUpdate() {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="favoritedWidgets"
|
||||
|
||||