Compare commits
17 Commits
refactor/i
...
bl/fix-qpo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6968c3ab | ||
|
|
71092b2011 | ||
|
|
d05ec230bf | ||
|
|
d6f632477f | ||
|
|
1ab63807e9 | ||
|
|
e35bea51d6 | ||
|
|
f429e1e0c4 | ||
|
|
d6b4137eec | ||
|
|
b578147714 | ||
|
|
06e09df673 | ||
|
|
e972d658d3 | ||
|
|
f090ea3d28 | ||
|
|
daab936d15 | ||
|
|
f176d18fe0 | ||
|
|
01742672bb | ||
|
|
b7fe0365af | ||
|
|
bdb92c845e |
19
.github/actions/cloud-nodes-pull/action.yaml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Cloud Nodes Pull
|
||||
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
|
||||
inputs:
|
||||
api_key:
|
||||
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
|
||||
required: true
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
shell: bash
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
|
||||
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
9
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -106,19 +106,12 @@ jobs:
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
|
||||
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></body></html>" > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source
|
||||
--ignore-errors source,unmapped
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
11
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -58,6 +58,7 @@ jobs:
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
@@ -151,10 +152,20 @@ jobs:
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
|
||||
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
|
||||
37
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles and
|
||||
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
|
||||
# Vercel website production deploy via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshot:
|
||||
refresh-snapshots:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,28 +31,39 @@ jobs:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
uses: ./.github/actions/cloud-nodes-pull
|
||||
with:
|
||||
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
body: |
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
Automated refresh of remote-data snapshots used by the website
|
||||
build:
|
||||
|
||||
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
|
||||
board API
|
||||
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
|
||||
`/api/object_info`
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
2. This PR opens with the regenerated snapshots.
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
|
||||
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
|
||||
without the respective API keys continue to use the committed
|
||||
snapshot (with a warning annotation in CI).
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
branch: chore/refresh-website-snapshots-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
@@ -119,6 +119,44 @@ snapshots can't be accidentally committed.
|
||||
|
||||
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
|
||||
|
||||
### Production strictness
|
||||
|
||||
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
|
||||
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
|
||||
prevents the production deploy from silently shipping an out-of-date snapshot
|
||||
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
|
||||
and local builds continue to use the committed snapshot with a warning
|
||||
annotation.
|
||||
|
||||
### Required GitHub Actions / Vercel secrets
|
||||
|
||||
| Name | Where | Purpose |
|
||||
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
|
||||
|
||||
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
|
||||
`apps/website/src/data/cloud-nodes.snapshot.json` via
|
||||
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
|
||||
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
|
||||
`deploy-production` job hard-fails before `vercel build --prod` if the secret
|
||||
is missing.
|
||||
|
||||
### Refreshing the snapshot
|
||||
|
||||
To update the committed snapshot manually (e.g. after onboarding new packs
|
||||
to Comfy Cloud):
|
||||
|
||||
```bash
|
||||
WEBSITE_CLOUD_API_KEY=… \
|
||||
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
git commit apps/website/src/data/cloud-nodes.snapshot.json
|
||||
```
|
||||
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
|
||||
can't be accidentally committed. Otherwise the `Release: Website` GitHub
|
||||
Actions workflow runs the same step on every manual dispatch and opens a PR
|
||||
with the refreshed snapshot.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
|
||||
@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const contactColumn = {
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
@@ -91,6 +91,11 @@ const contactColumn = {
|
||||
href: externalLinks.support,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.cloudStatus', locale),
|
||||
href: externalLinks.cloudStatus,
|
||||
external: true
|
||||
},
|
||||
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const externalLinks = {
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
|
||||
@@ -1773,6 +1773,7 @@ const translations = {
|
||||
'footer.support': { en: 'Support', 'zh-CN': '支持' },
|
||||
'footer.sales': { en: 'Sales', 'zh-CN': '销售' },
|
||||
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
|
||||
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.location': {
|
||||
en: 'San Francisco, USA',
|
||||
|
||||
128
apps/website/src/utils/cloudNodes.build.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
const fetchCloudNodesMock = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<FetchOutcome>>()
|
||||
)
|
||||
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes', () => ({
|
||||
fetchCloudNodesForBuild: fetchCloudNodesMock
|
||||
}))
|
||||
|
||||
vi.mock('./cloudNodes.ci', () => ({
|
||||
reportCloudNodesOutcome: reportCloudNodesOutcomeMock
|
||||
}))
|
||||
|
||||
import { loadPacksForBuild } from './cloudNodes.build'
|
||||
|
||||
const SNAPSHOT: NodesSnapshot = {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs: [
|
||||
{
|
||||
id: 'snapshot-pack',
|
||||
displayName: 'Snapshot Pack',
|
||||
nodes: [
|
||||
{ name: 'SnapshotNode', displayName: 'Snapshot Node', category: 'x' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('loadPacksForBuild', () => {
|
||||
const savedVercelEnv = process.env.VERCEL_ENV
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCloudNodesMock.mockReset()
|
||||
reportCloudNodesOutcomeMock.mockReset()
|
||||
delete process.env.VERCEL_ENV
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (savedVercelEnv === undefined) {
|
||||
delete process.env.VERCEL_ENV
|
||||
return
|
||||
}
|
||||
process.env.VERCEL_ENV = savedVercelEnv
|
||||
})
|
||||
|
||||
it('returns packs when fetch is fresh', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'fresh',
|
||||
snapshot: SNAPSHOT,
|
||||
droppedCount: 0,
|
||||
droppedNodes: []
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns snapshot packs when outcome is stale outside production', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns snapshot packs when outcome is stale on Vercel preview', async () => {
|
||||
process.env.VERCEL_ENV = 'preview'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'HTTP 503'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when outcome is stale on Vercel production', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/stale data in a production build/
|
||||
)
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/missing WEBSITE_CLOUD_API_KEY/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when outcome is failed regardless of environment', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'failed',
|
||||
reason: 'network error: ECONNREFUSED'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/Cloud nodes fetch failed and no snapshot is available/
|
||||
)
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(/ECONNREFUSED/)
|
||||
})
|
||||
|
||||
it('still reports outcome before throwing on stale-in-production', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'HTTP 503'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow()
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,14 @@ import type { Pack } from '../data/cloudNodes'
|
||||
import { fetchCloudNodesForBuild } from './cloudNodes'
|
||||
import { reportCloudNodesOutcome } from './cloudNodes.ci'
|
||||
|
||||
const REFRESH_HINT =
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
|
||||
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
|
||||
|
||||
function isProductionBuild(): boolean {
|
||||
return process.env.VERCEL_ENV === 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the list of packs to render at build time.
|
||||
*
|
||||
@@ -11,6 +19,10 @@ import { reportCloudNodesOutcome } from './cloudNodes.ci'
|
||||
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
|
||||
* `inflight` promise, so repeated calls in the same build process share a
|
||||
* single network round-trip and the same outcome.
|
||||
*
|
||||
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
|
||||
* to prevent silently shipping out-of-date snapshot data. Preview and
|
||||
* local builds continue to use the committed snapshot.
|
||||
*/
|
||||
export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
@@ -18,8 +30,14 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
|
||||
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
)
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale' && isProductionBuild()) {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
|
||||
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'cloud-stable',
|
||||
title: 'Cloud Stable',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
|
||||
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'desktop-stable',
|
||||
title: 'Desktop Stable',
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
|
||||
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'local-stable',
|
||||
title: 'Local Stable',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
|
||||
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'unrestricted-stable',
|
||||
title: 'Unrestricted Stable'
|
||||
})
|
||||
|
||||
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
|
||||
STABLE_CLOUD_TEMPLATE,
|
||||
STABLE_DESKTOP_TEMPLATE,
|
||||
STABLE_LOCAL_TEMPLATE,
|
||||
STABLE_UNRESTRICTED_TEMPLATE
|
||||
]
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
const defaultJobsListLimit = 100
|
||||
|
||||
export type MockJobRecord = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
|
||||
const value = Number(url.searchParams.get(name))
|
||||
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class JobsApiMock {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private jobsById = new Map<string, MockJobRecord>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
|
||||
this.jobsById = new Map(
|
||||
jobs.map(
|
||||
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
|
||||
)
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobsById.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.jobsById.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
|
||||
const limit =
|
||||
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
|
||||
const total = filteredJobs.length
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.jobsById.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.jobsById = new Map(
|
||||
Array.from(this.jobsById).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.jobsById.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
198
browser_tests/fixtures/helpers/TemplateHelper.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
/**
|
||||
* Generate N deterministic templates, optionally restricted to a distribution.
|
||||
*
|
||||
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
|
||||
* for static test data with no executable fixture logic.
|
||||
*/
|
||||
function generateTemplates(
|
||||
count: number,
|
||||
distribution?: TemplateIncludeOnDistributionEnum
|
||||
): TemplateInfo[] {
|
||||
const slug = distribution ?? 'unrestricted'
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeTemplate({
|
||||
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `Generated ${slug} ${i + 1}`,
|
||||
...(distribution ? { includeOnDistributions: [distribution] } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
|
||||
function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
}
|
||||
|
||||
function cloneIndex(
|
||||
index: readonly WorkflowTemplates[] | null
|
||||
): WorkflowTemplates[] | null {
|
||||
return index ? index.map((m) => structuredClone(m)) : null
|
||||
}
|
||||
|
||||
function addTemplates(
|
||||
config: TemplateConfig,
|
||||
templates: TemplateInfo[]
|
||||
): TemplateConfig {
|
||||
return { ...config, templates: [...config.templates, ...templates] }
|
||||
}
|
||||
|
||||
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
return (config) => addTemplates(config, templates)
|
||||
}
|
||||
|
||||
export function withTemplate(template: TemplateInfo): TemplateOperator {
|
||||
return (config) => addTemplates(config, [template])
|
||||
}
|
||||
|
||||
export function withCloudTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
|
||||
)
|
||||
}
|
||||
|
||||
export function withDesktopTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
|
||||
)
|
||||
}
|
||||
|
||||
export function withLocalTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
|
||||
)
|
||||
}
|
||||
|
||||
export function withUnrestrictedTemplates(count: number): TemplateOperator {
|
||||
return (config) => addTemplates(config, generateTemplates(count))
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the index payload entirely. Useful when a test needs a custom
|
||||
* `WorkflowTemplates[]` shape (e.g. multiple modules).
|
||||
*/
|
||||
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
|
||||
return (config) => ({ ...config, index })
|
||||
}
|
||||
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: TemplateConfig = emptyConfig()
|
||||
) {
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
configure(...operators: TemplateOperator[]): void {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
const thumbnailHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
return cloneTemplates(this.templates)
|
||||
}
|
||||
|
||||
get templateCount(): number {
|
||||
return this.templates.length
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.templates = []
|
||||
this.index = null
|
||||
}
|
||||
}
|
||||
|
||||
export function createTemplateHelper(
|
||||
page: Page,
|
||||
...operators: TemplateOperator[]
|
||||
): TemplateHelper {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new TemplateHelper(page, config)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export const jobsApiMockFixture = base.extend<{
|
||||
jobsApi: JobsApiMock
|
||||
}>({
|
||||
jobsApi: async ({ page }, use) => {
|
||||
const jobsApi = new JobsApiMock(page)
|
||||
|
||||
await use(jobsApi)
|
||||
|
||||
await jobsApi.clear()
|
||||
}
|
||||
})
|
||||
169
browser_tests/fixtures/jobsRouteFixture.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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' })
|
||||
}
|
||||
})
|
||||
16
browser_tests/fixtures/templateApiFixture.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
|
||||
export const templateApiFixture = base.extend<{
|
||||
templateApi: TemplateHelper
|
||||
}>({
|
||||
templateApi: async ({ page }, use) => {
|
||||
const templateApi = createTemplateHelper(page)
|
||||
|
||||
await use(templateApi)
|
||||
|
||||
await templateApi.clearMocks()
|
||||
}
|
||||
})
|
||||
@@ -122,3 +122,19 @@ export async function saveAndReopenInAppMode(
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
export async function saveCloseAndReopenInBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await expect(appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: JobEntry['status']) {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
|
||||
const updateTime =
|
||||
listItem.execution_end_time ??
|
||||
listItem.execution_start_time ??
|
||||
listItem.create_time
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
update_time: updateTime,
|
||||
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
listItem,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobRecords(
|
||||
listItems: readonly JobEntry[]
|
||||
): MockJobRecord[] {
|
||||
return listItems.map(createMockJobRecord)
|
||||
}
|
||||
44
browser_tests/tests/appModeInputPersistence.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
saveCloseAndReopenInBuilder,
|
||||
setupBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
test.describe(
|
||||
'App builder input persistence after reload',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('persists selected inputs after save and reopen without visibility errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
const workflowName = `${Date.now()} input-persistence`
|
||||
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
for (const widget of WIDGETS) {
|
||||
await expect(
|
||||
appMode.select.getInputItemSubtitle(widget)
|
||||
).not.toContainText('Widget not visible')
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -5,26 +5,18 @@ import {
|
||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
saveCloseAndReopenInBuilder,
|
||||
setupBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
|
||||
async function saveCloseAndReopenAsApp(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await expect(appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
|
||||
await appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
|
||||
@@ -247,6 +247,14 @@ test.describe('Cloud notification dialog', () => {
|
||||
await dialog.back.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should not advertise free monthly credits', async ({ comfyPage }) => {
|
||||
const dialog = new CloudNotification(comfyPage.page)
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.root.getByText(/Free Credits/i)).toHaveCount(0)
|
||||
await expect(dialog.root.getByText(/400/)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -8,15 +8,15 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
@@ -213,9 +213,9 @@ async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function outputHistoryJobs() {
|
||||
return createMockJobRecords([
|
||||
createMockJob({
|
||||
function outputHistoryJobs(): RawJobListItem[] {
|
||||
return [
|
||||
createRouteMockJob({
|
||||
id: 'history-output-image',
|
||||
preview_output: {
|
||||
filename: 'ComfyUI_00001_.png',
|
||||
@@ -225,7 +225,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'history-output-video',
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
@@ -235,7 +235,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'video'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'history-output-audio',
|
||||
preview_output: {
|
||||
filename: 'sound.wav',
|
||||
@@ -245,7 +245,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'audio'
|
||||
}
|
||||
})
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
ossTest.describe(
|
||||
@@ -258,8 +258,9 @@ ossTest.describe(
|
||||
|
||||
ossTest(
|
||||
'resolves annotated output media from job history',
|
||||
async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(outputHistoryJobs())
|
||||
async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsHistory(outputHistoryJobs())
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_output_annotations'
|
||||
|
||||
@@ -1,56 +1,54 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const mockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: now - 60_000,
|
||||
execution_start_time: now - 60_000,
|
||||
execution_end_time: now - 50_000,
|
||||
create_time: mockJobTimestamp - 60_000,
|
||||
execution_start_time: mockJobTimestamp - 60_000,
|
||||
execution_end_time: mockJobTimestamp - 50_000,
|
||||
outputs_count: 2
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-completed-2',
|
||||
status: 'completed',
|
||||
create_time: now - 120_000,
|
||||
execution_start_time: now - 120_000,
|
||||
execution_end_time: now - 115_000,
|
||||
create_time: mockJobTimestamp - 120_000,
|
||||
execution_start_time: mockJobTimestamp - 120_000,
|
||||
execution_end_time: mockJobTimestamp - 115_000,
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: now - 30_000,
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
create_time: mockJobTimestamp - 30_000,
|
||||
execution_start_time: mockJobTimestamp - 30_000,
|
||||
execution_end_time: mockJobTimestamp - 28_000,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-failed-bottom',
|
||||
status: 'failed',
|
||||
create_time: now - 180_000,
|
||||
execution_start_time: now - 180_000,
|
||||
execution_end_time: now - 178_000,
|
||||
create_time: mockJobTimestamp - 180_000,
|
||||
execution_start_time: mockJobTimestamp - 180_000,
|
||||
execution_end_time: mockJobTimestamp - 178_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
test.beforeEach(async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsScenario({ history: MOCK_JOBS, queue: [] })
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, templateApiFixture)
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
@@ -17,7 +17,7 @@ test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
test.beforeEach(async ({ comfyPage, templateApi }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -26,53 +26,37 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
await templateApi.mockThumbnails()
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -86,45 +70,38 @@ test.describe(
|
||||
})
|
||||
|
||||
test('filtered count reflects distribution + model filter together', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -144,36 +121,29 @@ test.describe(
|
||||
})
|
||||
|
||||
test('desktop-only templates never leak into DOM on cloud distribution', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -200,28 +170,21 @@ test.describe(
|
||||
})
|
||||
|
||||
test('templates without includeOnDistributions are visible on cloud', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -234,39 +197,32 @@ test.describe(
|
||||
})
|
||||
|
||||
test('clear filters button resets to correct distribution-filtered total', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 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: 107 KiB After Width: | Height: | Size: 106 KiB |
@@ -26,6 +26,10 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.7",
|
||||
"version": "1.45.8",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
723
pnpm-lock.yaml
generated
@@ -36,7 +36,7 @@ catalog:
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tailwindcss/vite': ^4.3.0
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
@@ -112,7 +112,7 @@ catalog:
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.2.0
|
||||
tailwindcss: ^4.3.0
|
||||
three: ^0.170.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
|
||||
20
src/base/wheelGestures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -16,14 +16,6 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/dialog', () => ({
|
||||
default: {
|
||||
name: 'Dialog',
|
||||
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
|
||||
props: ['visible']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
name: 'SelectButton',
|
||||
@@ -32,8 +24,29 @@ vi.mock('primevue/selectbutton', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: { name: 'Divider', template: '<hr />' }
|
||||
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
|
||||
default: { name: 'Dialog', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
|
||||
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
|
||||
default: { name: 'DialogOverlay', template: '<div />' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
|
||||
default: { name: 'DialogContent', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
|
||||
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogFooter.vue', () => ({
|
||||
default: { name: 'DialogFooter', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
|
||||
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
|
||||
default: { name: 'DialogClose', template: '<button />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
|
||||
|
||||
@@ -1,72 +1,111 @@
|
||||
<template>
|
||||
<Dialog v-model:visible="visible" :header="$t('g.customizeFolder')">
|
||||
<div class="p-fluid">
|
||||
<div class="field icon-field">
|
||||
<label for="icon">{{ $t('g.icon') }}</label>
|
||||
<SelectButton
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
<Dialog v-model:open="visible" :modal="false">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
size="md"
|
||||
:aria-labelledby="titleId"
|
||||
@pointer-down-outside="onPointerDownOutside"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle :id="titleId">
|
||||
{{ $t('g.customizeFolder') }}
|
||||
</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 px-4 py-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customization-icon" class="text-sm font-medium">
|
||||
{{ $t('g.icon') }}
|
||||
</label>
|
||||
<SelectButton
|
||||
id="customization-icon"
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customization-color" class="text-sm font-medium">
|
||||
{{ $t('g.color') }}
|
||||
</label>
|
||||
<ColorCustomizationSelector
|
||||
id="customization-color"
|
||||
v-model="finalColor"
|
||||
:color-options="colorOptions"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<div class="field color-field">
|
||||
<label for="color">{{ $t('g.color') }}</label>
|
||||
<ColorCustomizationSelector
|
||||
v-model="finalColor"
|
||||
:color-options="colorOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, useId, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorCustomizationSelector from '@/components/common/ColorCustomizationSelector.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
const { initialIcon, initialColor } = defineProps<{
|
||||
initialIcon?: string
|
||||
initialColor?: string
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', icon: string, color: string): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const titleId = useId()
|
||||
|
||||
// PrimeVue ColorPicker overlay teleports to body. Reka treats clicks on it as
|
||||
// outside and would dismiss the dialog mid-color-pick. Treat any PrimeVue
|
||||
// overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-colorpicker-panel, .p-overlay, .p-overlay-mask'
|
||||
|
||||
function onPointerDownOutside(
|
||||
event: CustomEvent<{ originalEvent: PointerEvent }>
|
||||
) {
|
||||
const target = event.detail.originalEvent.target
|
||||
if (target instanceof Element && target.closest(PRIMEVUE_OVERLAY_SELECTORS)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
@@ -95,30 +134,22 @@ const defaultIcon = iconOptions.find(
|
||||
)
|
||||
|
||||
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
|
||||
const finalColor = ref(
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
)
|
||||
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
|
||||
|
||||
const resetCustomization = () => {
|
||||
selectedIcon.value =
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ??
|
||||
iconOptions[0]
|
||||
finalColor.value =
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
iconOptions.find((option) => option.value === initialIcon) ?? iconOptions[0]
|
||||
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
}
|
||||
|
||||
const confirmCustomization = () => {
|
||||
emit('confirm', selectedIcon.value.value, finalColor.value)
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue: boolean) => {
|
||||
visible,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
resetCustomization()
|
||||
}
|
||||
@@ -135,10 +166,4 @@ watch(
|
||||
.p-selectbutton .p-button .pi {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { t } = useI18n()
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] -translate-1/2 rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
{{ $t(col.headerKey) }}
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div :class="cn(isOutdated(col) && 'text-danger-100')">
|
||||
{{ getDisplayValue(col) }}
|
||||
@@ -58,10 +58,8 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
@@ -80,7 +78,7 @@ type SystemInfoKey = keyof SystemStats['system']
|
||||
|
||||
type ColumnDef = {
|
||||
field: SystemInfoKey
|
||||
headerKey: string
|
||||
header: string
|
||||
getValue?: () => string
|
||||
format?: (value: string) => string
|
||||
formatNumber?: (value: number) => string
|
||||
@@ -88,45 +86,31 @@ type ColumnDef = {
|
||||
|
||||
/** Columns for local distribution */
|
||||
const localColumns: ColumnDef[] = [
|
||||
{ field: 'os', headerKey: 'g.systemStatsOS' },
|
||||
{ field: 'python_version', headerKey: 'g.systemStatsPythonVersion' },
|
||||
{ field: 'embedded_python', headerKey: 'g.systemStatsEmbeddedPython' },
|
||||
{ field: 'pytorch_version', headerKey: 'g.systemStatsPyTorchVersion' },
|
||||
{ field: 'argv', headerKey: 'g.systemStatsArguments' },
|
||||
{
|
||||
field: 'ram_total',
|
||||
headerKey: 'g.systemStatsRAMTotal',
|
||||
formatNumber: formatSize
|
||||
},
|
||||
{
|
||||
field: 'ram_free',
|
||||
headerKey: 'g.systemStatsRAMFree',
|
||||
formatNumber: formatSize
|
||||
},
|
||||
{
|
||||
field: 'installed_templates_version',
|
||||
headerKey: 'g.systemStatsTemplatesVersion'
|
||||
}
|
||||
{ field: 'os', header: 'OS' },
|
||||
{ field: 'python_version', header: 'Python Version' },
|
||||
{ field: 'embedded_python', header: 'Embedded Python' },
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
|
||||
{ field: 'installed_templates_version', header: 'Templates Version' }
|
||||
]
|
||||
|
||||
/** Columns for cloud distribution */
|
||||
const cloudColumns: ColumnDef[] = [
|
||||
{ field: 'cloud_version', headerKey: 'g.systemStatsCloudVersion' },
|
||||
{ field: 'cloud_version', header: 'Cloud Version' },
|
||||
{
|
||||
field: 'comfyui_version',
|
||||
headerKey: 'g.systemStatsComfyUIVersion',
|
||||
header: 'ComfyUI Version',
|
||||
format: formatCommitHash
|
||||
},
|
||||
{
|
||||
field: 'comfyui_frontend_version',
|
||||
headerKey: 'g.systemStatsFrontendVersion',
|
||||
header: 'Frontend Version',
|
||||
getValue: () => frontendCommit,
|
||||
format: formatCommitHash
|
||||
},
|
||||
{
|
||||
field: 'workflow_templates_version',
|
||||
headerKey: 'g.systemStatsTemplatesVersion'
|
||||
}
|
||||
{ field: 'workflow_templates_version', header: 'Templates Version' }
|
||||
]
|
||||
|
||||
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
|
||||
@@ -157,7 +141,7 @@ function formatSystemInfoText(): string {
|
||||
for (const col of systemColumns.value) {
|
||||
const display = getDisplayValue(col)
|
||||
if (display !== undefined && display !== '') {
|
||||
lines.push(`${t(col.headerKey)}: ${display}`)
|
||||
lines.push(`${col.header}: ${display}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface) h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
|
||||
class="h-full scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable overflow-y-auto [overflow-anchor:none]"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchInput"
|
||||
:searcher="applySearchQuery"
|
||||
:debounce-ms="400"
|
||||
@@ -412,7 +412,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<Divider />
|
||||
<ScrollPanel class="h-[400px] w-full max-w-[80vw]">
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="h-[400px] w-full max-w-[80vw] overflow-auto">
|
||||
<pre class="wrap-break-word whitespace-pre-wrap">{{
|
||||
reportContent
|
||||
}}</pre>
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
</template>
|
||||
<div class="flex justify-end gap-4">
|
||||
<FindIssueButton
|
||||
@@ -62,8 +62,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-current"
|
||||
class="pointer-events-none size-6.25 fill-current"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
@@ -26,7 +26,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
class="cls-1"
|
||||
@@ -44,7 +44,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="-6 -7 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
@@ -59,7 +59,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="-9 -7 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<g transform="scale(-1, 1)">
|
||||
<path
|
||||
@@ -76,7 +76,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
|
||||
@@ -92,7 +92,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
|
||||
|
||||
@@ -370,7 +370,7 @@ function handleTitleCancel() {
|
||||
</section>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<div class="flex-1 scrollbar-thin overflow-y-auto">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<template v-else-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
FormSearchInput: {
|
||||
AsyncSearchInput: {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<CollapseToggleButton
|
||||
v-model="isAllCollapsed"
|
||||
:show="!isSearching && tabErrorGroups.length > 1"
|
||||
@@ -260,7 +260,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
@@ -119,7 +119,7 @@ function onCollapseUpdate() {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="favoritedWidgets"
|
||||
|
||||
@@ -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 FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.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"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
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 FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.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"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
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 FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.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"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
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 FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.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">
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
<AsyncSearchInput 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
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { defineComponent, ref } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import FormSearchInput from './FormSearchInput.vue'
|
||||
import AsyncSearchInput from './AsyncSearchInput.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -20,7 +20,7 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
type Searcher = NonNullable<ComponentProps<typeof FormSearchInput>['searcher']>
|
||||
type Searcher = NonNullable<ComponentProps<typeof AsyncSearchInput>['searcher']>
|
||||
|
||||
function renderSearch(
|
||||
initialQuery: string = '',
|
||||
@@ -30,9 +30,9 @@ function renderSearch(
|
||||
const query = ref(initialQuery)
|
||||
const key = updateKey
|
||||
const Harness = defineComponent({
|
||||
components: { FormSearchInput },
|
||||
components: { AsyncSearchInput },
|
||||
setup: () => ({ query, searcher, key }),
|
||||
template: `<FormSearchInput
|
||||
template: `<AsyncSearchInput
|
||||
v-model="query"
|
||||
:searcher="searcher"
|
||||
:update-key="key"
|
||||
@@ -42,7 +42,7 @@ function renderSearch(
|
||||
return { ...utils, query, key }
|
||||
}
|
||||
|
||||
describe('FormSearchInput', () => {
|
||||
describe('AsyncSearchInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
@@ -47,7 +47,7 @@ watch(
|
||||
|
||||
searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
|
||||
.catch((error) => {
|
||||
console.error('[SidePanelSearch] searcher failed', error)
|
||||
console.error('[AsyncSearchInput] searcher failed', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCleanup) isQuerying.value = false
|
||||
@@ -23,7 +23,7 @@ defineExpose({
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'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',
|
||||
'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',
|
||||
className
|
||||
)
|
||||
"
|
||||
|
||||
@@ -23,8 +23,19 @@ import type { CanvasPointerEvent } from './types/events'
|
||||
* - {@link LGraphCanvas.processMouseUp}
|
||||
*/
|
||||
export class CanvasPointer {
|
||||
/** Maximum time in milliseconds to ignore click drift */
|
||||
static bufferTime = 150
|
||||
/**
|
||||
* 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 gap between pointerup and pointerdown events to be considered as a double click */
|
||||
static doubleClickTime = 300
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "المتابعة محليًا",
|
||||
"exploreCloud": "جرّب السحابة مجانًا",
|
||||
"feature1Title": "٤٠٠ رصيد مجاني شهريًا",
|
||||
"feature2Title": "يعمل في أي مكان، فورًا",
|
||||
"feature3Title": "نماذج جاهزة للاستخدام",
|
||||
"feature4Title": "أفضل حزم العقد المخصصة مثبتة مسبقًا",
|
||||
@@ -914,7 +913,8 @@
|
||||
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
|
||||
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
|
||||
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
|
||||
"promptExecutionError": "فشل تنفيذ الطلب"
|
||||
"promptExecutionError": "فشل تنفيذ الطلب",
|
||||
"queueOpenWorkflowFailedTitle": "فشل في فتح سير العمل"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطأ | {count} أخطاء",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "شعار ComfyOrg",
|
||||
"comfyPackageOutdated": "إصدار {name} المثبت ({installedVersion}) أقل من الإصدار المطلوب ({requiredVersion}).",
|
||||
"comingSoon": "قريباً",
|
||||
"command": "أمر",
|
||||
"commandProhibited": "الأمر {command} محظور. يرجى التواصل مع المسؤول لمزيد من المعلومات.",
|
||||
|
||||
@@ -64,17 +64,6 @@
|
||||
"insert": "Insert",
|
||||
"systemInfo": "System Info",
|
||||
"devices": "Devices",
|
||||
"systemStatsOS": "OS",
|
||||
"systemStatsPythonVersion": "Python Version",
|
||||
"systemStatsEmbeddedPython": "Embedded Python",
|
||||
"systemStatsPyTorchVersion": "PyTorch Version",
|
||||
"systemStatsArguments": "Arguments",
|
||||
"systemStatsRAMTotal": "RAM Total",
|
||||
"systemStatsRAMFree": "RAM Free",
|
||||
"systemStatsTemplatesVersion": "Templates Version",
|
||||
"systemStatsCloudVersion": "Cloud Version",
|
||||
"systemStatsComfyUIVersion": "ComfyUI Version",
|
||||
"systemStatsFrontendVersion": "Frontend Version",
|
||||
"about": "About",
|
||||
"add": "Add",
|
||||
"confirm": "Confirm",
|
||||
@@ -231,6 +220,7 @@
|
||||
"versionMismatchWarningMessage": "{warning}: {detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.",
|
||||
"frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires {requiredVersion} or higher.",
|
||||
"frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.",
|
||||
"comfyPackageOutdated": "Installed {name} version {installedVersion} is lower than the required version {requiredVersion}.",
|
||||
"updateFrontend": "Update Frontend",
|
||||
"dismiss": "Dismiss",
|
||||
"update": "Update",
|
||||
@@ -3803,7 +3793,6 @@
|
||||
"cloudNotification": {
|
||||
"title": "Run ComfyUI in the Cloud",
|
||||
"message": "From setup to creation in seconds. Popular models, extensions, and powerful GPUs — ready when you are.",
|
||||
"feature1Title": "400 Free Credits Monthly",
|
||||
"feature2Title": "Works Anywhere, Instantly",
|
||||
"feature3Title": "Models Ready to Use",
|
||||
"feature4Title": "Top Custom Node Packs Pre-installed",
|
||||
|
||||
@@ -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."
|
||||
"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."
|
||||
},
|
||||
"Comfy_Pointer_ClickDrift": {
|
||||
"name": "Pointer click drift (maximum distance)",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "Continuar Localmente",
|
||||
"exploreCloud": "Probar la Nube Gratis",
|
||||
"feature1Title": "400 Créditos Gratis al Mes",
|
||||
"feature2Title": "Funciona en Cualquier Lugar, al Instante",
|
||||
"feature3Title": "Modelos Listos para Usar",
|
||||
"feature4Title": "Paquetes de Nodos Personalizados Preinstalados",
|
||||
@@ -914,7 +913,8 @@
|
||||
"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ó"
|
||||
"promptExecutionError": "La ejecución del prompt falló",
|
||||
"queueOpenWorkflowFailedTitle": "No se pudo abrir el flujo de trabajo"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERROR | {count} ERRORES",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"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.",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "ادامه به صورت محلی",
|
||||
"exploreCloud": "امتحان رایگان فضای ابری",
|
||||
"feature1Title": "۴۰۰ اعتبار رایگان ماهانه",
|
||||
"feature2Title": "قابل استفاده در هر مکان، بلافاصله",
|
||||
"feature3Title": "مدلها آماده استفاده",
|
||||
"feature4Title": "برترین بستههای Node سفارشی از پیش نصبشده",
|
||||
@@ -914,7 +913,8 @@
|
||||
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
|
||||
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد دادههای workflow متوقف شد",
|
||||
"noStackTrace": "هیچ stacktraceی موجود نیست",
|
||||
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
|
||||
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
|
||||
"queueOpenWorkflowFailedTitle": "باز کردن workflow با خطا مواجه شد"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطا",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
|
||||
"comfyPackageOutdated": "نسخه نصبشده {name} با شماره {installedVersion} پایینتر از نسخه مورد نیاز {requiredVersion} است.",
|
||||
"comingSoon": "بهزودی",
|
||||
"command": "دستور",
|
||||
"commandProhibited": "دستور {command} مجاز نیست. برای اطلاعات بیشتر با مدیر تماس بگیرید.",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "Continuer localement",
|
||||
"exploreCloud": "Essayer le cloud gratuitement",
|
||||
"feature1Title": "400 crédits gratuits par mois",
|
||||
"feature2Title": "Fonctionne partout, instantanément",
|
||||
"feature3Title": "Modèles prêts à l’emploi",
|
||||
"feature4Title": "Packs de nœuds personnalisés préinstallés",
|
||||
@@ -914,7 +913,8 @@
|
||||
"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é"
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué",
|
||||
"queueOpenWorkflowFailedTitle": "Échec de l'ouverture du workflow"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERREUR | {count} ERREURS",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"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.",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "ローカルで続行",
|
||||
"exploreCloud": "クラウドを無料で試す",
|
||||
"feature1Title": "毎月400クレジット無料",
|
||||
"feature2Title": "どこでも即時利用可能",
|
||||
"feature3Title": "すぐに使えるモデル",
|
||||
"feature4Title": "人気カスタムノードパックをプリインストール",
|
||||
@@ -914,7 +913,8 @@
|
||||
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
|
||||
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
|
||||
"noStackTrace": "スタックトレースは利用できません",
|
||||
"promptExecutionError": "プロンプトの実行に失敗しました"
|
||||
"promptExecutionError": "プロンプトの実行に失敗しました",
|
||||
"queueOpenWorkflowFailedTitle": "ワークフローのオープンに失敗しました"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} 件のエラー",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrgロゴ",
|
||||
"comfyPackageOutdated": "インストールされている{name}のバージョン{installedVersion}は、必要なバージョン{requiredVersion}よりも低いです。",
|
||||
"comingSoon": "近日公開",
|
||||
"command": "コマンド",
|
||||
"commandProhibited": "コマンド {command} は禁止されています。詳細は管理者にお問い合わせください。",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "로컬에서 계속하기",
|
||||
"exploreCloud": "클라우드 무료 체험",
|
||||
"feature1Title": "매월 400 크레딧 무료 제공",
|
||||
"feature2Title": "어디서나 즉시 사용 가능",
|
||||
"feature3Title": "즉시 사용 가능한 모델",
|
||||
"feature4Title": "최고의 커스텀 노드 팩 사전 설치",
|
||||
@@ -914,7 +913,8 @@
|
||||
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
|
||||
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
|
||||
"noStackTrace": "스택 추적을 사용할 수 없습니다",
|
||||
"promptExecutionError": "프롬프트 실행 실패"
|
||||
"promptExecutionError": "프롬프트 실행 실패",
|
||||
"queueOpenWorkflowFailedTitle": "워크플로우 열기에 실패했습니다"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count}개 오류",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 로고",
|
||||
"comfyPackageOutdated": "설치된 {name} 버전 {installedVersion}이(가) 필요한 버전 {requiredVersion}보다 낮습니다.",
|
||||
"comingSoon": "곧 출시 예정",
|
||||
"command": "명령",
|
||||
"commandProhibited": "{command}는 금지된 명령입니다. 자세한 정보는 관리자에게 문의하십시오.",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "Continuar Localmente",
|
||||
"exploreCloud": "Experimente a Nuvem Gratuitamente",
|
||||
"feature1Title": "400 Créditos Grátis por Mês",
|
||||
"feature2Title": "Funciona em Qualquer Lugar, Instantaneamente",
|
||||
"feature3Title": "Modelos Prontos para Usar",
|
||||
"feature4Title": "Principais Pacotes de Nodes Personalizados Pré-instalados",
|
||||
@@ -914,7 +913,8 @@
|
||||
"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"
|
||||
"promptExecutionError": "Falha na execução do prompt",
|
||||
"queueOpenWorkflowFailedTitle": "Falha ao abrir o workflow"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRO | {count} ERROS",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"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.",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "Продолжить локально",
|
||||
"exploreCloud": "Попробовать облако бесплатно",
|
||||
"feature1Title": "400 бесплатных кредитов в месяц",
|
||||
"feature2Title": "Работает везде и мгновенно",
|
||||
"feature3Title": "Модели готовы к использованию",
|
||||
"feature4Title": "Лучшие пользовательские пакеты узлов предустановлены",
|
||||
@@ -914,7 +913,8 @@
|
||||
"extensionFileHint": "Это может быть связано со следующим скриптом",
|
||||
"loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса",
|
||||
"noStackTrace": "Стек вызовов недоступен",
|
||||
"promptExecutionError": "Ошибка выполнения запроса"
|
||||
"promptExecutionError": "Ошибка выполнения запроса",
|
||||
"queueOpenWorkflowFailedTitle": "Не удалось открыть рабочий процесс"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Логотип ComfyOrg",
|
||||
"comfyPackageOutdated": "Установленная версия {name} ({installedVersion}) ниже требуемой версии ({requiredVersion}).",
|
||||
"comingSoon": "Скоро будет",
|
||||
"command": "Команда",
|
||||
"commandProhibited": "Команда {command} запрещена. Свяжитесь с администратором для получения дополнительной информации.",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "Yerelde Devam Et",
|
||||
"exploreCloud": "Bulutu Ücretsiz Dene",
|
||||
"feature1Title": "Aylık 400 Ücretsiz Kredi",
|
||||
"feature2Title": "Her Yerde, Anında Çalışır",
|
||||
"feature3Title": "Kullanıma Hazır Modeller",
|
||||
"feature4Title": "En İyi Özel Node Paketleri Önceden Yüklü",
|
||||
@@ -914,7 +913,8 @@
|
||||
"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"
|
||||
"promptExecutionError": "İstem yürütmesi başarısız oldu",
|
||||
"queueOpenWorkflowFailedTitle": "İş Akışı Açılamadı"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} HATA | {count} HATA",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"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.",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "在本地繼續",
|
||||
"exploreCloud": "免費試用雲端",
|
||||
"feature1Title": "每月 400 點免費額度",
|
||||
"feature2Title": "隨處可用,立即啟動",
|
||||
"feature3Title": "模型即刻可用",
|
||||
"feature4Title": "頂級自訂節點包預先安裝",
|
||||
@@ -914,7 +913,8 @@
|
||||
"extensionFileHint": "這可能是由於以下指令碼所致",
|
||||
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",
|
||||
"noStackTrace": "沒有可用的堆疊追蹤",
|
||||
"promptExecutionError": "提示執行失敗"
|
||||
"promptExecutionError": "提示執行失敗",
|
||||
"queueOpenWorkflowFailedTitle": "開啟工作流程失敗"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} 個錯誤",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 標誌",
|
||||
"comfyPackageOutdated": "已安裝的 {name} 版本 {installedVersion} 低於所需版本 {requiredVersion}。",
|
||||
"comingSoon": "即將推出",
|
||||
"command": "指令",
|
||||
"commandProhibited": "指令 {command} 已被禁止。如需更多資訊,請聯絡管理員。",
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
"cloudNotification": {
|
||||
"continueLocally": "本地继续",
|
||||
"exploreCloud": "免费试用云端",
|
||||
"feature1Title": "每月 400 免费积分",
|
||||
"feature2Title": "随时随地,立即可用",
|
||||
"feature3Title": "模型即刻可用",
|
||||
"feature4Title": "顶级自定义节点包预装",
|
||||
@@ -914,7 +913,8 @@
|
||||
"extensionFileHint": "这可能是由于以下脚本",
|
||||
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
|
||||
"noStackTrace": "无可用堆栈跟踪",
|
||||
"promptExecutionError": "提示执行失败"
|
||||
"promptExecutionError": "提示执行失败",
|
||||
"queueOpenWorkflowFailedTitle": "打开工作流失败"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count}个错误",
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"comfy": "舒适",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 徽标",
|
||||
"comfyPackageOutdated": "已安装的 {name} 版本 {installedVersion} 低于所需版本 {requiredVersion}。",
|
||||
"comingSoon": "即将推出",
|
||||
"command": "指令",
|
||||
"commandProhibited": "命令 {command} 被禁止。请联系管理员了解更多信息。",
|
||||
|
||||
@@ -1,48 +1,51 @@
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<i class="pi pi-times text-sm" />
|
||||
</Button>
|
||||
<video
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
<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"
|
||||
class="w-full rounded-lg"
|
||||
:src="videoUrl"
|
||||
@escape-key-down="onEscapeKeyDown"
|
||||
>
|
||||
{{ $t('g.videoFailedToLoad') }}
|
||||
</video>
|
||||
</div>
|
||||
<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>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { onWatcherCleanup, watch } from 'vue'
|
||||
import { VisuallyHidden } from 'reka-ui'
|
||||
|
||||
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 })
|
||||
|
||||
@@ -51,27 +54,13 @@ const { videoUrl, ariaLabel = 'Help video' } = defineProps<{
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopImmediatePropagation()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
isVisible.value = false
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 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 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',
|
||||
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"
|
||||
@@ -42,7 +42,11 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-col items-start gap-0 self-stretch">
|
||||
<div v-for="n in 4" :key="n" class="flex items-center gap-2 py-2">
|
||||
<div
|
||||
v-for="n in [2, 3, 4]"
|
||||
:key="n"
|
||||
class="flex items-center gap-2 py-2"
|
||||
>
|
||||
<i class="pi pi-check text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ t(`cloudNotification.feature${n}Title`) }}
|
||||
|
||||
98
src/platform/secrets/components/SecretFormDialog.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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,106 +1,130 @@
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
{{ 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"
|
||||
<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"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" tabindex="0" :loading="loading">
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<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"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" tabindex="0" :loading="loading">
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</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'
|
||||
@@ -126,6 +150,8 @@ const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const titleId = useId()
|
||||
|
||||
const { form, errors, loading, apiError, providerOptions, handleSubmit } =
|
||||
useSecretForm({
|
||||
mode,
|
||||
|
||||
@@ -803,16 +803,17 @@ 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.',
|
||||
'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.',
|
||||
experimental: true,
|
||||
type: 'slider',
|
||||
attrs: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 25
|
||||
step: 1
|
||||
},
|
||||
defaultValue: 150,
|
||||
versionAdded: '1.4.3'
|
||||
defaultValue: 32,
|
||||
versionAdded: '1.4.3',
|
||||
versionModified: '1.44.19'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Pointer.DoubleClickTime',
|
||||
|
||||
@@ -55,6 +55,10 @@ vi.mock('vue-i18n', () => ({
|
||||
const p = params as Record<string, string>
|
||||
return `Frontend version ${p.frontendVersion} may not be compatible with backend version ${p.backendVersion}.`
|
||||
}
|
||||
if (key === 'g.comfyPackageOutdated' && params) {
|
||||
const p = params as Record<string, string>
|
||||
return `Installed ${p.name} version ${p.installedVersion} is lower than the required version ${p.requiredVersion}.`
|
||||
}
|
||||
return key
|
||||
}
|
||||
}),
|
||||
@@ -233,4 +237,37 @@ describe('useFrontendVersionMismatchWarning', () => {
|
||||
// Should only have been called once
|
||||
expect(addAlertSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should emit a separate alert for each outdated comfy package', () => {
|
||||
const toastStore = useToastStore()
|
||||
const versionStore = useVersionCompatibilityStore()
|
||||
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
|
||||
|
||||
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue(null)
|
||||
vi.spyOn(versionStore, 'packageWarningMessages', 'get').mockReturnValue([
|
||||
{
|
||||
name: 'comfyui-workflow-templates',
|
||||
installedVersion: '0.9.0',
|
||||
requiredVersion: '0.9.5'
|
||||
},
|
||||
{
|
||||
name: 'comfyui-embedded-docs',
|
||||
installedVersion: '0.4.0',
|
||||
requiredVersion: '0.5.0'
|
||||
}
|
||||
])
|
||||
|
||||
const { showWarning } = useFrontendVersionMismatchWarning()
|
||||
showWarning()
|
||||
|
||||
expect(addAlertSpy).toHaveBeenCalledTimes(2)
|
||||
expect(addAlertSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Installed comfyui-workflow-templates version 0.9.0'
|
||||
)
|
||||
)
|
||||
expect(addAlertSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Installed comfyui-embedded-docs version 0.4.0')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useToastStore } from './toastStore'
|
||||
@@ -41,47 +41,66 @@ export function useFrontendVersionMismatchWarning(
|
||||
// Track if we've already shown the warning
|
||||
let hasShownWarning = false
|
||||
|
||||
const emitAlert = (detail: string) => {
|
||||
const fullMessage = t('g.versionMismatchWarningMessage', {
|
||||
warning: t('g.versionMismatchWarning'),
|
||||
detail
|
||||
})
|
||||
toastStore.addAlert(fullMessage)
|
||||
}
|
||||
|
||||
const showWarning = () => {
|
||||
// Prevent showing the warning multiple times
|
||||
if (hasShownWarning) return
|
||||
|
||||
const message = versionCompatibilityStore.warningMessage
|
||||
if (!message) return
|
||||
const packageMessages = versionCompatibilityStore.packageWarningMessages
|
||||
if (!message && packageMessages.length === 0) return
|
||||
|
||||
const detailMessage = t('g.frontendOutdated', {
|
||||
frontendVersion: message.frontendVersion,
|
||||
requiredVersion: message.requiredVersion
|
||||
})
|
||||
if (message) {
|
||||
emitAlert(
|
||||
t('g.frontendOutdated', {
|
||||
frontendVersion: message.frontendVersion,
|
||||
requiredVersion: message.requiredVersion
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const fullMessage = t('g.versionMismatchWarningMessage', {
|
||||
warning: t('g.versionMismatchWarning'),
|
||||
detail: detailMessage
|
||||
})
|
||||
for (const pkg of packageMessages) {
|
||||
emitAlert(
|
||||
t('g.comfyPackageOutdated', {
|
||||
name: pkg.name,
|
||||
installedVersion: pkg.installedVersion,
|
||||
requiredVersion: pkg.requiredVersion
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
toastStore.addAlert(fullMessage)
|
||||
hasShownWarning = true
|
||||
|
||||
// Automatically dismiss the warning so it won't show again for 7 days
|
||||
versionCompatibilityStore.dismissWarning()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Only set up the watcher if immediate is true
|
||||
if (immediate) {
|
||||
// Wait for next tick to ensure reactive updates from settings load have propagated
|
||||
await nextTick()
|
||||
let stopWatcher: (() => void) | null = null
|
||||
|
||||
whenever(
|
||||
() => versionCompatibilityStore.shouldShowWarning,
|
||||
() => {
|
||||
showWarning()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
once: true
|
||||
}
|
||||
)
|
||||
}
|
||||
onMounted(async () => {
|
||||
if (!immediate) return
|
||||
// Wait for next tick to ensure reactive updates from settings load have propagated
|
||||
await nextTick()
|
||||
|
||||
stopWatcher = whenever(
|
||||
() => versionCompatibilityStore.shouldShowWarning,
|
||||
() => {
|
||||
showWarning()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWatcher?.()
|
||||
stopWatcher = null
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -64,6 +64,7 @@ describe('useVersionCompatibilityStore', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('version compatibility detection', () => {
|
||||
@@ -279,7 +280,8 @@ describe('useVersionCompatibilityStore', () => {
|
||||
describe('dismissal persistence', () => {
|
||||
it('should save dismissal to reactive storage with expiration', async () => {
|
||||
const mockNow = 1000000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(mockNow)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(mockNow)
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
@@ -381,4 +383,231 @@ describe('useVersionCompatibilityStore', () => {
|
||||
expect(vi.mocked(until)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('comfy package version warnings', () => {
|
||||
it('should detect outdated comfy packages and skip comfyui-frontend-package', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0',
|
||||
comfy_package_versions: [
|
||||
{
|
||||
name: 'comfyui-frontend-package',
|
||||
installed: '1.0.0',
|
||||
required: '2.0.0'
|
||||
},
|
||||
{
|
||||
name: 'comfyui-workflow-templates',
|
||||
installed: '0.9.0',
|
||||
required: '0.9.5'
|
||||
},
|
||||
{
|
||||
name: 'comfyui-embedded-docs',
|
||||
installed: '0.5.0',
|
||||
required: '0.5.0'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mockSystemStatsStore.isInitialized = true
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.outdatedComfyPackages).toEqual([
|
||||
{
|
||||
name: 'comfyui-workflow-templates',
|
||||
installed: '0.9.0',
|
||||
required: '0.9.5'
|
||||
}
|
||||
])
|
||||
expect(store.hasVersionMismatch).toBe(true)
|
||||
expect(store.shouldShowWarning).toBe(true)
|
||||
expect(store.packageWarningMessages).toEqual([
|
||||
{
|
||||
name: 'comfyui-workflow-templates',
|
||||
installedVersion: '0.9.0',
|
||||
requiredVersion: '0.9.5'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore packages with missing or invalid versions', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0',
|
||||
comfy_package_versions: [
|
||||
{
|
||||
name: 'comfy-kitchen',
|
||||
installed: null,
|
||||
required: '1.0.0'
|
||||
},
|
||||
{
|
||||
name: 'comfy-aimdo',
|
||||
installed: 'not-a-version',
|
||||
required: '1.0.0'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mockSystemStatsStore.isInitialized = true
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.outdatedComfyPackages).toEqual([])
|
||||
expect(store.hasVersionMismatch).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect outdated PEP 440 versions via coerce', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0',
|
||||
comfy_package_versions: [
|
||||
{
|
||||
name: 'comfy-kitchen',
|
||||
installed: '0.3.0.post1',
|
||||
required: '0.4.0rc1'
|
||||
},
|
||||
{
|
||||
name: 'comfy-aimdo',
|
||||
installed: '0.4.0',
|
||||
required: '0.4.0.post1'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mockSystemStatsStore.isInitialized = true
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.outdatedComfyPackages.map((p) => p.name)).toEqual([
|
||||
'comfy-kitchen'
|
||||
])
|
||||
})
|
||||
|
||||
it('should include outdated packages in dismissal key', async () => {
|
||||
const mockNow = 1000000
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(mockNow)
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0',
|
||||
comfy_package_versions: [
|
||||
{
|
||||
name: 'comfyui-workflow-templates',
|
||||
installed: '0.9.0',
|
||||
required: '0.9.5'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mockSystemStatsStore.isInitialized = true
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
store.dismissWarning()
|
||||
|
||||
expect(mockDismissalStorage.value).toEqual({
|
||||
'1.24.0-1.24.0-1.24.0-comfyui-workflow-templates@0.9.0->0.9.5':
|
||||
mockNow + 7 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
})
|
||||
|
||||
it('should produce the same dismissal key regardless of package order', async () => {
|
||||
const mockNow = 1000000
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(mockNow)
|
||||
|
||||
const packageA = {
|
||||
name: 'comfy-aimdo',
|
||||
installed: '0.1.0',
|
||||
required: '0.2.0'
|
||||
}
|
||||
const packageB = {
|
||||
name: 'comfyui-workflow-templates',
|
||||
installed: '0.9.0',
|
||||
required: '0.9.5'
|
||||
}
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0',
|
||||
comfy_package_versions: [packageA, packageB]
|
||||
}
|
||||
}
|
||||
mockSystemStatsStore.isInitialized = true
|
||||
await store.checkVersionCompatibility()
|
||||
store.dismissWarning()
|
||||
const firstKey = Object.keys(mockDismissalStorage.value)[0]
|
||||
|
||||
mockDismissalStorage.value = {}
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0',
|
||||
comfy_package_versions: [packageB, packageA]
|
||||
}
|
||||
}
|
||||
await store.checkVersionCompatibility()
|
||||
store.dismissWarning()
|
||||
const secondKey = Object.keys(mockDismissalStorage.value)[0]
|
||||
|
||||
expect(firstKey).toBe(secondKey)
|
||||
})
|
||||
|
||||
it('should prune expired dismissals when writing a new one', async () => {
|
||||
const mockNow = 10_000_000
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(mockNow)
|
||||
|
||||
mockDismissalStorage.value = {
|
||||
'expired-key': mockNow - 1,
|
||||
'still-valid-key': mockNow + 5000
|
||||
}
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.25.0',
|
||||
required_frontend_version: '1.25.0'
|
||||
}
|
||||
}
|
||||
mockSystemStatsStore.isInitialized = true
|
||||
await store.checkVersionCompatibility()
|
||||
store.dismissWarning()
|
||||
|
||||
expect(mockDismissalStorage.value).not.toHaveProperty('expired-key')
|
||||
expect(mockDismissalStorage.value).toHaveProperty('still-valid-key')
|
||||
expect(mockDismissalStorage.value).toHaveProperty('1.24.0-1.25.0-1.25.0')
|
||||
})
|
||||
|
||||
it('should allow dismissal when only package warnings are present', async () => {
|
||||
const mockNow = 1000000
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(mockNow)
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '',
|
||||
required_frontend_version: '',
|
||||
comfy_package_versions: [
|
||||
{
|
||||
name: 'comfyui-workflow-templates',
|
||||
installed: '0.9.0',
|
||||
required: '0.9.5'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mockSystemStatsStore.isInitialized = true
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
expect(store.shouldShowWarning).toBe(true)
|
||||
store.dismissWarning()
|
||||
expect(Object.keys(mockDismissalStorage.value)).toHaveLength(1)
|
||||
expect(store.shouldShowWarning).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import { until, useStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { gt, valid } from 'semver'
|
||||
import { coerce, gt } from 'semver'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import config from '@/config'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
interface OutdatedComfyPackage {
|
||||
name: string
|
||||
installed: string
|
||||
required: string
|
||||
}
|
||||
|
||||
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
// Already covered by the dedicated frontend warning, which uses the
|
||||
// running bundle's version rather than the installed pip version.
|
||||
const FRONTEND_PACKAGE_NAME = 'comfyui-frontend-package'
|
||||
|
||||
// Backend reports PEP 440 versions (e.g. "0.3.0.post1", "1.0.0rc1");
|
||||
// coerce strips the suffix so we can compare with semver. Note: this means
|
||||
// "0.4.0" vs "0.4.0.post1" both coerce to "0.4.0" and compare equal — a
|
||||
// post-release alone is not treated as outdated.
|
||||
function isOutdated(installed: string, required: string): boolean {
|
||||
const installedSemver = coerce(installed)
|
||||
const requiredSemver = coerce(required)
|
||||
if (!installedSemver || !requiredSemver) return false
|
||||
return gt(requiredSemver, installedSemver)
|
||||
}
|
||||
|
||||
export const useVersionCompatibilityStore = defineStore(
|
||||
'versionCompatibility',
|
||||
() => {
|
||||
@@ -25,16 +46,8 @@ export const useVersionCompatibilityStore = defineStore(
|
||||
)
|
||||
|
||||
const isFrontendOutdated = computed(() => {
|
||||
if (
|
||||
!frontendVersion.value ||
|
||||
!requiredFrontendVersion.value ||
|
||||
!valid(frontendVersion.value) ||
|
||||
!valid(requiredFrontendVersion.value)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Returns true if required version is greater than frontend version
|
||||
return gt(requiredFrontendVersion.value, frontendVersion.value)
|
||||
if (!frontendVersion.value || !requiredFrontendVersion.value) return false
|
||||
return isOutdated(frontendVersion.value, requiredFrontendVersion.value)
|
||||
})
|
||||
|
||||
const isFrontendNewer = computed(() => {
|
||||
@@ -43,19 +56,48 @@ export const useVersionCompatibilityStore = defineStore(
|
||||
return false
|
||||
})
|
||||
|
||||
const outdatedComfyPackages = computed<OutdatedComfyPackage[]>(() => {
|
||||
const packages =
|
||||
systemStatsStore.systemStats?.system?.comfy_package_versions ?? []
|
||||
const out: OutdatedComfyPackage[] = []
|
||||
for (const pkg of packages) {
|
||||
if (pkg.name === FRONTEND_PACKAGE_NAME) continue
|
||||
if (!pkg.installed || !pkg.required) continue
|
||||
if (!isOutdated(pkg.installed, pkg.required)) continue
|
||||
out.push({
|
||||
name: pkg.name,
|
||||
installed: pkg.installed,
|
||||
required: pkg.required
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const hasVersionMismatch = computed(() => {
|
||||
return isFrontendOutdated.value
|
||||
return isFrontendOutdated.value || outdatedComfyPackages.value.length > 0
|
||||
})
|
||||
|
||||
const versionKey = computed(() => {
|
||||
if (!frontendVersion.value) return null
|
||||
if (
|
||||
!frontendVersion.value ||
|
||||
!backendVersion.value ||
|
||||
!requiredFrontendVersion.value
|
||||
!backendVersion.value &&
|
||||
!requiredFrontendVersion.value &&
|
||||
outdatedComfyPackages.value.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
|
||||
const baseKey = `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
|
||||
if (outdatedComfyPackages.value.length === 0) return baseKey
|
||||
const packageKey = [...outdatedComfyPackages.value]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.name.localeCompare(b.name) ||
|
||||
a.installed.localeCompare(b.installed) ||
|
||||
a.required.localeCompare(b.required)
|
||||
)
|
||||
.map((pkg) => `${pkg.name}@${pkg.installed}->${pkg.required}`)
|
||||
.join(',')
|
||||
return `${baseKey}-${packageKey}`
|
||||
})
|
||||
|
||||
// Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage
|
||||
@@ -111,6 +153,14 @@ export const useVersionCompatibilityStore = defineStore(
|
||||
return null
|
||||
})
|
||||
|
||||
const packageWarningMessages = computed(() =>
|
||||
outdatedComfyPackages.value.map((pkg) => ({
|
||||
name: pkg.name,
|
||||
installedVersion: pkg.installed,
|
||||
requiredVersion: pkg.required
|
||||
}))
|
||||
)
|
||||
|
||||
async function checkVersionCompatibility() {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await until(systemStatsStore.isInitialized)
|
||||
@@ -120,11 +170,13 @@ export const useVersionCompatibilityStore = defineStore(
|
||||
function dismissWarning() {
|
||||
if (!versionKey.value) return
|
||||
|
||||
const dismissUntil = Date.now() + DISMISSAL_DURATION_MS
|
||||
dismissalStorage.value = {
|
||||
...dismissalStorage.value,
|
||||
[versionKey.value]: dismissUntil
|
||||
const now = Date.now()
|
||||
const pruned: Record<string, number> = {}
|
||||
for (const [key, until] of Object.entries(dismissalStorage.value)) {
|
||||
if (until > now) pruned[key] = until
|
||||
}
|
||||
pruned[versionKey.value] = now + DISMISSAL_DURATION_MS
|
||||
dismissalStorage.value = pruned
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
@@ -138,6 +190,8 @@ export const useVersionCompatibilityStore = defineStore(
|
||||
hasVersionMismatch,
|
||||
shouldShowWarning,
|
||||
warningMessage,
|
||||
packageWarningMessages,
|
||||
outdatedComfyPackages,
|
||||
isFrontendOutdated,
|
||||
isFrontendNewer,
|
||||
checkVersionCompatibility,
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
|
||||
})
|
||||
|
||||
it('does not call import when user chooses open-only', async () => {
|
||||
@@ -348,7 +348,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
|
||||
})
|
||||
|
||||
it('restores preserved share query before loading', async () => {
|
||||
|
||||
@@ -161,7 +161,8 @@ export function useSharedWorkflowUrlLoader() {
|
||||
if (result.action === 'copy-and-open' && nonOwnedAssets.length > 0) {
|
||||
try {
|
||||
await workflowShareService.importPublishedAssets(
|
||||
nonOwnedAssets.map((a) => a.id)
|
||||
nonOwnedAssets.map((a) => a.id),
|
||||
payload.shareId
|
||||
)
|
||||
} catch (importError) {
|
||||
console.error(
|
||||
|
||||
@@ -334,16 +334,45 @@ describe(useWorkflowShareService, () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('imports published assets via POST /assets/import', async () => {
|
||||
it('imports published assets via POST /assets/import with share_id', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
await service.importPublishedAssets(['pa-1', 'pa-2'])
|
||||
await service.importPublishedAssets(['pa-1', 'pa-2'], 'share-id-1')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: ['pa-1', 'pa-2'] })
|
||||
body: JSON.stringify({
|
||||
published_asset_ids: ['pa-1', 'pa-2'],
|
||||
share_id: 'share-id-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('omits share_id from the payload when not provided', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
await service.importPublishedAssets(['pa-1'])
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: ['pa-1'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('omits share_id from the payload when shareId is an empty string', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
await service.importPublishedAssets(['pa-1'], '')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: ['pa-1'] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -352,9 +381,9 @@ describe(useWorkflowShareService, () => {
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
|
||||
await expect(service.importPublishedAssets(['bad-id'])).rejects.toThrow(
|
||||
'Failed to import assets: 400'
|
||||
)
|
||||
await expect(
|
||||
service.importPublishedAssets(['bad-id'], 'share-id-1')
|
||||
).rejects.toThrow('Failed to import assets: 400')
|
||||
})
|
||||
|
||||
it('throws when shared workflow payload is invalid', async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ImportPublishedAssetsRequest } from '@comfyorg/ingest-types'
|
||||
|
||||
import type {
|
||||
PublishPrefill,
|
||||
SharedWorkflowPayload,
|
||||
@@ -255,11 +257,19 @@ export function useWorkflowShareService() {
|
||||
return workflow
|
||||
}
|
||||
|
||||
async function importPublishedAssets(assetIds: string[]): Promise<void> {
|
||||
async function importPublishedAssets(
|
||||
assetIds: string[],
|
||||
shareId?: string
|
||||
): Promise<void> {
|
||||
const body: ImportPublishedAssetsRequest = {
|
||||
published_asset_ids: assetIds,
|
||||
...(shareId ? { share_id: shareId } : {})
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: assetIds })
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -46,10 +46,17 @@ function createMockPointerEvent(
|
||||
return mockEvent as PointerEvent
|
||||
}
|
||||
|
||||
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
|
||||
function createMockWheelEvent(
|
||||
ctrlKey = false,
|
||||
metaKey = false,
|
||||
deltaX = 0,
|
||||
deltaY = 0
|
||||
): WheelEvent {
|
||||
const mockEvent: Partial<WheelEvent> = {
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
deltaX,
|
||||
deltaY,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
@@ -222,5 +229,107 @@ describe('useCanvasInteractions', () => {
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
|
||||
/** Regression: trackpad pinch-zoom inside a focused textarea must not
|
||||
* fall through to browser page zoom in non-standard navigation modes. */
|
||||
it.for(['legacy', 'custom'])(
|
||||
'should forward ctrl+wheel to canvas when capture element IS focused in %s mode',
|
||||
(mode) => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue(mode)
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(true)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
}
|
||||
)
|
||||
|
||||
it('should forward meta+wheel to canvas when capture element IS focused', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, true)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
|
||||
/** Regression: trackpad two-finger horizontal swipes inside a focused
|
||||
* textarea must not fall through to browser back/forward navigation. */
|
||||
it.for(['standard', 'legacy', 'custom'])(
|
||||
'should forward horizontal-dominant wheel to canvas when capture element IS focused in %s mode',
|
||||
(mode) => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue(mode)
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, false, 30, 5)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
}
|
||||
)
|
||||
|
||||
it('should NOT forward vertical-dominant wheel when capture element IS focused', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, false, 0, 30)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -41,30 +42,34 @@ export function useCanvasInteractions() {
|
||||
return !!(captureElement && active && captureElement.contains(active))
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward to canvas when the event is not consumed by a focused widget,
|
||||
* or when it is a canvas gesture (which must override widget consumption
|
||||
* to prevent destructive browser defaults).
|
||||
*/
|
||||
const shouldForwardWheelEvent = (event: WheelEvent): boolean =>
|
||||
!wheelCapturedByFocusedElement(event) ||
|
||||
(isStandardNavMode.value && (event.ctrlKey || event.metaKey))
|
||||
!wheelCapturedByFocusedElement(event) || isCanvasGestureWheel(event)
|
||||
|
||||
/**
|
||||
* Handles wheel events from UI components that should be forwarded to canvas
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom, two-finger pan in standard
|
||||
* mode; all wheel events in legacy mode).
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!shouldForwardWheelEvent(event)) return
|
||||
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
forwardEventToCanvas(event)
|
||||
// In standard mode, only canvas gestures (zoom/pan) are forwarded;
|
||||
// vertical wheel falls through so the document/widget scrolls normally.
|
||||
// The re-check is intentional and NOT redundant with shouldForwardWheelEvent:
|
||||
// that function also returns true for unfocused vertical wheel (its
|
||||
// `!wheelCapturedByFocusedElement` branch), which here must stay native.
|
||||
if (isStandardNavMode.value) {
|
||||
if (isCanvasGestureWheel(event)) forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, let the component handle it normally
|
||||
// In legacy mode, all forwardable wheel events go to canvas for zoom/pan.
|
||||
forwardEventToCanvas(event)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -94,14 +94,59 @@ describe('FormDropdownMenu', () => {
|
||||
})
|
||||
|
||||
it('has data-capture-wheel="true" on the root element', () => {
|
||||
const { container } = render(FormDropdownMenu, {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
container.firstElementChild!.getAttribute('data-capture-wheel')
|
||||
screen
|
||||
.getByTestId('form-dropdown-menu')
|
||||
.getAttribute('data-capture-wheel')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
/** Regression: PrimeVue Popover teleports the menu to document.body, so
|
||||
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
|
||||
* itself rather than relying on the LGraphNode wheel handler. */
|
||||
it.for([
|
||||
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
|
||||
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
|
||||
])('suppresses browser default for $name', ({ overrides }) => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.entries(overrides).forEach(([key, value]) => {
|
||||
Object.defineProperty(event, key, { value })
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
/** Vertical scrolling must remain native so the dropdown's own scroll
|
||||
* container can scroll its content. */
|
||||
it('does not suppress vertical scroll', () => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: 30,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -93,12 +94,25 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
key: String(item.id)
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
|
||||
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
|
||||
* Suppress only the destructive browser defaults (page zoom on pinch and
|
||||
* back/forward on horizontal swipe); regular vertical scrolling still
|
||||
* scrolls the dropdown's own content.
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (isCanvasGestureWheel(event)) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
|
||||
data-capture-wheel="true"
|
||||
data-testid="form-dropdown-menu"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from '@/platform/assets/types/filterTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import FormSearchInput from '../FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.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">
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchQuery"
|
||||
autofocus
|
||||
:class="
|
||||
|
||||
@@ -238,6 +238,12 @@ const zDeviceStats = z.object({
|
||||
torch_vram_free: z.number()
|
||||
})
|
||||
|
||||
const zComfyPackageVersion = z.object({
|
||||
name: z.string(),
|
||||
installed: z.string().nullable(),
|
||||
required: z.string().nullable()
|
||||
})
|
||||
|
||||
const zSystemStats = z.object({
|
||||
system: z.object({
|
||||
os: z.string(),
|
||||
@@ -254,7 +260,8 @@ const zSystemStats = z.object({
|
||||
comfyui_frontend_version: z.string().optional(),
|
||||
workflow_templates_version: z.string().optional(),
|
||||
installed_templates_version: z.string().optional(),
|
||||
required_templates_version: z.string().optional()
|
||||
required_templates_version: z.string().optional(),
|
||||
comfy_package_versions: z.array(zComfyPackageVersion).optional()
|
||||
}),
|
||||
devices: z.array(zDeviceStats)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -34,7 +33,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('dialogService Reka renderer opt-in (Phase 1)', () => {
|
||||
describe('dialogService Reka renderer opt-in', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
@@ -60,4 +59,24 @@ describe('dialogService Reka renderer opt-in (Phase 1)', () => {
|
||||
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,6 +99,8 @@ export const useDialogService = () => {
|
||||
component: ErrorDialogContent,
|
||||
props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'lg',
|
||||
onClose: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_closed'
|
||||
@@ -163,6 +165,8 @@ export const useDialogService = () => {
|
||||
component: ErrorDialogContent,
|
||||
props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'lg',
|
||||
onClose: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_closed'
|
||||
|
||||
@@ -5,11 +5,16 @@ import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
InputWidgetConfig,
|
||||
LinearInput,
|
||||
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 type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
@@ -100,6 +105,29 @@ 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>
|
||||
@@ -107,6 +135,7 @@ 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]
|
||||
@@ -163,6 +192,28 @@ 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', () => {
|
||||
@@ -202,33 +253,36 @@ 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) =>
|
||||
@@ -294,7 +348,11 @@ describe('appModeStore', () => {
|
||||
// Initially nodes are not resolvable — pruning removes them
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
const inputs: [number, string][] = [[1, 'seed']]
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(inputs, [1])
|
||||
workflowStore.activeWorkflow = createWorkflowWithLinearData(
|
||||
'app',
|
||||
inputs,
|
||||
[1]
|
||||
)
|
||||
store.loadSelections({ inputs })
|
||||
await nextTick()
|
||||
|
||||
@@ -324,6 +382,141 @@ 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()
|
||||
@@ -353,15 +546,22 @@ 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 })
|
||||
|
||||
store.selectedOutputs.push(1)
|
||||
await nextTick()
|
||||
try {
|
||||
store.selectedOutputs.push(1)
|
||||
await nextTick()
|
||||
} finally {
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
|
||||
})
|
||||
|
||||
it('calls captureCanvasState when input is selected', async () => {
|
||||
@@ -428,6 +628,18 @@ 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'])
|
||||
@@ -443,6 +655,17 @@ 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,14 +49,13 @@ 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: app.rootGraph
|
||||
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
: rawInputs,
|
||||
outputs: app.rootGraph
|
||||
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
: rawOutputs
|
||||
inputs: rawInputs.filter(([nodeId]) => resolveNode(nodeId)),
|
||||
outputs: rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +69,10 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
const { activeWorkflow } = workflowStore
|
||||
if (!activeWorkflow) return
|
||||
|
||||
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
|
||||
const source =
|
||||
activeWorkflow.changeTracker?.activeState?.extra?.linearData ??
|
||||
activeWorkflow.initialState?.extra?.linearData
|
||||
loadSelections(source)
|
||||
}
|
||||
|
||||
useEventListener(
|
||||
|
||||
@@ -212,8 +212,24 @@ describe('formatShortMonthDay', () => {
|
||||
describe('formatClockTime', () => {
|
||||
it('formats time with hours, minutes, and seconds', () => {
|
||||
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
|
||||
const result = formatClockTime(ts, 'en-GB')
|
||||
const result = formatClockTime(ts, 'en-GB', 'en-GB')
|
||||
// en-GB uses 24-hour format
|
||||
expect(result).toBe('14:05:06')
|
||||
})
|
||||
|
||||
it('uses app locale with browser/system hour-cycle preference', () => {
|
||||
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
|
||||
const hourCycle = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric'
|
||||
}).resolvedOptions().hourCycle
|
||||
const options = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle
|
||||
} satisfies Intl.DateTimeFormatOptions
|
||||
const expected = new Intl.DateTimeFormat('es', options).format(ts)
|
||||
|
||||
expect(formatClockTime(ts, 'es')).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,17 +84,30 @@ export const formatShortMonthDay = (ts: number, locale: string): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized clock time, e.g. "10:05:06" with locale defaults for 12/24 hour.
|
||||
* Localized clock time, e.g. "10:05:06" with the app locale for language and
|
||||
* the browser/system locale preference for 12/24-hour formatting.
|
||||
*
|
||||
* @param ts Unix timestamp in milliseconds
|
||||
* @param locale BCP-47 locale string
|
||||
* @param clockPreferenceLocale Optional locale source for hour-cycle preference
|
||||
* @returns Localized time string
|
||||
*/
|
||||
export const formatClockTime = (ts: number, locale: string): string => {
|
||||
export const formatClockTime = (
|
||||
ts: number,
|
||||
locale: string,
|
||||
clockPreferenceLocale?: string
|
||||
): string => {
|
||||
const d = new Date(ts)
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
const hourCycle = new Intl.DateTimeFormat(clockPreferenceLocale, {
|
||||
hour: 'numeric'
|
||||
}).resolvedOptions().hourCycle
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(d)
|
||||
}
|
||||
if (hourCycle) {
|
||||
options.hourCycle = hourCycle
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale, options).format(d)
|
||||
}
|
||||
|
||||