Compare commits
1 Commits
coderabbit
...
chore/refr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17cae6497a |
9
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -106,12 +106,19 @@ 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,unmapped
|
||||
--ignore-errors source
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
1
.github/workflows/ci-website-e2e.yaml
vendored
@@ -51,7 +51,6 @@ jobs:
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
|
||||
WEBSITE_CLOUD_NODES_FIXTURE: e2e/fixtures/cloud-nodes.fixture.json
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Run Playwright tests
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
{
|
||||
"fetchedAt": "2026-01-01T00:00:00.000Z",
|
||||
"packs": [
|
||||
{
|
||||
"id": "comfyui-impact-pack",
|
||||
"registryId": "comfyui-impact-pack",
|
||||
"displayName": "ComfyUI Impact Pack",
|
||||
"description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
|
||||
"bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-banner.webp",
|
||||
"iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-icon.webp",
|
||||
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
|
||||
"publisher": {
|
||||
"id": "drltdata",
|
||||
"name": "Dr.Lt.Data"
|
||||
},
|
||||
"downloads": 2618646,
|
||||
"githubStars": 3092,
|
||||
"latestVersion": "8.28.3",
|
||||
"license": "GPL-3.0",
|
||||
"lastUpdated": "2026-04-19T17:08:04.993918Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "FaceDetailer",
|
||||
"displayName": "FaceDetailer",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Detect and refine faces with iterative passes."
|
||||
},
|
||||
{
|
||||
"name": "DetailerForEach",
|
||||
"displayName": "DetailerForEach",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Run iterative detail refinement over detected SEG regions."
|
||||
},
|
||||
{
|
||||
"name": "UltralyticsDetectorProvider",
|
||||
"displayName": "UltralyticsDetectorProvider",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Provide detector models powered by Ultralytics YOLO."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-crystools",
|
||||
"registryId": "ComfyUI-Crystools",
|
||||
"displayName": "ComfyUI-Crystools",
|
||||
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow.",
|
||||
"bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-banner.webp",
|
||||
"iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-icon.webp",
|
||||
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
|
||||
"publisher": {
|
||||
"id": "crystian",
|
||||
"name": "Crystian"
|
||||
},
|
||||
"downloads": 1671447,
|
||||
"githubStars": 1855,
|
||||
"latestVersion": "1.27.4",
|
||||
"license": "MIT",
|
||||
"lastUpdated": "2025-10-26T19:11:09.943366Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "CCrystools_Show_Resources",
|
||||
"displayName": "CCrystools_Show_Resources",
|
||||
"category": "crystools/show",
|
||||
"description": "Display GPU, RAM and disk usage live in the workflow."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Show_Image",
|
||||
"displayName": "CCrystools_Show_Image",
|
||||
"category": "crystools/show",
|
||||
"description": "Inspect images at full resolution with metadata overlays."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alpha-test-pack",
|
||||
"registryId": "alpha-test-pack",
|
||||
"displayName": "Alpha Test Pack",
|
||||
"description": "Deterministic fixture pack used to anchor the A\u2192Z sort assertion in Playwright. Its display name starts with 'A' so it surfaces first when sorted alphabetically.",
|
||||
"bannerUrl": "https://media.comfy.org/cloud-nodes/alpha-test-pack-banner.webp",
|
||||
"repoUrl": "https://github.com/Comfy-Org/alpha-test-pack",
|
||||
"publisher": {
|
||||
"id": "comfy-org",
|
||||
"name": "Comfy Org"
|
||||
},
|
||||
"downloads": 42,
|
||||
"githubStars": 7,
|
||||
"latestVersion": "0.1.0",
|
||||
"license": "MIT",
|
||||
"lastUpdated": "2026-01-01T00:00:00.000Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "AlphaProbe",
|
||||
"displayName": "AlphaProbe",
|
||||
"category": "alpha/test",
|
||||
"description": "Deterministic node used to verify alphabetical ordering."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rgthree-comfy",
|
||||
"registryId": "rgthree-comfy",
|
||||
"displayName": "rgthree-comfy",
|
||||
"description": "Quality-of-life nodes from rgthree: Power Lora Loader, Display Any, Image Comparer, and more.",
|
||||
"bannerUrl": "https://media.comfy.org/cloud-nodes/rgthree-comfy-banner.webp",
|
||||
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
|
||||
"publisher": {
|
||||
"id": "rgthree",
|
||||
"name": "rgthree"
|
||||
},
|
||||
"downloads": 987654,
|
||||
"githubStars": 1500,
|
||||
"latestVersion": "2.0.0",
|
||||
"license": "MIT",
|
||||
"lastUpdated": "2026-03-15T00:00:00.000Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "PowerLoraLoader",
|
||||
"displayName": "Power Lora Loader",
|
||||
"category": "rgthree/loaders",
|
||||
"description": "Load multiple LoRAs at once with strength control."
|
||||
},
|
||||
{
|
||||
"name": "DisplayAny",
|
||||
"displayName": "Display Any",
|
||||
"category": "rgthree/display",
|
||||
"description": "Display the value of any wire for debugging."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "was-node-suite-comfyui",
|
||||
"registryId": "was-node-suite-comfyui",
|
||||
"displayName": "WAS Node Suite",
|
||||
"description": "Large collection of utility nodes for image processing, text manipulation, and workflow control.",
|
||||
"bannerUrl": "https://media.comfy.org/cloud-nodes/was-node-suite-banner.webp",
|
||||
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
|
||||
"publisher": {
|
||||
"id": "wasasquatch",
|
||||
"name": "WASasquatch"
|
||||
},
|
||||
"downloads": 1234567,
|
||||
"githubStars": 1700,
|
||||
"latestVersion": "1.0.0",
|
||||
"license": "MIT",
|
||||
"lastUpdated": "2026-02-01T00:00:00.000Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "WASImageBlend",
|
||||
"displayName": "Image Blend",
|
||||
"category": "WAS/image",
|
||||
"description": "Blend two images using a configurable mode."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
const contactColumn = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
@@ -91,11 +91,6 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
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,7 +32,6 @@ 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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-12T16:10:34.114Z",
|
||||
"fetchedAt": "2026-05-14T14:47:42.884Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
@@ -135,6 +135,13 @@
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
|
||||
},
|
||||
{
|
||||
"id": "e11f8b9e58dbea81",
|
||||
"title": "Creative Producer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1773,7 +1773,6 @@ 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',
|
||||
|
||||
@@ -4,9 +4,7 @@ import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
const fetchCloudNodesMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(options?: { snapshotUrl?: URL; apiKey?: string }) => Promise<FetchOutcome>
|
||||
>()
|
||||
vi.fn<() => Promise<FetchOutcome>>()
|
||||
)
|
||||
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
@@ -35,26 +33,19 @@ const SNAPSHOT: NodesSnapshot = {
|
||||
|
||||
describe('loadPacksForBuild', () => {
|
||||
const savedVercelEnv = process.env.VERCEL_ENV
|
||||
const savedFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCloudNodesMock.mockReset()
|
||||
reportCloudNodesOutcomeMock.mockReset()
|
||||
delete process.env.VERCEL_ENV
|
||||
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (savedVercelEnv === undefined) {
|
||||
delete process.env.VERCEL_ENV
|
||||
} else {
|
||||
process.env.VERCEL_ENV = savedVercelEnv
|
||||
}
|
||||
if (savedFixture === undefined) {
|
||||
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
|
||||
} else {
|
||||
process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedFixture
|
||||
return
|
||||
}
|
||||
process.env.VERCEL_ENV = savedVercelEnv
|
||||
})
|
||||
|
||||
it('returns packs when fetch is fresh', async () => {
|
||||
@@ -134,50 +125,4 @@ describe('loadPacksForBuild', () => {
|
||||
await expect(loadPacksForBuild()).rejects.toThrow()
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards WEBSITE_CLOUD_NODES_FIXTURE as snapshotUrl with an empty api key', async () => {
|
||||
process.env.WEBSITE_CLOUD_NODES_FIXTURE =
|
||||
'e2e/fixtures/cloud-nodes.fixture.json'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
await loadPacksForBuild()
|
||||
const call = fetchCloudNodesMock.mock.calls[0]?.[0]
|
||||
expect(call?.snapshotUrl).toBeInstanceOf(URL)
|
||||
expect(call?.snapshotUrl?.protocol).toBe('file:')
|
||||
expect(call?.snapshotUrl?.pathname).toMatch(
|
||||
/apps\/website\/e2e\/fixtures\/cloud-nodes\.fixture\.json$/
|
||||
)
|
||||
expect(call?.apiKey).toBe('')
|
||||
})
|
||||
|
||||
it('accepts an absolute path for WEBSITE_CLOUD_NODES_FIXTURE', async () => {
|
||||
process.env.WEBSITE_CLOUD_NODES_FIXTURE = '/etc/cloud-nodes.fixture.json'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
await loadPacksForBuild()
|
||||
const call = fetchCloudNodesMock.mock.calls[0]?.[0]
|
||||
expect(call?.snapshotUrl?.pathname).toBe('/etc/cloud-nodes.fixture.json')
|
||||
})
|
||||
|
||||
it('does not throw on stale-in-production when the fixture override is set', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
process.env.WEBSITE_CLOUD_NODES_FIXTURE =
|
||||
'e2e/fixtures/cloud-nodes.fixture.json'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
import type { Pack } from '../data/cloudNodes'
|
||||
|
||||
import { fetchCloudNodesForBuild } from './cloudNodes'
|
||||
@@ -10,42 +7,25 @@ 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.'
|
||||
|
||||
const WEBSITE_PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
/**
|
||||
* Determine whether the current build is a production Vercel deployment.
|
||||
*
|
||||
* @returns `true` if `process.env.VERCEL_ENV` is exactly `'production'`, `false` otherwise.
|
||||
*/
|
||||
function isProductionBuild(): boolean {
|
||||
return process.env.VERCEL_ENV === 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a file:// URL pointing to a local fixture snapshot when WEBSITE_CLOUD_NODES_FIXTURE is set.
|
||||
*
|
||||
* @returns A `URL` for the resolved fixture path, or `undefined` if the environment variable is not set.
|
||||
*/
|
||||
function fixtureSnapshotUrl(): URL | undefined {
|
||||
const fixturePath = process.env.WEBSITE_CLOUD_NODES_FIXTURE
|
||||
if (!fixturePath) return undefined
|
||||
const absolute = isAbsolute(fixturePath)
|
||||
? fixturePath
|
||||
: resolvePath(WEBSITE_PACKAGE_ROOT, fixturePath)
|
||||
return pathToFileURL(absolute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the list of packs to render at build time.
|
||||
*
|
||||
* The same resolved snapshot is used to derive both the site index and per-pack detail routes so static pages share a single source of truth. In production builds a stale snapshot causes the build to fail unless a local fixture override is provided via the `WEBSITE_CLOUD_NODES_FIXTURE` environment variable, which forces use of the on-disk snapshot instead of the live cloud API.
|
||||
* Used by both the index page and the per-pack detail pages so that the
|
||||
* static index and the static detail routes are always derived from the
|
||||
* 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.
|
||||
*
|
||||
* @returns The array of `Pack` objects from the resolved snapshot to render at build time.
|
||||
* 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 snapshotUrl = fixtureSnapshotUrl()
|
||||
const options = snapshotUrl ? { snapshotUrl, apiKey: '' } : {}
|
||||
const outcome = await fetchCloudNodesForBuild(options)
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
reportCloudNodesOutcome(outcome)
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
@@ -54,7 +34,7 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
)
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale' && isProductionBuild() && !snapshotUrl) {
|
||||
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}`
|
||||
|
||||
@@ -87,7 +87,6 @@ function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
|
||||
|
||||
describe('fetchCloudNodesForBuild', () => {
|
||||
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
|
||||
const savedCloudNodesFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesFetcherForTests()
|
||||
@@ -95,21 +94,11 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
sanitizeCallSpy.mockReset()
|
||||
delete process.env.WEBSITE_CLOUD_API_KEY
|
||||
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (savedCloudApiKey === undefined) {
|
||||
delete process.env.WEBSITE_CLOUD_API_KEY
|
||||
} else {
|
||||
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
|
||||
}
|
||||
if (savedCloudNodesFixture === undefined) {
|
||||
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
|
||||
} else {
|
||||
process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedCloudNodesFixture
|
||||
}
|
||||
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
|
||||
})
|
||||
|
||||
it('returns fresh when API succeeds', async () => {
|
||||
@@ -317,335 +306,4 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
})
|
||||
|
||||
it('falls back to the raw upstream id for registryId when registry lookup misses', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
QwenNode: validNode({
|
||||
name: 'QwenNode',
|
||||
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
|
||||
})
|
||||
})
|
||||
)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
|
||||
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
|
||||
})
|
||||
|
||||
it('falls back to the raw upstream id for registryId when fetchRegistryPacks throws', async () => {
|
||||
fetchRegistryPacksMock.mockImplementation(async () => {
|
||||
throw new Error('registry unreachable')
|
||||
})
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
QwenNode: validNode({
|
||||
name: 'QwenNode',
|
||||
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
|
||||
})
|
||||
})
|
||||
)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
|
||||
})
|
||||
|
||||
it('slugifies pack ids while querying the registry with the raw id', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'ComfyUI_QwenVL',
|
||||
{
|
||||
id: 'ComfyUI_QwenVL',
|
||||
name: 'ComfyUI QwenVL',
|
||||
repository: 'https://github.com/example/ComfyUI_QwenVL'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
QwenNode: validNode({
|
||||
name: 'QwenNode',
|
||||
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
|
||||
})
|
||||
})
|
||||
)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
|
||||
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
|
||||
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
|
||||
['ComfyUI_QwenVL'],
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('queries every raw-id alias when packs collide on the same slug and picks the first hit', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map<string, unknown>([
|
||||
['ComfyUI-QwenVL', null],
|
||||
[
|
||||
'ComfyUI_QwenVL',
|
||||
{
|
||||
id: 'ComfyUI_QwenVL',
|
||||
name: 'ComfyUI QwenVL',
|
||||
repository: 'https://github.com/example/ComfyUI_QwenVL'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
QwenDash: validNode({
|
||||
name: 'QwenDash',
|
||||
python_module: 'custom_nodes.ComfyUI-QwenVL.nodes'
|
||||
}),
|
||||
QwenUnder: validNode({
|
||||
name: 'QwenUnder',
|
||||
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
|
||||
})
|
||||
})
|
||||
)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.packs).toHaveLength(1)
|
||||
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
|
||||
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
|
||||
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
||||
'https://github.com/example/ComfyUI_QwenVL'
|
||||
)
|
||||
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
|
||||
['ComfyUI-QwenVL', 'ComfyUI_QwenVL'],
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the first non-null registry result when every alias resolves', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map<string, unknown>([
|
||||
[
|
||||
'ComfyUI-QwenVL',
|
||||
{
|
||||
id: 'ComfyUI-QwenVL',
|
||||
name: 'Dash Variant',
|
||||
repository: 'https://github.com/example/dash-first'
|
||||
}
|
||||
],
|
||||
[
|
||||
'ComfyUI_QwenVL',
|
||||
{
|
||||
id: 'ComfyUI_QwenVL',
|
||||
name: 'Underscore Variant',
|
||||
repository: 'https://github.com/example/underscore-second'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
QwenDash: validNode({
|
||||
name: 'QwenDash',
|
||||
python_module: 'custom_nodes.ComfyUI-QwenVL.nodes'
|
||||
}),
|
||||
QwenUnder: validNode({
|
||||
name: 'QwenUnder',
|
||||
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
|
||||
})
|
||||
})
|
||||
)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.packs).toHaveLength(1)
|
||||
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI-QwenVL')
|
||||
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
||||
'https://github.com/example/dash-first'
|
||||
)
|
||||
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
|
||||
['ComfyUI-QwenVL', 'ComfyUI_QwenVL'],
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes pack ids when reading a fallback snapshot', async () => {
|
||||
const snapshotUrl = withSnapshotDir({
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs: [
|
||||
{
|
||||
id: 'ComfyUI-Crystools',
|
||||
displayName: 'ComfyUI-Crystools',
|
||||
nodes: [
|
||||
{
|
||||
name: 'CrystoolsNode',
|
||||
displayName: 'Crystools Node',
|
||||
category: 'x'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'basic_data_handling',
|
||||
displayName: 'basic_data_handling',
|
||||
nodes: [
|
||||
{ name: 'BasicNode', displayName: 'Basic Node', category: 'x' }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.snapshot.packs.map((p) => p.id)).toEqual([
|
||||
'comfyui-crystools',
|
||||
'basic-data-handling'
|
||||
])
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('merges packs in the fallback snapshot whose ids slugify to the same value', async () => {
|
||||
const snapshotUrl = withSnapshotDir({
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs: [
|
||||
{
|
||||
id: 'ComfyUI-QwenVL',
|
||||
displayName: 'ComfyUI QwenVL',
|
||||
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
|
||||
},
|
||||
{
|
||||
id: 'ComfyUI_QwenVL',
|
||||
displayName: 'ComfyUI QwenVL',
|
||||
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.snapshot.packs).toHaveLength(1)
|
||||
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
|
||||
expect(outcome.snapshot.packs[0]?.nodes.map((n) => n.name).sort()).toEqual([
|
||||
'A',
|
||||
'B'
|
||||
])
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('preserves optional metadata from later aliases when snapshot packs collide on slug', async () => {
|
||||
const snapshotUrl = withSnapshotDir({
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs: [
|
||||
{
|
||||
id: 'ComfyUI-QwenVL',
|
||||
displayName: 'ComfyUI QwenVL',
|
||||
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
|
||||
},
|
||||
{
|
||||
id: 'ComfyUI_QwenVL',
|
||||
displayName: 'ComfyUI QwenVL',
|
||||
registryId: 'ComfyUI_QwenVL',
|
||||
description: 'rich description from the underscore variant',
|
||||
repoUrl: 'https://github.com/example/ComfyUI_QwenVL',
|
||||
publisher: { id: 'qwen-team', name: 'Qwen Team' },
|
||||
downloads: 1234,
|
||||
githubStars: 7,
|
||||
license: 'MIT',
|
||||
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
const merged = outcome.snapshot.packs[0]
|
||||
expect(merged?.id).toBe('comfyui-qwenvl')
|
||||
expect(merged?.registryId).toBe('ComfyUI_QwenVL')
|
||||
expect(merged?.description).toBe(
|
||||
'rich description from the underscore variant'
|
||||
)
|
||||
expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI_QwenVL')
|
||||
expect(merged?.publisher).toEqual({ id: 'qwen-team', name: 'Qwen Team' })
|
||||
expect(merged?.downloads).toBe(1234)
|
||||
expect(merged?.githubStars).toBe(7)
|
||||
expect(merged?.license).toBe('MIT')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not overwrite metadata already present on the first slug-collided pack', async () => {
|
||||
const snapshotUrl = withSnapshotDir({
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs: [
|
||||
{
|
||||
id: 'ComfyUI-QwenVL',
|
||||
displayName: 'first wins',
|
||||
registryId: 'ComfyUI-QwenVL',
|
||||
repoUrl: 'https://github.com/example/ComfyUI-QwenVL',
|
||||
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
|
||||
},
|
||||
{
|
||||
id: 'ComfyUI_QwenVL',
|
||||
displayName: 'second loses',
|
||||
registryId: 'ComfyUI_QwenVL',
|
||||
repoUrl: 'https://github.com/example/ComfyUI_QwenVL',
|
||||
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
const merged = outcome.snapshot.packs[0]
|
||||
expect(merged?.displayName).toBe('first wins')
|
||||
expect(merged?.registryId).toBe('ComfyUI-QwenVL')
|
||||
expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI-QwenVL')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
groupNodesByPack,
|
||||
sanitizeUserContent,
|
||||
slugifyPackId,
|
||||
validateComfyNodeDef
|
||||
} from '@comfyorg/object-info-parser'
|
||||
|
||||
@@ -214,13 +213,6 @@ async function callOnce(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates a raw cloud nodes envelope into domain packs, enriches packs with registry metadata when available, and collects validation failures.
|
||||
*
|
||||
* @param envelope - Raw payload object from the cloud API keyed by node class name containing node definitions to validate and parse.
|
||||
* @param options - Fetch and behavior options used when resolving registry pack metadata (for example, `fetchImpl`).
|
||||
* @returns The `'ok'` outcome containing `packs` (an array of domain `Pack` objects) and `droppedNodes` (an array of `{ name, reason }` entries for definitions that failed validation).
|
||||
*/
|
||||
async function parseCloudNodes(
|
||||
envelope: Record<string, unknown>,
|
||||
options: FetchCloudNodesOptions
|
||||
@@ -245,12 +237,12 @@ async function parseCloudNodes(
|
||||
)
|
||||
const grouped = groupNodesByPack(sanitizedDefs)
|
||||
|
||||
const allAliases = grouped.flatMap((pack) => pack.rawIds)
|
||||
let registryMap = new Map<string, RegistryPack | null>()
|
||||
try {
|
||||
registryMap = await fetchRegistryPacks(allAliases, {
|
||||
fetchImpl: options.fetchImpl
|
||||
})
|
||||
registryMap = await fetchRegistryPacks(
|
||||
grouped.map((pack) => pack.id),
|
||||
{ fetchImpl: options.fetchImpl }
|
||||
)
|
||||
} catch {
|
||||
registryMap = new Map()
|
||||
}
|
||||
@@ -258,40 +250,15 @@ async function parseCloudNodes(
|
||||
const packs = grouped.map((pack) =>
|
||||
toDomainPack(
|
||||
pack.id,
|
||||
pack.rawIds[0],
|
||||
pack.displayName,
|
||||
pack.nodes,
|
||||
pickRegistryPack(registryMap, pack.rawIds)
|
||||
registryMap.get(pack.id)
|
||||
)
|
||||
)
|
||||
|
||||
return { kind: 'ok', packs, droppedNodes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the most appropriate registry pack for a pack using its ordered aliases.
|
||||
*
|
||||
* @param registryMap - Map from alias to `RegistryPack` or explicit `null` indicating a known-but-empty entry
|
||||
* @param aliases - Ordered aliases to probe; earlier aliases have higher priority
|
||||
* @returns A `RegistryPack` if any alias maps to a non-null value; `null` if no alias had a non-null value but the first alias exists in the map with value `null`; `undefined` if the first alias is absent from the map
|
||||
*/
|
||||
function pickRegistryPack(
|
||||
registryMap: Map<string, RegistryPack | null>,
|
||||
aliases: readonly string[]
|
||||
): RegistryPack | null | undefined {
|
||||
for (const alias of aliases) {
|
||||
const hit = registryMap.get(alias)
|
||||
if (hit) return hit
|
||||
}
|
||||
return registryMap.get(aliases[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize an external URL string.
|
||||
*
|
||||
* @param value - The input URL string to validate; may be `undefined`.
|
||||
* @returns The canonical `http` or `https` URL string if `value` is a valid absolute URL with a host, `undefined` otherwise.
|
||||
*/
|
||||
function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
@@ -304,19 +271,8 @@ function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed pack data and optional registry metadata into a domain `Pack`.
|
||||
*
|
||||
* @param packId - The canonical identifier to use for the pack
|
||||
* @param fallbackRegistryId - Registry id to use when `registryPack` does not provide one
|
||||
* @param fallbackDisplayName - Display name to use when `registryPack` does not provide a name
|
||||
* @param nodes - Array of node entries containing the class name and validated node definition
|
||||
* @param registryPack - Optional registry metadata for enriching pack fields; may be `null` or `undefined`
|
||||
* @returns A `Pack` with normalized fields, safe external URLs, optional publisher info, registry-derived metadata when available, and nodes converted to `PackNode` objects sorted by display name
|
||||
*/
|
||||
function toDomainPack(
|
||||
packId: string,
|
||||
fallbackRegistryId: string | undefined,
|
||||
fallbackDisplayName: string,
|
||||
nodes: Array<{
|
||||
className: string
|
||||
@@ -332,7 +288,7 @@ function toDomainPack(
|
||||
): Pack {
|
||||
return {
|
||||
id: packId,
|
||||
registryId: registryPack?.id ?? fallbackRegistryId,
|
||||
registryId: registryPack?.id,
|
||||
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
|
||||
description: registryPack?.description?.trim() || undefined,
|
||||
bannerUrl: safeExternalUrl(registryPack?.banner_url),
|
||||
@@ -378,82 +334,22 @@ function toDomainNode(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate a nodes snapshot from a provided file URL or from the bundled snapshot, normalizing pack IDs.
|
||||
*
|
||||
* If `snapshotUrl` is provided, reads the file, parses JSON, and returns the snapshot after `isNodesSnapshot` validation and `normalizeSnapshotIds` normalization.
|
||||
* If `snapshotUrl` is omitted, validates and returns the bundled snapshot after normalization.
|
||||
* Returns `null` if reading, parsing, or validation fails.
|
||||
*
|
||||
* @param snapshotUrl - Optional file `URL` pointing to a snapshot JSON; when omitted the bundled snapshot is used
|
||||
* @returns The normalized `NodesSnapshot` if available and valid, `null` otherwise
|
||||
*/
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<NodesSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isNodesSnapshot(bundledSnapshot)
|
||||
? normalizeSnapshotIds(bundledSnapshot)
|
||||
: null
|
||||
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isNodesSnapshot(parsed)) return normalizeSnapshotIds(parsed)
|
||||
if (isNodesSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize pack IDs by slugifying each pack's `id`, omitting packs with empty slugs, and merging packs that produce the same slug.
|
||||
*
|
||||
* The returned snapshot preserves the original snapshot fields but replaces `packs` with a list whose `id` values are the slugified IDs. When multiple packs map to the same slug, their nodes and non-nullish metadata are merged into a single pack.
|
||||
*
|
||||
* @param snapshot - The snapshot whose pack IDs should be normalized and deduplicated
|
||||
* @returns A new `NodesSnapshot` with pack IDs replaced by slugs, colliding packs merged, and packs with falsy slugs removed
|
||||
*/
|
||||
function normalizeSnapshotIds(snapshot: NodesSnapshot): NodesSnapshot {
|
||||
const bySlug = new Map<string, Pack>()
|
||||
for (const pack of snapshot.packs) {
|
||||
const slug = slugifyPackId(pack.id)
|
||||
if (!slug) continue
|
||||
const existing = bySlug.get(slug)
|
||||
if (existing) {
|
||||
bySlug.set(slug, mergeCollidedPacks(existing, pack))
|
||||
continue
|
||||
}
|
||||
bySlug.set(slug, { ...pack, id: slug })
|
||||
}
|
||||
return { ...snapshot, packs: [...bySlug.values()] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two packs that represent the same logical pack by concatenating their nodes and filling any missing metadata from the later pack.
|
||||
*
|
||||
* @param first - The base pack whose values take precedence.
|
||||
* @param next - The colliding pack whose `nodes` are appended and whose non-null, non-`id` fields supply values only when `first` has them missing.
|
||||
* @returns A new `Pack` whose `nodes` are `first.nodes` followed by `next.nodes`, with other fields taken from `first` unless absent, in which case the corresponding value from `next` is used.
|
||||
*/
|
||||
function mergeCollidedPacks(first: Pack, next: Pack): Pack {
|
||||
const merged: Pack = { ...first, nodes: [...first.nodes, ...next.nodes] }
|
||||
for (const [key, value] of Object.entries(next) as [keyof Pack, unknown][]) {
|
||||
if (key === 'id' || key === 'nodes') continue
|
||||
if (value === undefined || value === null) continue
|
||||
if (merged[key] === undefined || merged[key] === null) {
|
||||
;(merged as Record<keyof Pack, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause execution for the specified duration.
|
||||
*
|
||||
* @param ms - Duration to wait in milliseconds
|
||||
* @returns A promise that resolves with no value when the delay has elapsed
|
||||
*/
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,6 @@ 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'>
|
||||
@@ -31,33 +26,3 @@ 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
|
||||
]
|
||||
|
||||
176
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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)
|
||||
}
|
||||
15
browser_tests/fixtures/jobsApiMockFixture.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -1,169 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
|
||||
const pageJobs = jobs.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
jobs: pageJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
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
|
||||
): Promise<void> {
|
||||
await this.mockJobsList({
|
||||
statuses: terminalJobStatuses,
|
||||
jobs,
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
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,19 +122,3 @@ 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()
|
||||
}
|
||||
|
||||
52
browser_tests/fixtures/utils/jobFixtures.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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,18 +5,26 @@ import {
|
||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
saveCloseAndReopenInBuilder,
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
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 saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
|
||||
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.toggleAppMode()
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -8,15 +8,15 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
@@ -213,9 +213,9 @@ async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function outputHistoryJobs(): RawJobListItem[] {
|
||||
return [
|
||||
createRouteMockJob({
|
||||
function outputHistoryJobs() {
|
||||
return createMockJobRecords([
|
||||
createMockJob({
|
||||
id: 'history-output-image',
|
||||
preview_output: {
|
||||
filename: 'ComfyUI_00001_.png',
|
||||
@@ -225,7 +225,7 @@ function outputHistoryJobs(): RawJobListItem[] {
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createRouteMockJob({
|
||||
createMockJob({
|
||||
id: 'history-output-video',
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
@@ -235,7 +235,7 @@ function outputHistoryJobs(): RawJobListItem[] {
|
||||
mediaType: 'video'
|
||||
}
|
||||
}),
|
||||
createRouteMockJob({
|
||||
createMockJob({
|
||||
id: 'history-output-audio',
|
||||
preview_output: {
|
||||
filename: 'sound.wav',
|
||||
@@ -245,7 +245,7 @@ function outputHistoryJobs(): RawJobListItem[] {
|
||||
mediaType: 'audio'
|
||||
}
|
||||
})
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
ossTest.describe(
|
||||
@@ -258,9 +258,8 @@ ossTest.describe(
|
||||
|
||||
ossTest(
|
||||
'resolves annotated output media from job history',
|
||||
async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsHistory(outputHistoryJobs())
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(outputHistoryJobs())
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_output_annotations'
|
||||
|
||||
@@ -1,54 +1,56 @@
|
||||
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 {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const mockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: mockJobTimestamp - 60_000,
|
||||
execution_start_time: mockJobTimestamp - 60_000,
|
||||
execution_end_time: mockJobTimestamp - 50_000,
|
||||
create_time: now - 60_000,
|
||||
execution_start_time: now - 60_000,
|
||||
execution_end_time: now - 50_000,
|
||||
outputs_count: 2
|
||||
}),
|
||||
createRouteMockJob({
|
||||
createMockJob({
|
||||
id: 'job-completed-2',
|
||||
status: 'completed',
|
||||
create_time: mockJobTimestamp - 120_000,
|
||||
execution_start_time: mockJobTimestamp - 120_000,
|
||||
execution_end_time: mockJobTimestamp - 115_000,
|
||||
create_time: now - 120_000,
|
||||
execution_start_time: now - 120_000,
|
||||
execution_end_time: now - 115_000,
|
||||
outputs_count: 1
|
||||
}),
|
||||
createRouteMockJob({
|
||||
createMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: mockJobTimestamp - 30_000,
|
||||
execution_start_time: mockJobTimestamp - 30_000,
|
||||
execution_end_time: mockJobTimestamp - 28_000,
|
||||
create_time: now - 30_000,
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createRouteMockJob({
|
||||
createMockJob({
|
||||
id: 'job-failed-bottom',
|
||||
status: 'failed',
|
||||
create_time: mockJobTimestamp - 180_000,
|
||||
execution_start_time: mockJobTimestamp - 180_000,
|
||||
execution_end_time: mockJobTimestamp - 178_000,
|
||||
create_time: now - 180_000,
|
||||
execution_start_time: now - 180_000,
|
||||
execution_end_time: now - 178_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsScenario({ history: MOCK_JOBS, queue: [] })
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
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, templateApi }) => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -26,37 +26,53 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await templateApi.mockThumbnails()
|
||||
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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -70,38 +86,45 @@ test.describe(
|
||||
})
|
||||
|
||||
test('filtered count reflects distribution + model filter together', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -121,29 +144,36 @@ test.describe(
|
||||
})
|
||||
|
||||
test('desktop-only templates never leak into DOM on cloud distribution', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -170,21 +200,28 @@ test.describe(
|
||||
})
|
||||
|
||||
test('templates without includeOnDistributions are visible on cloud', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -197,32 +234,39 @@ test.describe(
|
||||
})
|
||||
|
||||
test('clear filters button resets to correct distribution-filtered total', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 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 |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.8",
|
||||
"version": "1.45.7",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -51,67 +51,4 @@ describe('groupNodesByPack', () => {
|
||||
grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('slugifies pack ids to lowercase, hyphen-only URL slugs', () => {
|
||||
const grouped = groupNodesByPack({
|
||||
A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'),
|
||||
B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes'),
|
||||
C: makeNodeDef('C', 'custom_nodes.ComfyUI_yanc.nodes')
|
||||
})
|
||||
|
||||
expect(grouped.map((pack) => pack.id)).toEqual([
|
||||
'basic-data-handling',
|
||||
'comfyui-crystools',
|
||||
'comfyui-yanc'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the raw upstream id for registry lookups', () => {
|
||||
const grouped = groupNodesByPack({
|
||||
A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'),
|
||||
B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes')
|
||||
})
|
||||
|
||||
expect(grouped.find((pack) => pack.id === 'comfyui-crystools')?.rawId).toBe(
|
||||
'ComfyUI-Crystools'
|
||||
)
|
||||
expect(
|
||||
grouped.find((pack) => pack.id === 'basic-data-handling')?.rawId
|
||||
).toBe('basic_data_handling')
|
||||
})
|
||||
|
||||
it('merges packs whose raw ids slugify to the same URL slug', () => {
|
||||
const grouped = groupNodesByPack({
|
||||
QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'),
|
||||
QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI_QwenVL.nodes')
|
||||
})
|
||||
|
||||
expect(grouped).toHaveLength(1)
|
||||
expect(grouped[0].id).toBe('comfyui-qwenvl')
|
||||
expect(grouped[0].nodes.map((n) => n.className).sort()).toEqual([
|
||||
'QwenA',
|
||||
'QwenB'
|
||||
])
|
||||
expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL', 'ComfyUI_QwenVL'])
|
||||
})
|
||||
|
||||
it('does not record duplicate aliases when the same raw id appears twice', () => {
|
||||
const grouped = groupNodesByPack({
|
||||
QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'),
|
||||
QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI-QwenVL.nodes')
|
||||
})
|
||||
|
||||
expect(grouped).toHaveLength(1)
|
||||
expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL'])
|
||||
})
|
||||
|
||||
it('strips version suffix before slugifying', () => {
|
||||
const grouped = groupNodesByPack({
|
||||
A: makeNodeDef('A', 'custom_nodes.ComfyUI_yanc@1_0_3.nodes')
|
||||
})
|
||||
|
||||
expect(grouped).toHaveLength(1)
|
||||
expect(grouped[0].id).toBe('comfyui-yanc')
|
||||
expect(grouped[0].rawId).toBe('ComfyUI_yanc')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { slugifyPackId } from '../helpers/slugifyPackId'
|
||||
|
||||
describe('slugifyPackId', () => {
|
||||
it.for([
|
||||
['comfyui-impact-pack', 'comfyui-impact-pack'],
|
||||
['ComfyUI-Crystools', 'comfyui-crystools'],
|
||||
['comfyui_impact_pack', 'comfyui-impact-pack'],
|
||||
['ComfyUI_QwenVL', 'comfyui-qwenvl'],
|
||||
['basic_data_handling', 'basic-data-handling'],
|
||||
['ComfyUI_Step1X-Edit', 'comfyui-step1x-edit'],
|
||||
['HunyuanVideo_Foley', 'hunyuanvideo-foley']
|
||||
])('slugifies %s -> %s', ([input, expected]) => {
|
||||
expect(slugifyPackId(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('collapses runs of hyphens introduced by adjacent separators', () => {
|
||||
expect(slugifyPackId('a__b')).toBe('a-b')
|
||||
expect(slugifyPackId('a-_b')).toBe('a-b')
|
||||
expect(slugifyPackId('a___-_b')).toBe('a-b')
|
||||
})
|
||||
|
||||
it('strips leading and trailing separators', () => {
|
||||
expect(slugifyPackId('_pack_')).toBe('pack')
|
||||
expect(slugifyPackId('-pack-')).toBe('pack')
|
||||
expect(slugifyPackId('__a__')).toBe('a')
|
||||
})
|
||||
|
||||
it('produces URL-slug-safe output for every registry id observed today', () => {
|
||||
const samples = [
|
||||
'ComfyUI-AniPortrait',
|
||||
'comfyui_aniportrait',
|
||||
'ComfyUI-API-Manager',
|
||||
'ComfyUI_API_Manager',
|
||||
'comfy-oiio',
|
||||
'comfy_oiio',
|
||||
'ComfyUI-FlashVSR_Ultra_Fast',
|
||||
'comfyui-frame-interpolation',
|
||||
'Qwen3_TTS',
|
||||
'qwen3-tts'
|
||||
]
|
||||
for (const sample of samples) {
|
||||
expect(slugifyPackId(sample)).toMatch(/^[a-z0-9-]+$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the input unchanged when already a clean slug', () => {
|
||||
expect(slugifyPackId('comfyui-impact-pack')).toBe('comfyui-impact-pack')
|
||||
expect(slugifyPackId('rgthree-comfy')).toBe('rgthree-comfy')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
import { slugifyPackId } from './slugifyPackId'
|
||||
|
||||
export interface PackedNode {
|
||||
className: string
|
||||
@@ -9,27 +8,10 @@ export interface PackedNode {
|
||||
|
||||
export interface NodePack {
|
||||
id: string
|
||||
rawId: string
|
||||
rawIds: string[]
|
||||
displayName: string
|
||||
nodes: PackedNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group custom Comfy node definitions into packs keyed by a slugified pack identifier.
|
||||
*
|
||||
* Processes the provided node definitions, selects those identified as custom nodes, extracts
|
||||
* a raw pack identifier from each definition's `python_module`, converts it to a slug, and
|
||||
* aggregates nodes that share the same slug into a single `NodePack`.
|
||||
*
|
||||
* @param defs - Map of class name to `ComfyNodeDef` objects to be grouped
|
||||
* @returns An array of `NodePack` objects sorted by `id` (ascending). Each `NodePack` includes:
|
||||
* - `id`: the slugified pack identifier
|
||||
* - `rawId`: the raw identifier extracted from a representative node's `python_module`
|
||||
* - `rawIds`: all distinct raw identifiers that were mapped to the same slug
|
||||
* - `displayName`: display text taken from the node source metadata
|
||||
* - `nodes`: the list of packed node entries (`{ className, def }`)
|
||||
*/
|
||||
export function groupNodesByPack(
|
||||
defs: Record<string, ComfyNodeDef>
|
||||
): NodePack[] {
|
||||
@@ -41,31 +23,21 @@ export function groupNodesByPack(
|
||||
continue
|
||||
}
|
||||
|
||||
const rawId = def.python_module.split('.')[1]?.split('@')[0]
|
||||
if (!rawId) {
|
||||
const packId = def.python_module.split('.')[1]?.split('@')[0]
|
||||
if (!packId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = slugifyPackId(rawId)
|
||||
if (!slug) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = byPackId.get(slug)
|
||||
const existing = byPackId.get(packId)
|
||||
const node = { className, def }
|
||||
|
||||
if (existing) {
|
||||
existing.nodes.push(node)
|
||||
if (!existing.rawIds.includes(rawId)) {
|
||||
existing.rawIds.push(rawId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
byPackId.set(slug, {
|
||||
id: slug,
|
||||
rawId,
|
||||
rawIds: [rawId],
|
||||
byPackId.set(packId, {
|
||||
id: packId,
|
||||
displayName: source.displayText,
|
||||
nodes: [node]
|
||||
})
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Normalize a custom-node pack identifier into a URL-safe slug.
|
||||
*
|
||||
* Pack ids originate from Python module names exposed by ComfyUI and the
|
||||
* Comfy custom-node registry. The upstream names mix three conventions
|
||||
* freely: kebab-case (`comfyui-impact-pack`), snake_case
|
||||
* (`comfyui_impact_pack`), and PascalCase (`ComfyUI-Crystools`). Using
|
||||
* those raw strings as URL segments produces routes that are inconsistent
|
||||
* across packs and fail the website's `[a-z0-9-]+` slug contract.
|
||||
*
|
||||
* `slugifyPackId` produces a deterministic, lowercase, hyphen-only slug
|
||||
* suitable for use as a URL segment and as an `Astro.params` value. It
|
||||
* does NOT replace the raw id used for registry lookups; callers that
|
||||
* need to query the registry API must keep the raw `node_id` separately.
|
||||
*
|
||||
* The transformation is intentionally narrow:
|
||||
* - lowercase
|
||||
* - replace `_` with `-`
|
||||
* - collapse runs of `-` to a single `-`
|
||||
* - strip leading / trailing `-`
|
||||
*
|
||||
* Any other character (digits, letters, `-`) is preserved verbatim so
|
||||
* legitimate registry ids like `comfyui-flashvsr-ultra-fast` survive
|
||||
* untouched. The output is guaranteed to match `/^[a-z0-9-]+$/` as long
|
||||
* as the input only contains ASCII letters, digits, `_`, and `-` — which
|
||||
* is the case for every pack id observed in the registry today.
|
||||
*/
|
||||
export function slugifyPackId(rawId: string): string {
|
||||
return rawId
|
||||
.toLowerCase()
|
||||
.replace(/_/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
@@ -2,4 +2,3 @@ export * from './schemas/nodeDefSchema'
|
||||
export * from './classifiers/nodeSource'
|
||||
export * from './helpers/groupNodesByPack'
|
||||
export * from './helpers/sanitizeUserContent'
|
||||
export * from './helpers/slugifyPackId'
|
||||
|
||||
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.3.0
|
||||
'@tailwindcss/vite': ^4.2.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.3.0
|
||||
tailwindcss: ^4.2.0
|
||||
three: ^0.170.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
|
||||
@@ -16,6 +16,14 @@ 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',
|
||||
@@ -24,29 +32,8 @@ vi.mock('primevue/selectbutton', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
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('primevue/divider', () => ({
|
||||
default: { name: 'Divider', template: '<hr />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
|
||||
|
||||
@@ -1,111 +1,72 @@
|
||||
<template>
|
||||
<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"
|
||||
<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 }"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { ref, useId, watch } from 'vue'
|
||||
import { computed, ref, 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 { initialIcon, initialColor } = defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
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 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 visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
@@ -134,22 +95,30 @@ const defaultIcon = iconOptions.find(
|
||||
)
|
||||
|
||||
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
|
||||
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
|
||||
const finalColor = ref(
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
)
|
||||
|
||||
const resetCustomization = () => {
|
||||
selectedIcon.value =
|
||||
iconOptions.find((option) => option.value === initialIcon) ?? iconOptions[0]
|
||||
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ??
|
||||
iconOptions[0]
|
||||
finalColor.value =
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
}
|
||||
|
||||
const confirmCustomization = () => {
|
||||
emit('confirm', selectedIcon.value.value, finalColor.value)
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(newValue) => {
|
||||
() => props.modelValue,
|
||||
(newValue: boolean) => {
|
||||
if (newValue) {
|
||||
resetCustomization()
|
||||
}
|
||||
@@ -166,4 +135,10 @@ 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-1/2 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-x-[-50%] translate-y-[-50%] 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="h-full scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable overflow-y-auto [overflow-anchor:none]"
|
||||
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface) h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<AsyncSearchInput
|
||||
<FormSearchInput
|
||||
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 AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.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">
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="h-[400px] w-full max-w-[80vw] overflow-auto">
|
||||
<Divider />
|
||||
<ScrollPanel class="h-[400px] w-full max-w-[80vw]">
|
||||
<pre class="wrap-break-word whitespace-pre-wrap">{{
|
||||
reportContent
|
||||
}}</pre>
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</template>
|
||||
<div class="flex justify-end gap-4">
|
||||
<FindIssueButton
|
||||
@@ -62,6 +62,8 @@
|
||||
</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'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none size-6.25 fill-current"
|
||||
class="pointer-events-none h-6.25 w-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 size-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
class="cls-1"
|
||||
@@ -44,7 +44,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="-6 -7 15 15"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none h-6.25 w-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 size-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none h-6.25 w-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 size-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none h-6.25 w-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 size-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none h-6.25 w-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"
|
||||
|
||||
@@ -370,7 +370,7 @@ function handleTitleCancel() {
|
||||
</section>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="flex-1 scrollbar-thin overflow-y-auto">
|
||||
<div class="scrollbar-thin flex-1 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: {
|
||||
AsyncSearchInput: {
|
||||
FormSearchInput: {
|
||||
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"
|
||||
>
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<FormSearchInput 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 AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.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 AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.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"
|
||||
>
|
||||
<AsyncSearchInput
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="favoritedWidgets"
|
||||
|
||||
@@ -7,7 +7,7 @@ import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseTog
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
|
||||
@@ -78,7 +78,7 @@ async function searcher(query: string) {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<AsyncSearchInput
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
@@ -122,7 +122,7 @@ const advancedLabel = computed(() => {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<AsyncSearchInput
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
|
||||
@@ -17,7 +17,7 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -217,7 +217,7 @@ const label = computed(() => {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<AsyncSearchInput
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsList"
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -226,7 +226,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<div v-if="activeNode" class="subgraph-edit-section flex h-full flex-col">
|
||||
<div class="flex gap-2 border-b border-interface-stroke px-4 pt-1 pb-4">
|
||||
<AsyncSearchInput v-model="searchQuery" />
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="mx-[-2px] text-lg leading-none"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
|
||||
@@ -23,7 +23,7 @@ defineExpose({
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-16 w-full scrollbar-gutter-stable rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
'flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
|
||||
@@ -23,19 +23,8 @@ import type { CanvasPointerEvent } from './types/events'
|
||||
* - {@link LGraphCanvas.processMouseUp}
|
||||
*/
|
||||
export class CanvasPointer {
|
||||
/**
|
||||
* Maximum time in milliseconds to ignore click drift.
|
||||
*
|
||||
* This is the upper bound on how long after pointerdown the system will wait
|
||||
* before deciding "this is a drag, not a click" when the pointer hasn't moved
|
||||
* past {@link maxClickDrift}. Keep this short — drags should feel instant.
|
||||
* Disambiguation between click and drag is primarily handled by distance
|
||||
* ({@link maxClickDrift}); this time threshold only matters when the user
|
||||
* holds the pointer still then releases. ~2 frames at 60fps is plenty.
|
||||
*
|
||||
* Overridden at runtime by the `Comfy.Pointer.ClickBufferTime` user setting.
|
||||
*/
|
||||
static bufferTime = 32
|
||||
/** Maximum time in milliseconds to ignore click drift */
|
||||
static bufferTime = 150
|
||||
|
||||
/** Maximum gap between pointerup and pointerdown events to be considered as a double click */
|
||||
static doubleClickTime = 300
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
|
||||
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
|
||||
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
|
||||
"promptExecutionError": "فشل تنفيذ الطلب",
|
||||
"queueOpenWorkflowFailedTitle": "فشل في فتح سير العمل"
|
||||
"promptExecutionError": "فشل تنفيذ الطلب"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطأ | {count} أخطاء",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "شعار ComfyOrg",
|
||||
"comfyPackageOutdated": "إصدار {name} المثبت ({installedVersion}) أقل من الإصدار المطلوب ({requiredVersion}).",
|
||||
"comingSoon": "قريباً",
|
||||
"command": "أمر",
|
||||
"commandProhibited": "الأمر {command} محظور. يرجى التواصل مع المسؤول لمزيد من المعلومات.",
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "Pointer click drift delay",
|
||||
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.\n\nThe distance threshold (Pointer click drift) already disambiguates clicks from drags; this time threshold only matters when the pointer is held still then released. A long delay here forces every pointerdown to wait before drag begins, which feels laggy when click+dragging an unselected node. ~2 frames at 60fps is plenty."
|
||||
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking."
|
||||
},
|
||||
"Comfy_Pointer_ClickDrift": {
|
||||
"name": "Pointer click drift (maximum distance)",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "Esto puede deberse al siguiente script",
|
||||
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
|
||||
"noStackTrace": "No hay seguimiento de pila disponible",
|
||||
"promptExecutionError": "La ejecución del prompt falló",
|
||||
"queueOpenWorkflowFailedTitle": "No se pudo abrir el flujo de trabajo"
|
||||
"promptExecutionError": "La ejecución del prompt falló"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERROR | {count} ERRORES",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Logo de ComfyOrg",
|
||||
"comfyPackageOutdated": "La versión instalada de {name} ({installedVersion}) es inferior a la versión requerida ({requiredVersion}).",
|
||||
"comingSoon": "Próximamente",
|
||||
"command": "Comando",
|
||||
"commandProhibited": "El comando {command} está prohibido. Contacta a un administrador para más información.",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
|
||||
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد دادههای workflow متوقف شد",
|
||||
"noStackTrace": "هیچ stacktraceی موجود نیست",
|
||||
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
|
||||
"queueOpenWorkflowFailedTitle": "باز کردن workflow با خطا مواجه شد"
|
||||
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطا",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
|
||||
"comfyPackageOutdated": "نسخه نصبشده {name} با شماره {installedVersion} پایینتر از نسخه مورد نیاز {requiredVersion} است.",
|
||||
"comingSoon": "بهزودی",
|
||||
"command": "دستور",
|
||||
"commandProhibited": "دستور {command} مجاز نیست. برای اطلاعات بیشتر با مدیر تماس بگیرید.",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "Cela peut être dû au script suivant",
|
||||
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
|
||||
"noStackTrace": "Aucune trace de pile disponible",
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué",
|
||||
"queueOpenWorkflowFailedTitle": "Échec de l'ouverture du workflow"
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERREUR | {count} ERREURS",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Logo ComfyOrg",
|
||||
"comfyPackageOutdated": "La version installée de {name} ({installedVersion}) est inférieure à la version requise ({requiredVersion}).",
|
||||
"comingSoon": "Bientôt disponible",
|
||||
"command": "Commande",
|
||||
"commandProhibited": "La commande {command} est interdite. Contactez un administrateur pour plus d'informations.",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
|
||||
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
|
||||
"noStackTrace": "スタックトレースは利用できません",
|
||||
"promptExecutionError": "プロンプトの実行に失敗しました",
|
||||
"queueOpenWorkflowFailedTitle": "ワークフローのオープンに失敗しました"
|
||||
"promptExecutionError": "プロンプトの実行に失敗しました"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} 件のエラー",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrgロゴ",
|
||||
"comfyPackageOutdated": "インストールされている{name}のバージョン{installedVersion}は、必要なバージョン{requiredVersion}よりも低いです。",
|
||||
"comingSoon": "近日公開",
|
||||
"command": "コマンド",
|
||||
"commandProhibited": "コマンド {command} は禁止されています。詳細は管理者にお問い合わせください。",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
|
||||
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
|
||||
"noStackTrace": "스택 추적을 사용할 수 없습니다",
|
||||
"promptExecutionError": "프롬프트 실행 실패",
|
||||
"queueOpenWorkflowFailedTitle": "워크플로우 열기에 실패했습니다"
|
||||
"promptExecutionError": "프롬프트 실행 실패"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count}개 오류",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 로고",
|
||||
"comfyPackageOutdated": "설치된 {name} 버전 {installedVersion}이(가) 필요한 버전 {requiredVersion}보다 낮습니다.",
|
||||
"comingSoon": "곧 출시 예정",
|
||||
"command": "명령",
|
||||
"commandProhibited": "{command}는 금지된 명령입니다. 자세한 정보는 관리자에게 문의하십시오.",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "Isso pode ser devido ao seguinte script",
|
||||
"loadWorkflowTitle": "Carregamento abortado devido a erro ao recarregar os dados do fluxo de trabalho",
|
||||
"noStackTrace": "Nenhum stacktrace disponível",
|
||||
"promptExecutionError": "Falha na execução do prompt",
|
||||
"queueOpenWorkflowFailedTitle": "Falha ao abrir o workflow"
|
||||
"promptExecutionError": "Falha na execução do prompt"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRO | {count} ERROS",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Logo do ComfyOrg",
|
||||
"comfyPackageOutdated": "A versão instalada de {name} ({installedVersion}) é inferior à versão necessária ({requiredVersion}).",
|
||||
"comingSoon": "Em breve",
|
||||
"command": "Comando",
|
||||
"commandProhibited": "O comando {command} é proibido. Entre em contato com um administrador para mais informações.",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "Это может быть связано со следующим скриптом",
|
||||
"loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса",
|
||||
"noStackTrace": "Стек вызовов недоступен",
|
||||
"promptExecutionError": "Ошибка выполнения запроса",
|
||||
"queueOpenWorkflowFailedTitle": "Не удалось открыть рабочий процесс"
|
||||
"promptExecutionError": "Ошибка выполнения запроса"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Логотип ComfyOrg",
|
||||
"comfyPackageOutdated": "Установленная версия {name} ({installedVersion}) ниже требуемой версии ({requiredVersion}).",
|
||||
"comingSoon": "Скоро будет",
|
||||
"command": "Команда",
|
||||
"commandProhibited": "Команда {command} запрещена. Свяжитесь с администратором для получения дополнительной информации.",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "Bu, aşağıdaki komut dosyasından kaynaklanıyor olabilir",
|
||||
"loadWorkflowTitle": "İş akışı verileri yeniden yüklenirken hata nedeniyle yükleme iptal edildi",
|
||||
"noStackTrace": "Yığın izi mevcut değil",
|
||||
"promptExecutionError": "İstem yürütmesi başarısız oldu",
|
||||
"queueOpenWorkflowFailedTitle": "İş Akışı Açılamadı"
|
||||
"promptExecutionError": "İstem yürütmesi başarısız oldu"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} HATA | {count} HATA",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg Logosu",
|
||||
"comfyPackageOutdated": "Yüklü {name} sürümü ({installedVersion}), gerekli sürümden ({requiredVersion}) daha eski.",
|
||||
"comingSoon": "Çok Yakında",
|
||||
"command": "Komut",
|
||||
"commandProhibited": "{command} komutu yasak. Daha fazla bilgi için bir yöneticiyle iletişime geçin.",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "這可能是由於以下指令碼所致",
|
||||
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",
|
||||
"noStackTrace": "沒有可用的堆疊追蹤",
|
||||
"promptExecutionError": "提示執行失敗",
|
||||
"queueOpenWorkflowFailedTitle": "開啟工作流程失敗"
|
||||
"promptExecutionError": "提示執行失敗"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} 個錯誤",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 標誌",
|
||||
"comfyPackageOutdated": "已安裝的 {name} 版本 {installedVersion} 低於所需版本 {requiredVersion}。",
|
||||
"comingSoon": "即將推出",
|
||||
"command": "指令",
|
||||
"commandProhibited": "指令 {command} 已被禁止。如需更多資訊,請聯絡管理員。",
|
||||
|
||||
@@ -913,8 +913,7 @@
|
||||
"extensionFileHint": "这可能是由于以下脚本",
|
||||
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
|
||||
"noStackTrace": "无可用堆栈跟踪",
|
||||
"promptExecutionError": "提示执行失败",
|
||||
"queueOpenWorkflowFailedTitle": "打开工作流失败"
|
||||
"promptExecutionError": "提示执行失败"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count}个错误",
|
||||
@@ -1042,7 +1041,6 @@
|
||||
"comfy": "舒适",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 徽标",
|
||||
"comfyPackageOutdated": "已安装的 {name} 版本 {installedVersion} 低于所需版本 {requiredVersion}。",
|
||||
"comingSoon": "即将推出",
|
||||
"command": "指令",
|
||||
"commandProhibited": "命令 {command} 被禁止。请联系管理员了解更多信息。",
|
||||
|
||||
@@ -1,51 +1,48 @@
|
||||
<template>
|
||||
<Dialog v-model:open="isVisible">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="bg-black/70" />
|
||||
<DialogContent
|
||||
size="full"
|
||||
class="w-[90vw] border-0 bg-transparent p-0 shadow-none"
|
||||
:aria-label="ariaLabel"
|
||||
@escape-key-down="onEscapeKeyDown"
|
||||
<Dialog
|
||||
v-model:visible="isVisible"
|
||||
modal
|
||||
:closable="false"
|
||||
:close-on-escape="false"
|
||||
:dismissable-mask="true"
|
||||
:pt="{
|
||||
root: { class: 'video-help-dialog' },
|
||||
header: { class: '!hidden' },
|
||||
content: { class: '!p-0' },
|
||||
mask: { class: '!bg-black/70' }
|
||||
}"
|
||||
:style="{ width: '90vw' }"
|
||||
>
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="absolute top-4 right-6 z-10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="isVisible = false"
|
||||
>
|
||||
<VisuallyHidden as-child>
|
||||
<DialogTitle>{{ ariaLabel }}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="absolute top-4 right-6 z-10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="isVisible = false"
|
||||
>
|
||||
<i class="pi pi-times text-sm" />
|
||||
</Button>
|
||||
<video
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
:aria-label="ariaLabel"
|
||||
class="w-full rounded-lg"
|
||||
:src="videoUrl"
|
||||
>
|
||||
{{ $t('g.videoFailedToLoad') }}
|
||||
</video>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
<i class="pi pi-times text-sm" />
|
||||
</Button>
|
||||
<video
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
:aria-label="ariaLabel"
|
||||
class="w-full rounded-lg"
|
||||
:src="videoUrl"
|
||||
>
|
||||
{{ $t('g.videoFailedToLoad') }}
|
||||
</video>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VisuallyHidden } from 'reka-ui'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { onWatcherCleanup, watch } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.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'
|
||||
|
||||
const isVisible = defineModel<boolean>({ required: true })
|
||||
|
||||
@@ -54,13 +51,27 @@ const { videoUrl, ariaLabel = 'Help video' } = defineProps<{
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
// The dialog mounts inside other dialogs (e.g. UploadModelFooter inside an
|
||||
// asset modal). Reka's Escape handling bubbles to the parent dialog and would
|
||||
// close it as well. Stop propagation so only this dialog closes, and prevent
|
||||
// Reka's default auto-dismiss so the close path stays solely under the model.
|
||||
function onEscapeKeyDown(event: KeyboardEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
isVisible.value = false
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopImmediatePropagation()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
isVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add listener with capture phase to intercept before parent dialogs
|
||||
// Only active when dialog is visible
|
||||
watch(
|
||||
isVisible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
const stop = useEventListener(document, 'keydown', handleEscapeKey, {
|
||||
capture: true
|
||||
})
|
||||
onWatcherCleanup(stop)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
rows="3"
|
||||
:class="
|
||||
cn(
|
||||
'w-full resize-y scrollbar-gutter-stable rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
|
||||
'w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
|
||||
isImmutable && 'cursor-not-allowed'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="ml-[-20%] h-full min-w-5/4 object-cover p-0"
|
||||
class="-ml-[20%] h-full min-w-5/4 object-cover p-0"
|
||||
>
|
||||
<source
|
||||
src="/assets/images/cloud-subscription.webm"
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SecretFormDialog from './SecretFormDialog.vue'
|
||||
|
||||
vi.mock('../composables/useSecretForm', () => ({
|
||||
useSecretForm: () => ({
|
||||
form: { provider: '', name: '', secretValue: '' },
|
||||
errors: {},
|
||||
loading: false,
|
||||
apiError: '',
|
||||
providerOptions: [],
|
||||
handleSubmit: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/inputtext', () => ({
|
||||
default: { name: 'InputText', template: '<input />' }
|
||||
}))
|
||||
vi.mock('primevue/password', () => ({
|
||||
default: { name: 'Password', template: '<input type="password" />' }
|
||||
}))
|
||||
|
||||
let capturedPointerDownOutside: ((event: Event) => void) | null = null
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: { name: 'Button', template: '<button><slot /></button>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/Select.vue', () => ({
|
||||
default: { name: 'Select', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', () => ({
|
||||
default: { name: 'SelectContent', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: { name: 'SelectItem', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
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: defineComponent({
|
||||
name: 'DialogContent',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs }) {
|
||||
const onPointerDownOutside = (attrs as Record<string, unknown>)[
|
||||
'onPointerDownOutside'
|
||||
] as ((event: Event) => void) | undefined
|
||||
capturedPointerDownOutside = onPointerDownOutside ?? null
|
||||
},
|
||||
template: '<div data-testid="dialog-content"><slot /></div>'
|
||||
})
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
|
||||
default: { name: 'DialogHeader', 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 />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
describe('SecretFormDialog', () => {
|
||||
beforeEach(() => {
|
||||
capturedPointerDownOutside = null
|
||||
})
|
||||
|
||||
it('prevents backdrop pointer-down-outside from closing the dialog', () => {
|
||||
render(SecretFormDialog, {
|
||||
global: { plugins: [i18n] },
|
||||
props: { visible: true }
|
||||
})
|
||||
|
||||
expect(capturedPointerDownOutside).not.toBeNull()
|
||||
const event = new CustomEvent('pointerDownOutside', { cancelable: true })
|
||||
capturedPointerDownOutside!(event)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,130 +1,106 @@
|
||||
<template>
|
||||
<Dialog v-model:open="visible">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
size="md"
|
||||
:aria-labelledby="titleId"
|
||||
@pointer-down-outside.prevent
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle :id="titleId">
|
||||
{{
|
||||
mode === 'create'
|
||||
? $t('secrets.addSecret')
|
||||
: $t('secrets.editSecret')
|
||||
}}
|
||||
</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="flex flex-col gap-4 px-4 py-2"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-provider" class="text-sm font-medium">
|
||||
{{ $t('secrets.provider') }}
|
||||
</label>
|
||||
<Select v-model="form.provider" :disabled="mode === 'edit'">
|
||||
<SelectTrigger id="secret-provider" class="w-full" autofocus>
|
||||
<SelectValue :placeholder="$t('g.none')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent disable-portal>
|
||||
<SelectItem
|
||||
v-for="option in providerOptions"
|
||||
:key="option.value || 'none'"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<small v-if="errors.provider" class="text-red-500">
|
||||
{{ errors.provider }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-name" class="text-sm font-medium">
|
||||
{{ $t('secrets.name') }}
|
||||
</label>
|
||||
<InputText
|
||||
id="secret-name"
|
||||
v-model="form.name"
|
||||
:placeholder="$t('secrets.namePlaceholder')"
|
||||
:class="{ 'p-invalid': errors.name }"
|
||||
/>
|
||||
<small v-if="errors.name" class="text-red-500">
|
||||
{{ errors.name }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-value" class="text-sm font-medium">
|
||||
{{ $t('secrets.secretValue') }}
|
||||
</label>
|
||||
<Password
|
||||
id="secret-value"
|
||||
v-model="form.secretValue"
|
||||
:placeholder="
|
||||
mode === 'edit'
|
||||
? $t('secrets.secretValuePlaceholderEdit')
|
||||
: $t('secrets.secretValuePlaceholder')
|
||||
"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
fluid
|
||||
:class="{ 'p-invalid': errors.secretValue }"
|
||||
/>
|
||||
<small v-if="errors.secretValue" class="text-red-500">
|
||||
{{ errors.secretValue }}
|
||||
</small>
|
||||
<small v-else class="text-muted">
|
||||
{{
|
||||
mode === 'edit'
|
||||
? $t('secrets.secretValueHintEdit')
|
||||
: $t('secrets.secretValueHint')
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<span v-if="apiError" class="text-destructive text-sm">
|
||||
{{ apiError }}
|
||||
</span>
|
||||
|
||||
<div class="flex justify-end gap-2 py-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
@click="visible = false"
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:header="
|
||||
mode === 'create' ? $t('secrets.addSecret') : $t('secrets.editSecret')
|
||||
"
|
||||
modal
|
||||
class="w-full max-w-md"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-provider" class="text-sm font-medium">
|
||||
{{ $t('secrets.provider') }}
|
||||
</label>
|
||||
<Select v-model="form.provider" :disabled="mode === 'edit'">
|
||||
<SelectTrigger id="secret-provider" class="w-full" autofocus>
|
||||
<SelectValue :placeholder="$t('g.none')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent disable-portal>
|
||||
<SelectItem
|
||||
v-for="option in providerOptions"
|
||||
:key="option.value || 'none'"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" tabindex="0" :loading="loading">
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<small v-if="errors.provider" class="text-red-500">
|
||||
{{ errors.provider }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-name" class="text-sm font-medium">
|
||||
{{ $t('secrets.name') }}
|
||||
</label>
|
||||
<InputText
|
||||
id="secret-name"
|
||||
v-model="form.name"
|
||||
:placeholder="$t('secrets.namePlaceholder')"
|
||||
:class="{ 'p-invalid': errors.name }"
|
||||
/>
|
||||
<small v-if="errors.name" class="text-red-500">{{ errors.name }}</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-value" class="text-sm font-medium">
|
||||
{{ $t('secrets.secretValue') }}
|
||||
</label>
|
||||
<Password
|
||||
id="secret-value"
|
||||
v-model="form.secretValue"
|
||||
:placeholder="
|
||||
mode === 'edit'
|
||||
? $t('secrets.secretValuePlaceholderEdit')
|
||||
: $t('secrets.secretValuePlaceholder')
|
||||
"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
fluid
|
||||
:class="{ 'p-invalid': errors.secretValue }"
|
||||
/>
|
||||
<small v-if="errors.secretValue" class="text-red-500">
|
||||
{{ errors.secretValue }}
|
||||
</small>
|
||||
<small v-else class="text-muted">
|
||||
{{
|
||||
mode === 'edit'
|
||||
? $t('secrets.secretValueHintEdit')
|
||||
: $t('secrets.secretValueHint')
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<span v-if="apiError" class="text-destructive text-sm">
|
||||
{{ apiError }}
|
||||
</span>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
@click="visible = false"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" tabindex="0" :loading="loading">
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import { useId } from '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 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 Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
@@ -150,8 +126,6 @@ const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const titleId = useId()
|
||||
|
||||
const { form, errors, loading, apiError, providerOptions, handleSubmit } =
|
||||
useSecretForm({
|
||||
mode,
|
||||
|
||||
@@ -803,17 +803,16 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
category: ['LiteGraph', 'Pointer', 'ClickBufferTime'],
|
||||
name: 'Pointer click drift delay',
|
||||
tooltip:
|
||||
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.\n\nThe distance threshold (Pointer click drift) already disambiguates clicks from drags; this time threshold only matters when the pointer is held still then released. A long delay here forces every pointerdown to wait before drag begins, which feels laggy when click+dragging an unselected node. ~2 frames at 60fps is plenty.',
|
||||
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.',
|
||||
experimental: true,
|
||||
type: 'slider',
|
||||
attrs: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 1
|
||||
step: 25
|
||||
},
|
||||
defaultValue: 32,
|
||||
versionAdded: '1.4.3',
|
||||
versionModified: '1.44.19'
|
||||
defaultValue: 150,
|
||||
versionAdded: '1.4.3'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Pointer.DoubleClickTime',
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
cn(
|
||||
'pointer-events-none absolute z-0 border-3 outline-none',
|
||||
selectionShapeClass,
|
||||
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
|
||||
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
|
||||
isSelected
|
||||
? 'border-node-component-outline'
|
||||
: 'border-node-stroke-executing'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { defineComponent, ref } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AsyncSearchInput from './AsyncSearchInput.vue'
|
||||
import FormSearchInput from './FormSearchInput.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -20,7 +20,7 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
type Searcher = NonNullable<ComponentProps<typeof AsyncSearchInput>['searcher']>
|
||||
type Searcher = NonNullable<ComponentProps<typeof FormSearchInput>['searcher']>
|
||||
|
||||
function renderSearch(
|
||||
initialQuery: string = '',
|
||||
@@ -30,9 +30,9 @@ function renderSearch(
|
||||
const query = ref(initialQuery)
|
||||
const key = updateKey
|
||||
const Harness = defineComponent({
|
||||
components: { AsyncSearchInput },
|
||||
components: { FormSearchInput },
|
||||
setup: () => ({ query, searcher, key }),
|
||||
template: `<AsyncSearchInput
|
||||
template: `<FormSearchInput
|
||||
v-model="query"
|
||||
:searcher="searcher"
|
||||
:update-key="key"
|
||||
@@ -42,7 +42,7 @@ function renderSearch(
|
||||
return { ...utils, query, key }
|
||||
}
|
||||
|
||||
describe('AsyncSearchInput', () => {
|
||||
describe('FormSearchInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
@@ -47,7 +47,7 @@ watch(
|
||||
|
||||
searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
|
||||
.catch((error) => {
|
||||
console.error('[AsyncSearchInput] searcher failed', error)
|
||||
console.error('[SidePanelSearch] searcher failed', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCleanup) isQuerying.value = false
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from '@/platform/assets/types/filterTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '../FormSearchInput.vue'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -99,7 +99,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
|
||||
<template>
|
||||
<div class="text-secondary flex gap-2 px-4">
|
||||
<AsyncSearchInput
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
autofocus
|
||||
:class="
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Dialog migration regression net: when callers in `dialogService` open a
|
||||
* Reka-migrated dialog, the dialog stack item must carry `renderer: 'reka'`.
|
||||
* Catches accidental reverts of the Reka renderer flip.
|
||||
* Phase 1 dialog migration regression net: when `dialogService.prompt()`,
|
||||
* `dialogService.confirm()`, or `dialogService.showBillingComingSoonDialog()`
|
||||
* is invoked, the dialog stack item must carry `renderer: 'reka'`. Catches
|
||||
* accidental reverts of the Reka renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -33,7 +34,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('dialogService Reka renderer opt-in', () => {
|
||||
describe('dialogService Reka renderer opt-in (Phase 1)', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
@@ -59,24 +60,4 @@ describe('dialogService Reka renderer opt-in', () => {
|
||||
expect(args.dialogComponentProps.size).toBe('sm')
|
||||
expect(args.dialogComponentProps.contentClass).toBe('max-w-[360px]')
|
||||
})
|
||||
|
||||
it("showExecutionErrorDialog() sets renderer 'reka' and size 'lg'", () => {
|
||||
useDialogService().showExecutionErrorDialog({
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'boom',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
traceback: ['line 1', 'line 2']
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('lg')
|
||||
})
|
||||
|
||||
it("showErrorDialog() sets renderer 'reka' and size 'lg'", () => {
|
||||
useDialogService().showErrorDialog(new Error('boom'))
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('lg')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,8 +99,6 @@ export const useDialogService = () => {
|
||||
component: ErrorDialogContent,
|
||||
props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'lg',
|
||||
onClose: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_closed'
|
||||
@@ -165,8 +163,6 @@ export const useDialogService = () => {
|
||||
component: ErrorDialogContent,
|
||||
props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'lg',
|
||||
onClose: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_closed'
|
||||
|
||||
@@ -5,16 +5,11 @@ import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
InputWidgetConfig,
|
||||
LinearInput,
|
||||
LoadedComfyWorkflow
|
||||
} from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
@@ -105,29 +100,6 @@ function createBuilderWorkflowWithOutputs(
|
||||
return workflow
|
||||
}
|
||||
|
||||
function createWorkflowWithLinearData(
|
||||
activeMode: string,
|
||||
inputs: LinearInput[],
|
||||
outputs: NodeId[]
|
||||
): LoadedComfyWorkflow {
|
||||
const workflow = createBuilderWorkflow(activeMode)
|
||||
workflow.changeTracker = createMockChangeTracker(
|
||||
fromPartial<Partial<ChangeTracker>>({
|
||||
activeState: {
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 0.4,
|
||||
extra: { linearData: fromAny({ inputs, outputs }) }
|
||||
}
|
||||
})
|
||||
)
|
||||
return workflow
|
||||
}
|
||||
|
||||
describe('appModeStore', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
let store: ReturnType<typeof useAppModeStore>
|
||||
@@ -135,7 +107,6 @@ describe('appModeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
mockSettings.reset()
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
|
||||
@@ -192,28 +163,6 @@ describe('appModeStore', () => {
|
||||
)
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('prunes selections from workflow state on entry', () => {
|
||||
const node1 = { id: 1 }
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
)
|
||||
workflowStore.activeWorkflow = createWorkflowWithLinearData(
|
||||
'graph',
|
||||
[
|
||||
[1, 'seed'],
|
||||
[99, 'steps']
|
||||
],
|
||||
[1, 99]
|
||||
)
|
||||
store.selectedInputs = [[42, 'prompt']]
|
||||
store.selectedOutputs = [42]
|
||||
|
||||
store.enterBuilder()
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'seed']])
|
||||
expect(store.selectedOutputs).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty workflow dialog callbacks', () => {
|
||||
@@ -253,36 +202,33 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('exitBuilder', () => {
|
||||
it('prunes selections from workflow state on exit', () => {
|
||||
const node1 = { id: 1 }
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
)
|
||||
workflowStore.activeWorkflow = createWorkflowWithLinearData(
|
||||
'builder:inputs',
|
||||
[
|
||||
[1, 'seed'],
|
||||
[99, 'steps']
|
||||
],
|
||||
[1, 99]
|
||||
)
|
||||
store.selectedInputs = [[42, 'prompt']]
|
||||
store.selectedOutputs = [42]
|
||||
|
||||
store.exitBuilder()
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'seed']])
|
||||
expect(store.selectedOutputs).toEqual([1])
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSelections pruning', () => {
|
||||
function mockNode(id: number) {
|
||||
return { id }
|
||||
}
|
||||
|
||||
function workflowWithLinearData(
|
||||
inputs: [number, string][],
|
||||
outputs: number[]
|
||||
) {
|
||||
const workflow = createBuilderWorkflow('app')
|
||||
workflow.changeTracker = createMockChangeTracker(
|
||||
fromPartial<Partial<ChangeTracker>>({
|
||||
activeState: {
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 0.4,
|
||||
extra: { linearData: { inputs, outputs } }
|
||||
}
|
||||
})
|
||||
)
|
||||
return workflow
|
||||
}
|
||||
|
||||
it('removes inputs referencing deleted nodes on load', async () => {
|
||||
const node1 = mockNode(1)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
@@ -348,11 +294,7 @@ describe('appModeStore', () => {
|
||||
// Initially nodes are not resolvable — pruning removes them
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
const inputs: [number, string][] = [[1, 'seed']]
|
||||
workflowStore.activeWorkflow = createWorkflowWithLinearData(
|
||||
'app',
|
||||
inputs,
|
||||
[1]
|
||||
)
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(inputs, [1])
|
||||
store.loadSelections({ inputs })
|
||||
await nextTick()
|
||||
|
||||
@@ -382,141 +324,6 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSelections edge cases', () => {
|
||||
it('clears existing selections on undefined or empty data', () => {
|
||||
store.selectedInputs = [[1, 'seed']]
|
||||
store.selectedOutputs = [1]
|
||||
|
||||
store.loadSelections(undefined)
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(store.selectedOutputs).toEqual([])
|
||||
|
||||
store.selectedInputs = [[1, 'seed']]
|
||||
store.selectedOutputs = [1]
|
||||
|
||||
store.loadSelections({})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(store.selectedOutputs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneLinearData', () => {
|
||||
it('returns empty selections for undefined data', () => {
|
||||
expect(store.pruneLinearData(undefined)).toEqual({
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
})
|
||||
|
||||
it('does not prune when rootGraph is empty', () => {
|
||||
const originalRootGraph = app.rootGraph
|
||||
Object.defineProperty(app, 'rootGraph', { value: null, writable: true })
|
||||
|
||||
try {
|
||||
expect(
|
||||
store.pruneLinearData({
|
||||
inputs: [[1, 'seed']],
|
||||
outputs: [1]
|
||||
})
|
||||
).toEqual({
|
||||
inputs: [[1, 'seed']],
|
||||
outputs: [1]
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneLinearData during graph loading', () => {
|
||||
it('preserves all entries when ChangeTracker.isLoadingGraph is true', () => {
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [
|
||||
[1, 'seed'],
|
||||
[999, 'steps']
|
||||
],
|
||||
outputs: [1, 999]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([
|
||||
[1, 'seed'],
|
||||
[999, 'steps']
|
||||
])
|
||||
expect(store.selectedOutputs).toEqual([1, 999])
|
||||
})
|
||||
|
||||
it('prunes entries for deleted nodes when not loading', () => {
|
||||
const node1 = { id: 1 }
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [
|
||||
[1, 'seed'],
|
||||
[999, 'steps']
|
||||
],
|
||||
outputs: [1, 999]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'seed']])
|
||||
expect(store.selectedOutputs).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetSelectedToWorkflow fallback', () => {
|
||||
it('falls back to initialState when activeState has no linearData', () => {
|
||||
const node1 = { id: 1 }
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
)
|
||||
const workflow = createBuilderWorkflow('app')
|
||||
workflow.changeTracker.activeState.extra = {}
|
||||
workflow.changeTracker.initialState = fromAny({
|
||||
...workflow.changeTracker.activeState,
|
||||
extra: {
|
||||
linearData: { inputs: [[1, 'seed']], outputs: [1] }
|
||||
}
|
||||
})
|
||||
workflowStore.activeWorkflow = workflow
|
||||
|
||||
store.resetSelectedToWorkflow()
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'seed']])
|
||||
expect(store.selectedOutputs).toEqual([1])
|
||||
})
|
||||
|
||||
it('prefers activeState linearData when available', () => {
|
||||
const node1 = { id: 1 }
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
)
|
||||
const workflow = createBuilderWorkflow('app')
|
||||
workflow.changeTracker.activeState.extra = {
|
||||
linearData: { inputs: [[1, 'steps']], outputs: [1] }
|
||||
}
|
||||
workflow.changeTracker.initialState = fromAny({
|
||||
...workflow.changeTracker.activeState,
|
||||
extra: {
|
||||
linearData: { inputs: [[1, 'seed']], outputs: [1] }
|
||||
}
|
||||
})
|
||||
workflowStore.activeWorkflow = workflow
|
||||
|
||||
store.resetSelectedToWorkflow()
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'steps']])
|
||||
expect(store.selectedOutputs).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('linearData sync watcher', () => {
|
||||
it('writes linearData to rootGraph.extra when in builder mode', async () => {
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow()
|
||||
@@ -546,22 +353,15 @@ describe('appModeStore', () => {
|
||||
await nextTick()
|
||||
|
||||
const originalRootGraph = app.rootGraph
|
||||
const dataBefore = JSON.parse(
|
||||
JSON.stringify(originalRootGraph.extra.linearData)
|
||||
)
|
||||
Object.defineProperty(app, 'rootGraph', { value: null, writable: true })
|
||||
|
||||
try {
|
||||
store.selectedOutputs.push(1)
|
||||
await nextTick()
|
||||
} finally {
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
store.selectedOutputs.push(1)
|
||||
await nextTick()
|
||||
|
||||
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('calls captureCanvasState when input is selected', async () => {
|
||||
@@ -628,18 +428,6 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
|
||||
})
|
||||
|
||||
it('merges existing config with new values', () => {
|
||||
const existingConfig: InputWidgetConfig & { width: number } = {
|
||||
height: 120,
|
||||
width: 240
|
||||
}
|
||||
store.selectedInputs.push([1, 'prompt', existingConfig])
|
||||
|
||||
store.updateInputConfig(1 as NodeId, 'prompt', { height: 300 })
|
||||
|
||||
expect(store.selectedInputs[0][2]).toEqual({ height: 300, width: 240 })
|
||||
})
|
||||
|
||||
it('triggers linearData sync watcher', async () => {
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow()
|
||||
store.selectedInputs.push([42, 'prompt'])
|
||||
@@ -655,17 +443,6 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSelectedInput', () => {
|
||||
it('removes the matching input entry only', () => {
|
||||
store.selectedInputs.push([1, 'prompt'])
|
||||
store.selectedInputs.push([2, 'steps'])
|
||||
|
||||
store.removeSelectedInput({ name: 'steps' } as IBaseWidget, { id: 2 })
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'prompt']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
it('enables Vue nodes when entering select mode with them disabled', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
|
||||
@@ -49,13 +49,14 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
|
||||
const rawInputs = data?.inputs ?? []
|
||||
const rawOutputs = data?.outputs ?? []
|
||||
if (!app.rootGraph || ChangeTracker.isLoadingGraph) {
|
||||
return { inputs: rawInputs, outputs: rawOutputs }
|
||||
}
|
||||
|
||||
return {
|
||||
inputs: rawInputs.filter(([nodeId]) => resolveNode(nodeId)),
|
||||
outputs: rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
inputs: app.rootGraph
|
||||
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
: rawInputs,
|
||||
outputs: app.rootGraph
|
||||
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
: rawOutputs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +70,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
const { activeWorkflow } = workflowStore
|
||||
if (!activeWorkflow) return
|
||||
|
||||
const source =
|
||||
activeWorkflow.changeTracker?.activeState?.extra?.linearData ??
|
||||
activeWorkflow.initialState?.extra?.linearData
|
||||
loadSelections(source)
|
||||
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
|
||||
}
|
||||
|
||||
useEventListener(
|
||||
|
||||