Compare commits
58 Commits
claude/sla
...
version-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
592cccd163 | ||
|
|
1d14eadc70 | ||
|
|
bcb2bceb65 | ||
|
|
4558ca7f17 | ||
|
|
c2e3b8a841 | ||
|
|
5db2f20312 | ||
|
|
76b63dbcb1 | ||
|
|
5cd62c0b51 | ||
|
|
1d948db4a9 | ||
|
|
7bc05bdece | ||
|
|
bca95177e2 | ||
|
|
84780becf7 | ||
|
|
44d0159c82 | ||
|
|
3b766ac98c | ||
|
|
4a128585a8 | ||
|
|
613b660786 | ||
|
|
a7761cac77 | ||
|
|
ea98a480d7 | ||
|
|
d9e930bd1c | ||
|
|
53e7abcc4a | ||
|
|
caa5574ba7 | ||
|
|
3b2c8d541b | ||
|
|
503eb72c8b | ||
|
|
96823b6f58 | ||
|
|
a6adab43cc | ||
|
|
d913a3e4b8 | ||
|
|
9cea37fed2 | ||
|
|
125bd01a61 | ||
|
|
5c2a8b741e | ||
|
|
5088defdf0 | ||
|
|
99099b5a79 | ||
|
|
d1ad5a6093 | ||
|
|
9fd8455b92 | ||
|
|
19b1151b84 | ||
|
|
d1fb972c82 | ||
|
|
00490e8d94 | ||
|
|
e5a4443653 | ||
|
|
094c4c4871 | ||
|
|
6ab6e78497 | ||
|
|
602784a672 | ||
|
|
22eefc4222 | ||
|
|
e181ec95b0 | ||
|
|
c5f42b0862 | ||
|
|
32fff22eb1 | ||
|
|
e29f9b6800 | ||
|
|
c1262e3bb2 | ||
|
|
69aa9ae2d7 | ||
|
|
fa652592b4 | ||
|
|
9f2de249f4 | ||
|
|
114c2ef182 | ||
|
|
dd0aff5865 | ||
|
|
c723ee4891 | ||
|
|
3bea20e755 | ||
|
|
3e97dde185 | ||
|
|
f0fbb55a0a | ||
|
|
d37023bf5e | ||
|
|
a28cb69a73 | ||
|
|
cd7d627ef4 |
12
.github/workflows/ci-lint-format.yaml
vendored
@@ -26,14 +26,6 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Detect browser_tests changes
|
||||
id: changed-paths
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
browser_tests:
|
||||
- 'browser_tests/**'
|
||||
|
||||
- name: Run ESLint with auto-fix
|
||||
run: pnpm lint:fix
|
||||
|
||||
@@ -68,10 +60,6 @@ jobs:
|
||||
pnpm format:check
|
||||
pnpm knip
|
||||
|
||||
- name: Typecheck browser tests
|
||||
if: steps.changed-paths.outputs.browser_tests == 'true'
|
||||
run: pnpm typecheck:browser
|
||||
|
||||
- name: Comment on PR about auto-fix
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
|
||||
118
.github/workflows/ci-oss-assets-validation.yaml
vendored
@@ -1,118 +0,0 @@
|
||||
name: 'CI: OSS Assets Validation'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
push:
|
||||
branches: [main, dev*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-fonts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
env:
|
||||
DISTRIBUTION: localhost
|
||||
|
||||
- name: Check for proprietary fonts in dist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Checking dist for proprietary ABCROM fonts...'
|
||||
|
||||
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
|
||||
echo '❌ ERROR: dist/ directory missing or empty!'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for ABCROM font files
|
||||
if find dist/ -type f -iname '*abcrom*' \
|
||||
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \) \
|
||||
-print -quit | grep -q .; then
|
||||
echo ''
|
||||
echo '❌ ERROR: Found proprietary ABCROM font files in dist!'
|
||||
echo ''
|
||||
find dist/ -type f -iname '*abcrom*' \
|
||||
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \)
|
||||
echo ''
|
||||
echo 'ABCROM fonts are proprietary and should not ship to OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use conditional font loading based on isCloud'
|
||||
echo '2. Ensure fonts are dynamically imported, not bundled'
|
||||
echo '3. Check vite config for font handling'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '✅ No proprietary fonts found in dist'
|
||||
|
||||
validate-licenses:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate production dependency licenses
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Checking production dependency licenses...'
|
||||
|
||||
# Use license-checker-rseidelsohn (actively maintained fork, handles monorepos)
|
||||
# Exclude internal @comfyorg packages from license check
|
||||
# Run in if condition to capture exit code
|
||||
if npx license-checker-rseidelsohn@4 \
|
||||
--production \
|
||||
--summary \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
|
||||
echo ''
|
||||
echo '✅ All production dependency licenses are approved!'
|
||||
else
|
||||
echo ''
|
||||
echo '❌ ERROR: Found dependencies with non-approved licenses!'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Check the license of the problematic package'
|
||||
echo '2. Find an alternative package with an approved license'
|
||||
echo '3. If the license is safe and OSI-approved, add it to the --onlyAllow list'
|
||||
echo ''
|
||||
echo 'For more info on OSI-approved licenses:'
|
||||
echo 'https://opensource.org/licenses'
|
||||
exit 1
|
||||
fi
|
||||
6
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
workflows: ['CI: Tests E2E']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -60,7 +63,8 @@ jobs:
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
"starting" \
|
||||
"$(date -u '${{ env.DATE_FORMAT }}')"
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
|
||||
9
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -4,10 +4,8 @@ name: 'CI: Tests E2E'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -184,6 +182,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get start time
|
||||
id: start-time
|
||||
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -192,7 +194,8 @@ jobs:
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting"
|
||||
"starting" \
|
||||
"${{ steps.start-time.outputs.time }}"
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
workflows: ['CI: Tests Storybook']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -60,7 +63,8 @@ jobs:
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
"starting" \
|
||||
"$(date -u '${{ env.DATE_FORMAT }}')"
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
|
||||
3
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -24,7 +24,8 @@ jobs:
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting"
|
||||
"starting" \
|
||||
"$(date -u '+%m/%d/%Y, %I:%M:%S %p')"
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
|
||||
2
.github/workflows/ci-tests-unit.yaml
vendored
@@ -4,10 +4,8 @@ name: 'CI: Tests Unit'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
10
.github/workflows/release-draft-create.yaml
vendored
@@ -53,7 +53,13 @@ jobs:
|
||||
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
# Desktop-specific release artifact with desktop distribution flags.
|
||||
DISTRIBUTION=desktop pnpm build
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -62,6 +68,7 @@ jobs:
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
|
||||
draft_release:
|
||||
needs: build
|
||||
@@ -79,6 +86,7 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
target_commitish: ${{ github.event.pull_request.base.ref }}
|
||||
make_latest: >-
|
||||
|
||||
1
.gitignore
vendored
@@ -64,7 +64,6 @@ browser_tests/local/
|
||||
dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
],
|
||||
"no-control-regex": "off",
|
||||
"no-eval": "off",
|
||||
"no-eval": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
|
||||
@@ -90,6 +90,7 @@ const preview: Preview = {
|
||||
{ value: 'light', icon: 'sun', title: 'Light' },
|
||||
{ value: 'dark', icon: 'moon', title: 'Dark' }
|
||||
],
|
||||
showName: true,
|
||||
dynamicTitle: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,6 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
|
||||
|
||||
The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Package Manager
|
||||
|
||||
This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g., `pnpm test:unit`, `pnpm lint`). To run arbitrary packages not in scripts, use `pnpx` or `pnpm dlx` — never `npx`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm dev`: Start Vite dev server.
|
||||
|
||||
@@ -61,8 +61,7 @@
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite build --config vite.config.mts"
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="relative overflow-hidden h-full w-full bg-neutral-900"
|
||||
>
|
||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||
<div ref="terminalEl" class="h-full terminal-host" />
|
||||
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
|
||||
<div class="p-terminal size-full rounded-none p-2">
|
||||
<div ref="terminalEl" class="terminal-host h-full" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
@@ -16,7 +13,7 @@
|
||||
size="small"
|
||||
:class="
|
||||
cn('absolute top-2 right-8 transition-opacity', {
|
||||
'opacity-0 pointer-events-none select-none': !isHovered
|
||||
'pointer-events-none opacity-0 select-none': !isHovered
|
||||
})
|
||||
"
|
||||
:aria-label="tooltipText"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-8 select-none">
|
||||
<!-- Installation Path Section -->
|
||||
<div class="grow flex flex-col gap-6 text-neutral-300">
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
<div class="flex grow flex-col gap-6 text-neutral-300">
|
||||
<h2 class="text-center font-inter text-3xl font-bold text-neutral-100">
|
||||
{{ $t('install.locationPicker.title') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-center text-neutral-400 px-12">
|
||||
<p class="px-12 text-center text-neutral-400">
|
||||
{{ $t('install.locationPicker.subtitle') }}
|
||||
</p>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<InputText
|
||||
v-model="installPath"
|
||||
:placeholder="$t('install.locationPicker.pathPlaceholder')"
|
||||
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
|
||||
class="flex-1 border-neutral-700 bg-neutral-800/50 text-neutral-200 placeholder:text-neutral-500"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validatePath"
|
||||
@focus="onFocus"
|
||||
@@ -23,7 +23,7 @@
|
||||
<Button
|
||||
icon="pi pi-folder-open"
|
||||
severity="secondary"
|
||||
class="bg-neutral-700 hover:bg-neutral-600 border-0"
|
||||
class="border-0 bg-neutral-700 hover:bg-neutral-600"
|
||||
@click="browsePath"
|
||||
/>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@
|
||||
<Message
|
||||
v-if="pathError"
|
||||
severity="error"
|
||||
class="whitespace-pre-line w-full"
|
||||
class="w-full whitespace-pre-line"
|
||||
>
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<img
|
||||
v-if="task.headerImg"
|
||||
:src="task.headerImg"
|
||||
class="h-full w-full object-contain px-4 pt-4 opacity-25"
|
||||
class="size-full object-contain px-4 pt-4 opacity-25"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<i
|
||||
v-if="!isLoading && runner.state === 'OK'"
|
||||
class="task-card-ok pi pi-check"
|
||||
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 z-10 col-span-full row-span-full text-[4rem] text-green-500 opacity-100 transition-opacity [text-shadow:0.25rem_0_0.5rem_black] group-hover/task-card:opacity-20"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template v-if="filter.tasks.length === 0">
|
||||
<!-- Empty filter -->
|
||||
<Divider />
|
||||
<p class="text-neutral-400 w-full text-center">
|
||||
<p class="w-full text-center text-neutral-400">
|
||||
{{ $t('maintenance.allOk') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<!-- Display: Cards -->
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
||||
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
|
||||
<TaskCard
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
@@ -45,7 +45,8 @@ import { useConfirm, useToast } from 'primevue'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type {
|
||||
MaintenanceFilter,
|
||||
@@ -55,6 +56,7 @@ import type {
|
||||
import TaskCard from './TaskCard.vue'
|
||||
import TaskListItem from './TaskListItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -80,8 +82,7 @@ const executeTask = async (task: MaintenanceTask) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('maintenance.error.toastTitle'),
|
||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
||||
life: 10_000
|
||||
detail: message ?? t('maintenance.error.defaultDescription')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
|
||||
<h1 class="font-inter font-semibold text-xl m-0 italic">
|
||||
{{ t(`desktopDialogs.${id}.title`, title) }}
|
||||
<div class="flex size-full flex-col justify-between rounded-lg p-6">
|
||||
<h1 class="m-0 font-inter text-xl font-semibold italic">
|
||||
{{ $t(`desktopDialogs.${id}.title`, title) }}
|
||||
</h1>
|
||||
<p class="whitespace-pre-wrap">
|
||||
{{ t(`desktopDialogs.${id}.message`, message) }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div
|
||||
class="h-screen w-screen grid items-center justify-around overflow-y-auto"
|
||||
class="grid h-screen w-screen items-center justify-around overflow-y-auto"
|
||||
>
|
||||
<div class="relative m-8 text-center">
|
||||
<!-- Header -->
|
||||
@@ -13,7 +13,7 @@
|
||||
<span>{{ t('desktopUpdate.description') }}</span>
|
||||
</div>
|
||||
|
||||
<ProgressSpinner class="m-8 w-48 h-48" />
|
||||
<ProgressSpinner class="m-8 size-48" />
|
||||
|
||||
<!-- Console button -->
|
||||
<Button
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<!-- Fixed height container with flexbox layout for proper content management -->
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="flex size-full flex-col">
|
||||
<Stepper
|
||||
v-model:value="currentStep"
|
||||
class="flex flex-col h-full"
|
||||
class="flex h-full flex-col"
|
||||
@update:value="handleStepChange"
|
||||
>
|
||||
<!-- Main content area that grows to fill available space -->
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<!-- Install footer with navigation -->
|
||||
<InstallFooter
|
||||
class="w-full max-w-2xl my-6 mx-auto"
|
||||
class="mx-auto my-6 w-full max-w-2xl"
|
||||
:current-step
|
||||
:can-proceed
|
||||
:disable-location-step="noGpu"
|
||||
|
||||
@@ -77,7 +77,7 @@ const createMockElectronAPI = () => {
|
||||
}
|
||||
|
||||
const ensureElectronAPI = () => {
|
||||
const globalWindow = window as { electronAPI?: unknown }
|
||||
const globalWindow = window as unknown as { electronAPI?: unknown }
|
||||
if (!globalWindow.electronAPI) {
|
||||
globalWindow.electronAPI = createMockElectronAPI()
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div
|
||||
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme overflow-y-auto"
|
||||
class="dark-theme grid h-screen min-h-full w-screen min-w-full justify-around overflow-y-auto bg-neutral-900 font-sans text-neutral-300"
|
||||
>
|
||||
<div class="max-w-(--breakpoint-sm) w-screen m-8 relative">
|
||||
<div class="relative m-8 w-screen max-w-(--breakpoint-sm)">
|
||||
<!-- Header -->
|
||||
<h1 class="backspan pi-wrench text-4xl font-bold">
|
||||
{{ t('maintenance.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="w-full flex flex-wrap gap-4 items-center">
|
||||
<div class="flex w-full flex-wrap items-center gap-4">
|
||||
<span class="grow">
|
||||
{{ t('maintenance.status') }}:
|
||||
<StatusTag :refreshing="isRefreshing" :error="anyErrors" />
|
||||
</span>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<SelectButton
|
||||
v-model="displayAsList"
|
||||
:options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]"
|
||||
@@ -56,10 +56,10 @@
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
<span>
|
||||
<strong class="block mb-1">
|
||||
<strong class="mb-1 block">
|
||||
{{ t('maintenance.unsafeMigration.title') }}
|
||||
</strong>
|
||||
<span class="block mb-1">
|
||||
<span class="mb-1 block">
|
||||
{{ unsafeReasonText }}
|
||||
</span>
|
||||
<span class="block text-sm text-neutral-400">
|
||||
@@ -71,13 +71,13 @@
|
||||
|
||||
<!-- Tasks -->
|
||||
<TaskListPanel
|
||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
||||
class="border-x-0 border-y border-solid border-neutral-700"
|
||||
:filter
|
||||
:display-as-list
|
||||
/>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between gap-4 flex-row">
|
||||
<div class="flex flex-row justify-between gap-4">
|
||||
<Button
|
||||
:label="t('maintenance.consoleLogs')"
|
||||
icon="pi pi-desktop"
|
||||
@@ -188,8 +188,7 @@ const completeValidation = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('maintenance.error.cannotContinue'),
|
||||
life: 5_000
|
||||
detail: t('maintenance.error.cannotContinue')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark hide-language-selector>
|
||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
||||
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
|
||||
<div
|
||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
||||
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold text-neutral-100">
|
||||
{{ $t('install.helpImprove') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
class="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
{{ $t('install.privacyPolicy') }} </a
|
||||
>.
|
||||
@@ -33,7 +33,7 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex pt-6 justify-end">
|
||||
<div class="flex justify-end pt-6">
|
||||
<Button
|
||||
:label="$t('g.ok')"
|
||||
icon="pi pi-check"
|
||||
@@ -72,8 +72,7 @@ const updateConsent = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('install.settings.errorUpdatingConsent'),
|
||||
detail: t('install.settings.errorUpdatingConsentDetail'),
|
||||
life: 3000
|
||||
detail: t('install.settings.errorUpdatingConsentDetail')
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/>
|
||||
|
||||
<div class="no-drag sad-text flex items-center">
|
||||
<div class="flex flex-col gap-8 p-8 min-w-110">
|
||||
<div class="flex min-w-110 flex-col gap-8 p-8">
|
||||
<!-- Header -->
|
||||
<h1 class="text-4xl font-bold text-red-500">
|
||||
{{ $t('notSupported.title') }}
|
||||
@@ -20,7 +20,7 @@
|
||||
<p class="text-xl">
|
||||
{{ $t('notSupported.message') }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-neutral-800">
|
||||
<ul class="list-inside list-disc space-y-1 text-neutral-800">
|
||||
<li>{{ $t('notSupported.supportedDevices.macos') }}</li>
|
||||
<li>{{ $t('notSupported.supportedDevices.windows') }}</li>
|
||||
</ul>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<BaseViewTemplate dark>
|
||||
<div class="relative min-h-screen">
|
||||
<!-- Terminal Background Layer (always visible during loading) -->
|
||||
<div v-if="!isError" class="fixed inset-0 overflow-hidden z-0">
|
||||
<div class="h-full w-full">
|
||||
<div v-if="!isError" class="fixed inset-0 z-0 overflow-hidden">
|
||||
<div class="size-full">
|
||||
<BaseTerminal @created="terminalCreated" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Semi-transparent overlay -->
|
||||
<div v-if="!isError" class="fixed inset-0 bg-neutral-900/80 z-5"></div>
|
||||
<div v-if="!isError" class="fixed inset-0 z-5 bg-neutral-900/80"></div>
|
||||
|
||||
<!-- Smooth radial gradient overlay -->
|
||||
<div
|
||||
@@ -45,9 +45,9 @@
|
||||
<!-- Error Section (positioned at bottom) -->
|
||||
<div
|
||||
v-if="isError"
|
||||
class="absolute bottom-20 left-0 right-0 flex flex-col items-center gap-4"
|
||||
class="absolute inset-x-0 bottom-20 flex flex-col items-center gap-4"
|
||||
>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<div class="flex justify-center gap-4">
|
||||
<Button
|
||||
icon="pi pi-flag"
|
||||
:label="$t('serverStart.reportIssue')"
|
||||
@@ -71,10 +71,10 @@
|
||||
<!-- Terminal Output (positioned at bottom when manually toggled in error state) -->
|
||||
<div
|
||||
v-if="terminalVisible && isError"
|
||||
class="absolute bottom-4 left-4 right-4 max-w-4xl mx-auto z-10"
|
||||
class="absolute inset-x-4 bottom-4 z-10 mx-auto max-w-4xl"
|
||||
>
|
||||
<div
|
||||
class="bg-neutral-900/95 rounded-lg p-4 border border-neutral-700 h-[300px]"
|
||||
class="h-[300px] rounded-lg border border-neutral-700 bg-neutral-900/95 p-4"
|
||||
>
|
||||
<BaseTerminal @created="terminalCreated" />
|
||||
</div>
|
||||
|
||||
760
browser_tests/assets/subgraphs/subgraph-nested-promotion.json
Normal file
@@ -0,0 +1,760 @@
|
||||
{
|
||||
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 18,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": [1031, 434],
|
||||
"size": [250, 178],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
},
|
||||
"widgets_values": [null, null, null]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"pos": [788, 433.5],
|
||||
"size": [225, 380],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["3", "string_a"],
|
||||
["4", "value"],
|
||||
["6", "value"],
|
||||
["6", "value_1"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [548, 451],
|
||||
"size": [225, 142],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"title": "Outer",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Outer\n"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[4, 1, 0, 5, 0, "STRING"],
|
||||
[5, 5, 0, 2, 0, "STRING"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 0",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [351, 432.5, 120, 120]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1352, 294.5, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"localized_name": "string_a",
|
||||
"pos": [451, 452.5]
|
||||
},
|
||||
{
|
||||
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [13],
|
||||
"pos": [451, 472.5]
|
||||
},
|
||||
{
|
||||
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [16],
|
||||
"pos": [451, 492.5]
|
||||
},
|
||||
{
|
||||
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
|
||||
"name": "value_1_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [18],
|
||||
"pos": [451, 512.5]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1372, 314.5]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [504, 437],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"title": "Inner 1",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 1\n"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [743, 325],
|
||||
"size": [347, 231],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"pos": [1115, 301],
|
||||
"size": [210, 196],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value_1"
|
||||
},
|
||||
"link": 18
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["5", "string_a"],
|
||||
["11", "value"],
|
||||
["9", "value"],
|
||||
["10", "string_a"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 4,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 6,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 6,
|
||||
"target_slot": 2,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 1",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [180, 739, 120, 100]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1246, 612, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "string_a",
|
||||
"pos": [280, 759]
|
||||
},
|
||||
{
|
||||
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [14],
|
||||
"pos": [280, 779]
|
||||
},
|
||||
{
|
||||
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [17],
|
||||
"pos": [280, 799]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [12],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1266, 632]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [334, 742],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 14
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"title": "Inner 2",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 2\n"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [581, 637],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"pos": [1004, 613],
|
||||
"size": [210, 142],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 17
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["7", "string_a"],
|
||||
["8", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": 10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 9,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 9,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 2",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [262, 1222, 120, 80]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "string_a",
|
||||
"pos": [362, 1242]
|
||||
},
|
||||
{
|
||||
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [15],
|
||||
"pos": [362, 1262]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1143.089999999999, 1145.1999999999998]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 8,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [412.96000000000004, 1228.2399999999996],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"title": "Inner 3",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 3\n"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [686.08, 1132.38],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": 8,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 8,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-412, 11]
|
||||
},
|
||||
"frontendVersion": "1.41.7"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
{
|
||||
"id": "43e9499c-2512-43b5-a5a1-2485eb65da32",
|
||||
"revision": 0,
|
||||
"last_node_id": 8,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [170.55728894250745, 515.6401487466619],
|
||||
"size": [282.8166809082031, 363.8333435058594],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [7, 9]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "21dea088-e1b4-47a4-a01f-3d1bf4504001",
|
||||
"pos": [500.2639113468392, 519.9960755960157],
|
||||
"size": [464.95001220703125, 615.8333129882812],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["2", "$$canvas-image-preview"],
|
||||
["4", "$$canvas-image-preview"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "a7a0350a-af99-4d26-9391-450b4f726206",
|
||||
"pos": [1000.5293620197185, 499.9253405678786],
|
||||
"size": [225, 359.8333435058594],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"name": "image2",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["6", "$$canvas-image-preview"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[7, 1, 0, 7, 0, "IMAGE"],
|
||||
[9, 1, 0, 8, 0, "IMAGE"],
|
||||
[10, 7, 0, 8, 1, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "21dea088-e1b4-47a4-a01f-3d1bf4504001",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 8,
|
||||
"lastLinkId": 10,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [297.7833638107301, 502.6302057820892, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1052.8175480718996, 502.6302057820892, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "afc8dbc3-12e6-4b3c-9840-9b398d06e6bd",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1, 2],
|
||||
"localized_name": "images",
|
||||
"pos": [397.7833638107301, 522.6302057820892]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "d0a84974-5f4d-4f4b-b23a-2e7288a9689d",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [5],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1072.8175480718996, 522.6302057820892]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PreviewImage",
|
||||
"pos": [767.8225773415076, 602.8695134060456],
|
||||
"size": [225, 303.8333435058594],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewImage",
|
||||
"pos": [754.9358989867657, 188.55375831225257],
|
||||
"size": [225, 303.8333435058594],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ImageInvert",
|
||||
"pos": [477.783932416778, 542.2440719627998],
|
||||
"size": [225, 71.83333587646484],
|
||||
"flags": {
|
||||
"collapsed": false
|
||||
},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image",
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [3, 5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 4,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 2,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "a7a0350a-af99-4d26-9391-450b4f726206",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 8,
|
||||
"lastLinkId": 10,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [973.7423316105073, 561.9744246746379, 120, 80]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1905.487372786412, 581.9744246746379, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "20ac4159-6814-4d40-a217-ea260152b689",
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [4],
|
||||
"localized_name": "image1",
|
||||
"pos": [1073.7423316105073, 581.9744246746379]
|
||||
},
|
||||
{
|
||||
"id": "c3759a8c-914e-4450-bc41-ca683ffce96b",
|
||||
"name": "image2",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [8],
|
||||
"localized_name": "image2",
|
||||
"shape": 7,
|
||||
"pos": [1073.7423316105073, 601.9744246746379]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "ImageStitch",
|
||||
"pos": [1153.7423085222254, 396.2033931749105],
|
||||
"size": [270, 225.1666717529297],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image1",
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "image2",
|
||||
"name": "image2",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [6]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageStitch"
|
||||
},
|
||||
"widgets_values": ["right", true, 0, "white"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1620.4874189629757, 529.9122050216333],
|
||||
"size": [225, 307.8333435058594],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 5,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 5,
|
||||
"target_slot": 1,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7269777827561446,
|
||||
"offset": [-35.273237658266034, -55.17394203309256]
|
||||
},
|
||||
"frontendVersion": "1.40.8"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
{
|
||||
"id": "preview-subgraph-test-001",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"pos": [318.6320139868054, 212.9091015141833],
|
||||
"size": [225, 368],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["10", "filename_prefix"],
|
||||
["10", "$$canvas-image-preview"]
|
||||
],
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.13.0",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.6.2",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [-0.5080003681592018, 211.3051121416672],
|
||||
"size": [282.8333435058594, 364],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {}
|
||||
},
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.13.0",
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [[2, 11, 0, 5, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [300, 350, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 350, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "img-slot-001",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": [400, 370]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [500.0046924937855, 300.0146992076527],
|
||||
"size": [315, 340],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.13.0",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.6.2",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.1819400303977265,
|
||||
"offset": [81.66005130613983, -19.028558221588725]
|
||||
},
|
||||
"frontendVersion": "1.40.3",
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": [],
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { Topbar } from './components/Topbar'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DebugHelper } from './helpers/DebugHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
@@ -173,6 +174,7 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly debug: DebugHelper
|
||||
public readonly subgraph: SubgraphHelper
|
||||
public readonly canvasOps: CanvasHelper
|
||||
public readonly nodeOps: NodeOperationsHelper
|
||||
@@ -204,9 +206,7 @@ export class ComfyPage {
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||
this.runButton = page
|
||||
.getByTestId(TestIds.topbar.queueButton)
|
||||
.getByRole('button', { name: 'Run' })
|
||||
this.runButton = page.getByTestId(TestIds.topbar.queueButton)
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
@@ -217,6 +217,7 @@ export class ComfyPage {
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.debug = new DebugHelper(page, this.canvas)
|
||||
this.subgraph = new SubgraphHelper(this)
|
||||
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
|
||||
this.nodeOps = new NodeOperationsHelper(this)
|
||||
|
||||
@@ -11,10 +11,7 @@ export class CommandHelper {
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ commandId, metadata }) => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
|
||||
return app.extensionManager.command.execute(commandId, {
|
||||
return window['app'].extensionManager.command.execute(commandId, {
|
||||
metadata
|
||||
})
|
||||
},
|
||||
|
||||
167
browser_tests/fixtures/helpers/DebugHelper.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { Locator, Page, TestInfo } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
|
||||
export interface DebugScreenshotOptions {
|
||||
fullPage?: boolean
|
||||
element?: 'canvas' | 'page'
|
||||
markers?: Array<{ position: Position; id?: string }>
|
||||
}
|
||||
|
||||
export class DebugHelper {
|
||||
constructor(
|
||||
private page: Page,
|
||||
private canvas: Locator
|
||||
) {}
|
||||
|
||||
async addMarker(
|
||||
position: Position,
|
||||
id: string = 'debug-marker'
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
([pos, markerId]) => {
|
||||
const existing = document.getElementById(markerId)
|
||||
if (existing) existing.remove()
|
||||
|
||||
const marker = document.createElement('div')
|
||||
marker.id = markerId
|
||||
marker.style.position = 'fixed'
|
||||
marker.style.left = `${pos.x - 10}px`
|
||||
marker.style.top = `${pos.y - 10}px`
|
||||
marker.style.width = '20px'
|
||||
marker.style.height = '20px'
|
||||
marker.style.border = '2px solid red'
|
||||
marker.style.borderRadius = '50%'
|
||||
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
|
||||
marker.style.pointerEvents = 'none'
|
||||
marker.style.zIndex = '10000'
|
||||
document.body.appendChild(marker)
|
||||
},
|
||||
[position, id] as const
|
||||
)
|
||||
}
|
||||
|
||||
async removeMarkers(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document
|
||||
.querySelectorAll('[id^="debug-marker"]')
|
||||
.forEach((el) => el.remove())
|
||||
})
|
||||
}
|
||||
|
||||
async attachScreenshot(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
options?: DebugScreenshotOptions
|
||||
): Promise<void> {
|
||||
if (options?.markers) {
|
||||
for (const marker of options.markers) {
|
||||
await this.addMarker(marker.position, marker.id)
|
||||
}
|
||||
}
|
||||
|
||||
let screenshot: Buffer
|
||||
const targetElement = options?.element || 'page'
|
||||
|
||||
if (targetElement === 'canvas') {
|
||||
screenshot = await this.canvas.screenshot()
|
||||
} else if (options?.fullPage) {
|
||||
screenshot = await this.page.screenshot({ fullPage: true })
|
||||
} else {
|
||||
screenshot = await this.page.screenshot()
|
||||
}
|
||||
|
||||
await testInfo.attach(name, {
|
||||
body: screenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
if (options?.markers) {
|
||||
await this.removeMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
async saveCanvasScreenshot(filename: string): Promise<void> {
|
||||
await this.page.evaluate(async (filename) => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
throw new Error('Failed to create blob from canvas')
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}, 'image/png')
|
||||
})
|
||||
}, filename)
|
||||
}
|
||||
|
||||
async getCanvasDataURL(): Promise<string> {
|
||||
return await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
return canvas.toDataURL('image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async showCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
const existingOverlay = document.getElementById('debug-canvas-overlay')
|
||||
if (existingOverlay) {
|
||||
existingOverlay.remove()
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'debug-canvas-overlay'
|
||||
overlay.style.position = 'fixed'
|
||||
overlay.style.top = '0'
|
||||
overlay.style.left = '0'
|
||||
overlay.style.zIndex = '9999'
|
||||
overlay.style.backgroundColor = 'white'
|
||||
overlay.style.padding = '10px'
|
||||
overlay.style.border = '2px solid red'
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.src = canvas.toDataURL('image/png')
|
||||
img.style.maxWidth = '800px'
|
||||
img.style.maxHeight = '600px'
|
||||
overlay.appendChild(img)
|
||||
|
||||
document.body.appendChild(overlay)
|
||||
})
|
||||
}
|
||||
|
||||
async hideCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const overlay = document.getElementById('debug-canvas-overlay')
|
||||
if (overlay) {
|
||||
overlay.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -115,16 +115,6 @@ export class DragDropHelper {
|
||||
const dragOverEvent = new DragEvent('dragover', eventOptions)
|
||||
const dropEvent = new DragEvent('drop', eventOptions)
|
||||
|
||||
const graphCanvasElement = document.querySelector('#graph-canvas')
|
||||
|
||||
// Keep Litegraph's drag-over node tracking in sync when the drop target is a
|
||||
// Vue node DOM overlay outside of the graph canvas element.
|
||||
if (graphCanvasElement && !graphCanvasElement.contains(targetElement)) {
|
||||
graphCanvasElement.dispatchEvent(
|
||||
new DragEvent('dragover', eventOptions)
|
||||
)
|
||||
}
|
||||
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
|
||||
@@ -33,10 +33,6 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => window.app!.graph.nodes.length)
|
||||
}
|
||||
|
||||
async getNodes(): Promise<LGraphNode[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
return window.app!.graph.nodes
|
||||
|
||||
@@ -36,7 +36,7 @@ export class SubgraphHelper {
|
||||
const currentGraph = app.canvas!.graph!
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (!('inputNode' in currentGraph)) {
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
@@ -88,7 +88,7 @@ export class SubgraphHelper {
|
||||
|
||||
if (node.onPointerDown) {
|
||||
node.onPointerDown(
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
|
||||
event as unknown as CanvasPointerEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
@@ -121,7 +121,7 @@ export class SubgraphHelper {
|
||||
|
||||
if (node.onPointerDown) {
|
||||
node.onPointerDown(
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
|
||||
event as unknown as CanvasPointerEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
@@ -129,7 +129,7 @@ export class SubgraphHelper {
|
||||
// Trigger double-click
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent
|
||||
event as unknown as CanvasPointerEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,12 @@ export const TestIds = {
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
missingNodes: 'missing-nodes-warning',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
},
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button'
|
||||
},
|
||||
nodeLibrary: {
|
||||
@@ -47,12 +47,8 @@ export const TestIds = {
|
||||
widgets: {
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
},
|
||||
templates: {
|
||||
content: 'template-workflows-content',
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
@@ -75,7 +71,6 @@ export type TestIdValue =
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
(typeof TestIds.templates)[keyof typeof TestIds.templates],
|
||||
(id: string) => string
|
||||
|
||||
@@ -128,8 +128,7 @@ class NodeSlotReference {
|
||||
nodeSize: [node.size[0], node.size[1]],
|
||||
rawConnectionPos: [rawPos[0], rawPos[1]],
|
||||
convertedPos: [convertedPos[0], convertedPos[1]],
|
||||
currentGraphType:
|
||||
'inputNode' in window.app!.canvas.graph! ? 'Subgraph' : 'LGraph'
|
||||
currentGraphType: window.app!.canvas.graph!.constructor.name
|
||||
}
|
||||
)
|
||||
|
||||
@@ -462,44 +461,18 @@ export class NodeReference {
|
||||
// Try multiple positions to avoid DOM widget interference
|
||||
const clickPositions = [
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
|
||||
{
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y + nodeSize.height / 2
|
||||
},
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
|
||||
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
|
||||
]
|
||||
|
||||
// Click the enter_subgraph title button (top-right of title bar).
|
||||
// This is more reliable than dblclick on the node body because
|
||||
// promoted DOM widgets can overlay the body and intercept events.
|
||||
const subgraphButtonPos = {
|
||||
x: nodePos.x + nodeSize.width - 15,
|
||||
y: nodePos.y - titleHeight / 2
|
||||
}
|
||||
|
||||
const checkIsInSubgraph = async () => {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
})
|
||||
}
|
||||
|
||||
await expect(async () => {
|
||||
// Try just clicking the enter button first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
position: subgraphButtonPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
interface TestWindow extends Window {
|
||||
__ws__?: Record<string, WebSocket>
|
||||
}
|
||||
|
||||
export const webSocketFixture = base.extend<{
|
||||
ws: { trigger(data: unknown, url?: string): Promise<void> }
|
||||
}>({
|
||||
|
||||
@@ -29,8 +29,10 @@ class ComfyQueueButton {
|
||||
public readonly dropdownButton: Locator
|
||||
constructor(public readonly actionbar: ComfyActionbar) {
|
||||
this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton)
|
||||
this.primaryButton = this.root.locator('.p-splitbutton-button')
|
||||
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
|
||||
this.primaryButton = this.root
|
||||
this.dropdownButton = actionbar.root.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
}
|
||||
|
||||
public async toggleOptions() {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
export function isPromotedWidgetEntry(
|
||||
entry: unknown
|
||||
): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizePromotedWidgets(
|
||||
value: unknown
|
||||
): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return node?.properties?.proxyWidgets ?? []
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<string[]> {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.map(([, widgetName]) => widgetName)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCount(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<number> {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.length
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCountByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
([id, name]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
return widgets.filter((widget) => widget.name === name).length
|
||||
},
|
||||
[nodeId, widgetName] as const
|
||||
)
|
||||
}
|
||||
@@ -14,9 +14,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.filename
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
.toBe(`*${workflowName} - ComfyUI`)
|
||||
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
|
||||
})
|
||||
|
||||
// Failing on CI
|
||||
@@ -53,7 +51,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
})
|
||||
|
||||
test('Can display default title', async ({ comfyPage }) => {
|
||||
await expect.poll(() => comfyPage.page.title()).toBe('ComfyUI')
|
||||
expect(await comfyPage.page.title()).toBe('ComfyUI')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,12 +160,12 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
})
|
||||
// Click empty space to trigger a change detection.
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
})
|
||||
|
||||
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,18 +245,11 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
if (typeof graph.serialize !== 'function') {
|
||||
throw new Error('app.graph.serialize is not available')
|
||||
}
|
||||
return graph.serialize() as {
|
||||
nodes: Array<{ bgcolor?: string; color?: string }>
|
||||
}
|
||||
return window['app'].graph.serialize()
|
||||
})
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
const nodes = parsed.nodes
|
||||
for (const node of nodes) {
|
||||
for (const node of parsed.nodes) {
|
||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { expect } from '@playwright/test'
|
||||
import type { Keybinding } from '../../src/platform/keybindings/types'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -16,9 +15,8 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
// Wait for the element with the .comfy-missing-nodes selector to be visible
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -27,9 +25,8 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
// Wait for the element with the .comfy-missing-nodes selector to be visible
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
|
||||
// Verify the missing node text includes subgraph context
|
||||
@@ -41,14 +38,13 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
|
||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
await comfyPage.page
|
||||
.locator('.p-dialog')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click({ force: true })
|
||||
await comfyPage.page.locator('.p-dialog').waitFor({ state: 'hidden' })
|
||||
|
||||
// Wait for any async operations to complete after dialog closes
|
||||
await comfyPage.nextFrame()
|
||||
@@ -59,14 +55,9 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
|
||||
// Undo and redo the change
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Execution error', () => {
|
||||
@@ -410,7 +401,7 @@ test.describe('Signin dialog', () => {
|
||||
test('Paste content to signin dialog should not paste node on canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeNum = await comfyPage.nodeOps.getNodeCount()
|
||||
const nodeNum = (await comfyPage.nodeOps.getNodes()).length
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyLatentWidgetClick
|
||||
})
|
||||
@@ -433,6 +424,6 @@ test.describe('Signin dialog', () => {
|
||||
await input.press('Control+v')
|
||||
await expect(input).toHaveValue('test_password')
|
||||
|
||||
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
|
||||
expect(await comfyPage.nodeOps.getNodes()).toHaveLength(nodeNum)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 100 KiB |
@@ -171,6 +171,7 @@ test.describe('Node Interaction', () => {
|
||||
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 135 KiB |
@@ -203,19 +203,19 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
await topbar.switchTheme('light')
|
||||
|
||||
// Verify menu stays open and Light theme shows as active
|
||||
await expect(async () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeSubmenu).toBeVisible()
|
||||
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
|
||||
}).toPass({ timeout: 5000 })
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeSubmenu).toBeVisible()
|
||||
|
||||
// Check that Light theme is active
|
||||
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
|
||||
|
||||
// Screenshot with light theme active
|
||||
await comfyPage.attachScreenshot('theme-menu-light-active')
|
||||
|
||||
// Verify ColorPalette setting is set to "light"
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
|
||||
.toBe('light')
|
||||
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
|
||||
'light'
|
||||
)
|
||||
|
||||
// Close menu to see theme change
|
||||
await topbar.closeTopbarMenu()
|
||||
@@ -228,22 +228,20 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
await topbar.switchTheme('dark')
|
||||
|
||||
// Verify menu stays open and Dark theme shows as active
|
||||
await expect(async () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeItems2.submenu).toBeVisible()
|
||||
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
|
||||
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(
|
||||
false
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeItems2.submenu).toBeVisible()
|
||||
|
||||
// Check that Dark theme is active and Light theme is not
|
||||
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
|
||||
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(false)
|
||||
|
||||
// Screenshot with dark theme active
|
||||
await comfyPage.attachScreenshot('theme-menu-dark-active')
|
||||
|
||||
// Verify ColorPalette setting is set to "dark"
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
|
||||
.toBe('dark')
|
||||
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
|
||||
'dark'
|
||||
)
|
||||
|
||||
// Close menu
|
||||
await topbar.closeTopbarMenu()
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
@@ -83,15 +83,17 @@ test.describe('Workflows sidebar', () => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
|
||||
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
const originalNodeCount = (await comfyPage.nodeOps.getNodes()).length
|
||||
|
||||
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
|
||||
.toEqual(originalNodeCount + 1)
|
||||
|
||||
await tab.getPersistedItem('workflow1.json').click()
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
|
||||
.toEqual(1)
|
||||
})
|
||||
|
||||
test('Can rename nested workflow from opened workflow item', async ({
|
||||
|
||||
@@ -53,7 +53,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
): Promise<boolean> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -555,6 +555,74 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
|
||||
// slot connected externally from the Outer node, so it should be
|
||||
// disabled. The remaining promoted textarea widgets (value, value_1)
|
||||
// are unlinked and should be enabled.
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(promotedNames).toContain('string_a')
|
||||
expect(promotedNames).toContain('value')
|
||||
|
||||
const disabledState = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('5')
|
||||
return (node?.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
disabled: !!w.computedDisabled
|
||||
}))
|
||||
})
|
||||
|
||||
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
|
||||
expect(linkedWidget?.disabled).toBe(true)
|
||||
|
||||
const unlinkedWidgets = disabledState.filter(
|
||||
(w) => w.name !== 'string_a'
|
||||
)
|
||||
for (const w of unlinkedWidgets) {
|
||||
expect(w.disabled).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The promoted textareas that are NOT externally linked should be
|
||||
// fully opaque and interactive.
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
|
||||
const count = await textareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = textareas.nth(i)
|
||||
const wrapper = textarea.locator('..')
|
||||
const opacity = await wrapper.evaluate(
|
||||
(el) => getComputedStyle(el).opacity
|
||||
)
|
||||
|
||||
if (opacity === '1' && (await textarea.isEditable())) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
comfyPage
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
@@ -1,27 +1,23 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '../../../../helpers/promotedWidgets'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
async function loadImageOnNode(
|
||||
comfyPage: Awaited<
|
||||
ReturnType<(typeof test)['info']>
|
||||
>['fixtures']['comfyPage']
|
||||
) {
|
||||
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
await comfyPage.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
@@ -30,31 +26,24 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
return imagePreview
|
||||
}
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('opens mask editor from image preview button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
const imagePreview = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.locator('[role="img"]').focus()
|
||||
await imagePreview.locator('[role="img"]').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('shows image context menu options', async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.lg-node-header')
|
||||
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
@@ -65,69 +54,4 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
await expect(contextMenu.getByText('Save Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'renders promoted image previews for each subgraph node',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const firstSubgraphNode = comfyPage.vueNodes.getNodeLocator('7')
|
||||
const secondSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
|
||||
|
||||
await expect(firstSubgraphNode).toBeVisible()
|
||||
await expect(secondSubgraphNode).toBeVisible()
|
||||
|
||||
const firstPromotedWidgets = await getPromotedWidgetNames(comfyPage, '7')
|
||||
const secondPromotedWidgets = await getPromotedWidgetNames(comfyPage, '8')
|
||||
expect(firstPromotedWidgets).toEqual([
|
||||
'$$canvas-image-preview',
|
||||
'$$canvas-image-preview'
|
||||
])
|
||||
expect(secondPromotedWidgets).toEqual(['$$canvas-image-preview'])
|
||||
|
||||
expect(
|
||||
await getPromotedWidgetCountByName(
|
||||
comfyPage,
|
||||
'7',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
).toBe(2)
|
||||
expect(
|
||||
await getPromotedWidgetCountByName(
|
||||
comfyPage,
|
||||
'8',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
).toBe(1)
|
||||
|
||||
await expect(
|
||||
firstSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
await expect(
|
||||
secondSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const firstPreviewImages = firstSubgraphNode.locator('.image-preview img')
|
||||
const secondPreviewImages =
|
||||
secondSubgraphNode.locator('.image-preview img')
|
||||
|
||||
await expect(firstPreviewImages).toHaveCount(2, { timeout: 30_000 })
|
||||
await expect(secondPreviewImages).toHaveCount(1, { timeout: 30_000 })
|
||||
|
||||
await expect(firstPreviewImages.first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(firstPreviewImages.nth(1)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(secondPreviewImages.first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-multiple-promoted-previews.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
@@ -9,20 +9,17 @@ test.describe('Vue Upload Widgets', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
test(
|
||||
'should hide canvas-only upload buttons',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByText('choose file to upload', { exact: true })
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading image').count())
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading video').count())
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-upload-widgets.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
@@ -1,58 +0,0 @@
|
||||
# Widget Serialization: `widget.serialize` vs `widget.options.serialize`
|
||||
|
||||
Two properties named `serialize` exist at different levels of a widget object. They control different serialization layers and are checked by completely different code paths.
|
||||
|
||||
**`widget.serialize`** — Controls **workflow persistence**. Checked by `LGraphNode.serialize()` and `configure()` when reading/writing `widgets_values` in the workflow JSON. When `false`, the widget is skipped in both serialization and deserialization. Used for UI-only widgets (image previews, progress text, audio players). Typed as `IBaseWidget.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
|
||||
|
||||
**`widget.options.serialize`** — Controls **prompt/API serialization**. Checked by `executionUtil.ts` when building the API payload sent to the backend. When `false`, the widget is excluded from prompt inputs. Used for client-side-only controls (`control_after_generate`, combo filter lists) that the server doesn't need. Typed as `IWidgetOptions.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
|
||||
|
||||
These correspond to the two data formats in `ComfyMetadata` embedded in output files (PNG, GLTF, WebM, AVIF, etc.): `widget.serialize` → `ComfyMetadataTags.WORKFLOW`, `widget.options.serialize` → `ComfyMetadataTags.PROMPT`.
|
||||
|
||||
## Permutation table
|
||||
|
||||
| `widget.serialize` | `widget.options.serialize` | In workflow? | In prompt? | Examples |
|
||||
| ------------------ | -------------------------- | ------------ | ---------- | -------------------------------------------------------------------- |
|
||||
| ✅ default | ✅ default | Yes | Yes | seed, cfg, sampler_name |
|
||||
| ✅ default | ❌ false | Yes | No | control_after_generate, combo filter list |
|
||||
| ❌ false | ✅ default | No | Yes | No current usage (would be a transient value computed at queue time) |
|
||||
| ❌ false | ❌ false | No | No | Image/video previews, audio players, progress text |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `addWidget('combo', name, value, cb, { serialize: false })` puts `serialize` into `widget.options`, **not** onto `widget` directly. These are different properties consumed by different systems.
|
||||
- `LGraphNode.serialize()` checks `widget.serialize === false` (line 967). It does **not** check `widget.options.serialize`. A widget with `options.serialize = false` is still included in `widgets_values`.
|
||||
- `LGraphNode.serialize()` only writes `widgets_values` if `this.widgets` is truthy. Nodes that create widgets dynamically (like `PrimitiveNode`) will have no `widgets_values` in serialized output if serialized before widget creation — even if `this.widgets_values` exists on the instance from a prior `configure()` call.
|
||||
- `widget.options.serialize` is typed as `IWidgetOptions.serialize` — both properties share the name `serialize` but live at different levels of the widget object.
|
||||
|
||||
## PrimitiveNode and copy/paste
|
||||
|
||||
`PrimitiveNode` creates widgets dynamically on connection — it starts as an empty polymorphic node and morphs to match its target widget in `_onFirstConnection()`. This interacts badly with the copy/paste pipeline.
|
||||
|
||||
### The clone→serialize gap
|
||||
|
||||
`LGraphCanvas._serializeItems()` copies nodes via `item.clone()?.serialize()` (line 3911). For PrimitiveNode this fails:
|
||||
|
||||
1. `clone()` calls `this.serialize()` on the **original** node (which has widgets, so `widgets_values` is captured correctly).
|
||||
2. `clone()` creates a **fresh** PrimitiveNode via `LiteGraph.createNode()` and calls `configure(data)` on it — this stores `widgets_values` on the instance.
|
||||
3. But the fresh PrimitiveNode has no `this.widgets` (widgets are created only on connection), so when `serialize()` is called on the clone, `LGraphNode.serialize()` skips the `widgets_values` block entirely (line 964: `if (widgets && this.serialize_widgets)`).
|
||||
|
||||
Result: `widgets_values` is silently dropped from the clipboard data.
|
||||
|
||||
### Why seed survives but control_after_generate doesn't
|
||||
|
||||
When the pasted PrimitiveNode reconnects to the pasted target node, `_createWidget()` copies `theirWidget.value` from the target (line 254). This restores the **primary** widget value (e.g., `seed`).
|
||||
|
||||
But `control_after_generate` is a **secondary** widget created by `addValueControlWidgets()`, which reads its initial value from `this.widgets_values?.[1]` (line 263). That value was lost during clone→serialize, so it falls back to `'fixed'` (line 265).
|
||||
|
||||
See [ADR-0006](adr/0006-primitive-node-copy-paste-lifecycle.md) for proposed fixes and design tradeoffs.
|
||||
|
||||
## Code references
|
||||
|
||||
- `widget.serialize` checked: `src/lib/litegraph/src/LGraphNode.ts` serialize() and configure()
|
||||
- `widget.options.serialize` checked: `src/utils/executionUtil.ts`
|
||||
- `widget.options.serialize` set: `src/scripts/widgets.ts` addValueControlWidgets()
|
||||
- `widget.serialize` set: `src/composables/node/useNodeImage.ts`, `src/extensions/core/previewAny.ts`, etc.
|
||||
- Metadata types: `src/types/metadataTypes.ts`
|
||||
- PrimitiveNode: `src/extensions/core/widgetInputs.ts`
|
||||
- Copy/paste serialization: `src/lib/litegraph/src/LGraphCanvas.ts` `_serializeItems()`
|
||||
- Clone: `src/lib/litegraph/src/LGraphNode.ts` `clone()`
|
||||
@@ -1,45 +0,0 @@
|
||||
# 6. PrimitiveNode Copy/Paste Lifecycle
|
||||
|
||||
Date: 2026-02-22
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
PrimitiveNode creates widgets dynamically on connection. When copied, the clone has no `this.widgets`, so `LGraphNode.serialize()` drops `widgets_values` from the clipboard data. This causes secondary widget values (e.g., `control_after_generate`) to be lost on paste. See [WIDGET_SERIALIZATION.md](../WIDGET_SERIALIZATION.md#primitiveno-and-copypaste) for the full mechanism.
|
||||
|
||||
Related: [#1757](https://github.com/Comfy-Org/ComfyUI_frontend/issues/1757), [#8938](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8938)
|
||||
|
||||
## Options
|
||||
|
||||
### A. Minimal fix: override `serialize()` on PrimitiveNode
|
||||
|
||||
Override `serialize()` to fall back to `this.widgets_values` (set during `configure()`) when the base implementation omits it due to missing `this.widgets`.
|
||||
|
||||
- **Pro**: No change to connection lifecycle semantics. Lowest risk.
|
||||
- **Pro**: Doesn't affect workflow save/load (which already works via `onAfterGraphConfigured`).
|
||||
- **Con**: Doesn't address the deeper design issue — primitives are still empty on copy.
|
||||
|
||||
### B. Clone-configured-instance lifecycle
|
||||
|
||||
On copy, the primitive is a clone of the configured instance (with widgets intact). On disconnect or paste without connections, it returns to empty state.
|
||||
|
||||
- **Pro**: Copy→serialize captures `widgets_values` correctly. Matches OOP expectations.
|
||||
- **Pro**: Secondary widget state survives round-trips without special-casing.
|
||||
- **Con**: `input.widget[CONFIG]` allows extensions to make PrimitiveNode create a _different_ widget than the target. Widget config is derived at connection time, not stored, so cloning the configured state may not be faithful.
|
||||
- **Con**: Deserialization ordering — `configure()` runs before links are restored. PrimitiveNode needs links to know what widgets to create. `onAfterGraphConfigured()` handles this for workflow load, but copy/paste uses a different code path.
|
||||
- **Con**: Higher risk of regressions in extension compatibility.
|
||||
|
||||
### C. Projection model (like Subgraph widgets)
|
||||
|
||||
Primitives act as a synchronization mechanism — no own state, just a projection of the target widget's resolved value.
|
||||
|
||||
- **Pro**: Cleanest conceptual model. Eliminates state duplication.
|
||||
- **Con**: Primitives can connect to multiple targets. Projection with multiple targets is ambiguous.
|
||||
- **Con**: Major architectural change with broad impact.
|
||||
|
||||
## Decision
|
||||
|
||||
Pending. Option A is the most pragmatic first step. Option B can be revisited after Option A ships and stabilizes.
|
||||
@@ -57,7 +57,7 @@ settings: testData as any
|
||||
// Don't add test settings to src/schemas/apiSchema.ts
|
||||
|
||||
// ❌ Don't chain through unknown to bypass types
|
||||
data as unknown as SomeType // Avoid; prefer `as Partial<SomeType> as SomeType` or explicit typings
|
||||
data as unknown as SomeType // Avoid; prefer explicit typings or helpers
|
||||
```
|
||||
|
||||
### Accessing Internal State
|
||||
|
||||
@@ -36,25 +36,6 @@ When you must handle uncertain types, prefer these approaches in order:
|
||||
- Don't expose internal implementation types (e.g., Pinia store internals)
|
||||
- Reactive refs (`ComputedRef<T>`) should be unwrapped before exposing
|
||||
|
||||
## Avoiding Circular Dependencies
|
||||
|
||||
Extract type guards and their associated interfaces into **leaf modules** — files with only `import type` statements. This keeps them safe to import from anywhere without pulling in heavy transitive dependencies.
|
||||
|
||||
```typescript
|
||||
// ✅ myTypes.ts — leaf module (only type imports)
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export interface MyView extends IBaseWidget {
|
||||
/* ... */
|
||||
}
|
||||
export function isMyView(w: IBaseWidget): w is MyView {
|
||||
return 'myProp' in w
|
||||
}
|
||||
|
||||
// ❌ myView.ts — heavy module (runtime imports from stores, utils, etc.)
|
||||
// Importing the type guard from here drags in the entire dependency tree.
|
||||
```
|
||||
|
||||
## Utility Libraries
|
||||
|
||||
- Use `es-toolkit` for utility functions (not lodash)
|
||||
|
||||
@@ -39,9 +39,7 @@ const config: KnipConfig = {
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml'
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -6,19 +6,10 @@ export default {
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
|
||||
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
|
||||
|
||||
const hasBrowserTestsChanges = stagedFiles
|
||||
.map((f) => path.relative(process.cwd(), f).replace(/\\/g, '/'))
|
||||
.some((f) => f.startsWith('browser_tests/'))
|
||||
|
||||
if (hasBrowserTestsChanges) {
|
||||
commands.push('pnpm typecheck:browser')
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
|
||||
...formatAndEslint(stagedFiles),
|
||||
'pnpm typecheck'
|
||||
]
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames: string[]) {
|
||||
|
||||
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.3",
|
||||
"version": "1.40.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -70,14 +70,13 @@
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
"@tiptap/extension-table": "catalog:",
|
||||
"@tiptap/extension-table-cell": "catalog:",
|
||||
"@tiptap/extension-table-header": "catalog:",
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@tiptap/core": "^2.10.4",
|
||||
"@tiptap/extension-link": "^2.10.4",
|
||||
"@tiptap/extension-table": "^2.10.4",
|
||||
"@tiptap/extension-table-cell": "^2.10.4",
|
||||
"@tiptap/extension-table-header": "^2.10.4",
|
||||
"@tiptap/extension-table-row": "^2.10.4",
|
||||
"@tiptap/starter-kit": "^2.10.4",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -94,9 +93,9 @@
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "catalog:",
|
||||
"glob": "^11.0.3",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "catalog:",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "catalog:",
|
||||
|
||||
@@ -99,10 +99,6 @@
|
||||
--color-magenta-300: #ceaac9;
|
||||
--color-magenta-700: #6a246a;
|
||||
|
||||
--color-ocean-300: #badde8;
|
||||
--color-ocean-600: #2f687a;
|
||||
--color-ocean-900: #253236;
|
||||
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
@@ -117,7 +113,6 @@
|
||||
--color-interface-panel-job-progress-secondary: var(
|
||||
--color-alpha-azure-600-30
|
||||
);
|
||||
--color-interface-panel-job-progress-border: var(--base-foreground);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
|
||||
@@ -159,7 +154,6 @@
|
||||
--comfy-menu-bg: #353535;
|
||||
--comfy-menu-secondary-bg: #292929;
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
--workflow-tabs-height: 2.375rem;
|
||||
--comfy-input-bg: #222;
|
||||
--input-text: #ddd;
|
||||
--descrip-text: #999;
|
||||
@@ -224,10 +218,6 @@
|
||||
--interface-panel-surface: var(--color-white);
|
||||
--interface-stroke: var(--color-smoke-300);
|
||||
|
||||
--interface-builder-mode-background: var(--color-ocean-300);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
|
||||
--nav-background: var(--color-white);
|
||||
|
||||
--node-border: var(--color-smoke-300);
|
||||
@@ -387,10 +377,6 @@
|
||||
--interface-panel-surface: var(--color-charcoal-800);
|
||||
--interface-stroke: var(--color-charcoal-400);
|
||||
|
||||
--interface-builder-mode-background: var(--color-ocean-900);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
|
||||
--nav-background: var(--color-charcoal-800);
|
||||
|
||||
--node-border: var(--color-charcoal-500);
|
||||
@@ -426,7 +412,6 @@
|
||||
--color-interface-panel-job-progress-secondary: var(
|
||||
--color-alpha-azure-600-30
|
||||
);
|
||||
--color-interface-panel-job-progress-border: var(--base-foreground);
|
||||
|
||||
--text-secondary: var(--color-slate-100);
|
||||
--text-primary: var(--color-white);
|
||||
@@ -525,15 +510,6 @@
|
||||
--color-comfy-menu-bg: var(--comfy-menu-bg);
|
||||
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
|
||||
|
||||
--color-interface-builder-mode-background: var(
|
||||
--interface-builder-mode-background
|
||||
);
|
||||
--color-interface-builder-mode-button-background: var(
|
||||
--interface-builder-mode-button-background
|
||||
);
|
||||
--color-interface-builder-mode-button-foreground: var(
|
||||
--interface-builder-mode-button-foreground
|
||||
);
|
||||
--color-interface-stroke: var(--interface-stroke);
|
||||
--color-nav-background: var(--nav-background);
|
||||
--color-node-border: var(--node-border);
|
||||
@@ -1901,22 +1877,3 @@ audio.comfy-audio.empty-audio-widget {
|
||||
margin-left: 15px;
|
||||
}
|
||||
/* ===================== End of Mask Editor Styles ===================== */
|
||||
|
||||
/* ===================== Skeleton Shimmer ===================== */
|
||||
.skeleton-shimmer {
|
||||
background:
|
||||
linear-gradient(135deg, rgb(188 188 188 / 0.5) 0%, rgb(0 0 0 / 0.5) 100%) 0
|
||||
0 / 200% 200%,
|
||||
#000;
|
||||
animation: skeleton-shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
50% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
5097
pnpm-lock.yaml
generated
@@ -9,12 +9,12 @@ catalog:
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@nx/eslint': 22.5.2
|
||||
'@nx/playwright': 22.5.2
|
||||
'@nx/storybook': 22.5.2
|
||||
'@nx/vite': 22.5.2
|
||||
'@nx/eslint': 22.2.6
|
||||
'@nx/playwright': 22.2.6
|
||||
'@nx/storybook': 22.2.4
|
||||
'@nx/vite': 22.2.6
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -27,19 +27,11 @@ catalog:
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tiptap/core': ^2.27.2
|
||||
'@tiptap/extension-link': ^2.27.2
|
||||
'@tiptap/extension-table': ^2.27.2
|
||||
'@tiptap/extension-table-cell': ^2.27.2
|
||||
'@tiptap/extension-table-header': ^2.27.2
|
||||
'@tiptap/extension-table-row': ^2.27.2
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@tiptap/starter-kit': ^2.27.2
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
@@ -53,7 +45,7 @@ catalog:
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
axios: ^1.13.5
|
||||
axios: ^1.8.2
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: ^3.3.1
|
||||
@@ -63,26 +55,24 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.25.0
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-storybook: ^10.1.9
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
globals: ^16.5.0
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.5.2
|
||||
oxfmt: ^0.34.0
|
||||
oxlint: ^1.49.0
|
||||
oxlint-tsgolint: ^0.14.2
|
||||
nx: 22.2.6
|
||||
oxfmt: ^0.26.0
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
@@ -91,9 +81,9 @@ catalog:
|
||||
primevue: ^4.2.5
|
||||
reka-ui: ^2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
storybook: ^10.1.9
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.2.0
|
||||
tailwindcss: ^4.1.12
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
@@ -110,10 +100,10 @@ catalog:
|
||||
vitest: ^4.0.16
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.4.0
|
||||
vue-i18n: ^9.14.5
|
||||
vue-eslint-parser: ^10.2.0
|
||||
vue-i18n: ^9.14.3
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.2.5
|
||||
vue-tsc: ^3.2.1
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yjs: ^13.6.27
|
||||
@@ -140,5 +130,4 @@ onlyBuiltDependencies:
|
||||
- oxc-resolver
|
||||
|
||||
overrides:
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
|
||||
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status>
|
||||
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
@@ -31,6 +31,8 @@ case "$STATUS" in
|
||||
;;
|
||||
esac
|
||||
|
||||
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
|
||||
|
||||
# Required environment variables
|
||||
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
@@ -133,8 +135,23 @@ post_comment() {
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post concise starting comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright: ⏳ Running..."
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎭 Playwright Tests: ⏳ Running...
|
||||
|
||||
Tests started at $START_TIME UTC
|
||||
|
||||
<details>
|
||||
<summary>📊 Browser Tests</summary>
|
||||
|
||||
- **chromium**: Running...
|
||||
- **chromium-0.5x**: Running...
|
||||
- **chromium-2x**: Running...
|
||||
- **mobile-chrome**: Running...
|
||||
|
||||
</details>
|
||||
EOF
|
||||
)
|
||||
post_comment "$comment"
|
||||
|
||||
else
|
||||
@@ -283,7 +300,7 @@ else
|
||||
|
||||
# Generate compact single-line comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright: $status_icon $total_passed passed, $total_failed failed$flaky_note"
|
||||
**Playwright:** $status_icon $total_passed passed, $total_failed failed$flaky_note"
|
||||
|
||||
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
# Deploy Storybook to Cloudflare Pages and comment on PR
|
||||
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status>
|
||||
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
@@ -31,6 +31,7 @@ case "$STATUS" in
|
||||
;;
|
||||
esac
|
||||
|
||||
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
|
||||
|
||||
# Required environment variables
|
||||
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
|
||||
@@ -119,9 +120,50 @@ post_comment() {
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post starting comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎨 Storybook: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
|
||||
# Check if this is a version-bump branch
|
||||
IS_VERSION_BUMP="false"
|
||||
if echo "$BRANCH_NAME" | grep -q "^version-bump-"; then
|
||||
IS_VERSION_BUMP="true"
|
||||
fi
|
||||
|
||||
# Post starting comment with appropriate message
|
||||
if [ "$IS_VERSION_BUMP" = "true" ]; then
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
|
||||
### 🚀 Building Storybook
|
||||
- 📦 Installing dependencies...
|
||||
- 🔧 Building Storybook components...
|
||||
- 🎨 Running Chromatic visual tests...
|
||||
|
||||
---
|
||||
⏱️ Please wait while the Storybook build is in progress...
|
||||
EOF
|
||||
)
|
||||
else
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
|
||||
### 🚀 Building Storybook
|
||||
- 📦 Installing dependencies...
|
||||
- 🔧 Building Storybook components...
|
||||
- 🌐 Preparing deployment to Cloudflare Pages...
|
||||
|
||||
---
|
||||
⏱️ Please wait while the Storybook build is in progress...
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
post_comment "$comment"
|
||||
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
@@ -150,57 +192,56 @@ elif [ "$STATUS" = "completed" ]; then
|
||||
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
|
||||
WORKFLOW_URL="${WORKFLOW_URL:-}"
|
||||
|
||||
# Generate compact header based on conclusion
|
||||
# Generate completion comment based on conclusion
|
||||
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
status_icon="✅"
|
||||
status_text="Built"
|
||||
status_text="Build completed successfully!"
|
||||
footer_text="🎉 Your Storybook is ready for review!"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
|
||||
status_icon="⏭️"
|
||||
status_text="Skipped"
|
||||
status_text="Build skipped."
|
||||
footer_text="ℹ️ Chromatic was skipped for this PR."
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
|
||||
status_icon="🚫"
|
||||
status_text="Cancelled"
|
||||
status_text="Build cancelled."
|
||||
footer_text="ℹ️ The Chromatic run was cancelled."
|
||||
else
|
||||
status_icon="❌"
|
||||
status_text="Failed"
|
||||
status_text="Build failed!"
|
||||
footer_text="⚠️ Please check the workflow logs for error details."
|
||||
fi
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
# Build compact header with optional storybook link
|
||||
header="## 🎨 Storybook: $status_icon $status_text"
|
||||
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
header="$header — $deployment_url"
|
||||
fi
|
||||
|
||||
# Build details section
|
||||
details="<details>
|
||||
<summary>Details</summary>
|
||||
$status_icon **$status_text**
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
|
||||
|
||||
**Links**
|
||||
### 🔗 Links
|
||||
- [📊 View Workflow Run]($WORKFLOW_URL)"
|
||||
|
||||
|
||||
# Add deployment status
|
||||
if [ "$deployment_url" != "Not deployed" ]; then
|
||||
if [ "$deployment_url" = "Deployment failed" ]; then
|
||||
details="$details
|
||||
comment="$comment
|
||||
- ❌ Storybook deployment failed"
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
details="$details
|
||||
- ⚠️ Build failed — $deployment_url"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
comment="$comment
|
||||
- 🎨 $deployment_url"
|
||||
else
|
||||
comment="$comment
|
||||
- ⚠️ Build failed - $deployment_url"
|
||||
fi
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
details="$details
|
||||
comment="$comment
|
||||
- ⏭️ Storybook deployment skipped (build did not succeed)"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
|
||||
details="$details
|
||||
|
||||
</details>"
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
$header
|
||||
|
||||
$details"
|
||||
---
|
||||
$footer_text"
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
|
||||
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
|
||||
import type { SettingParams } from '../src/platform/settings/types'
|
||||
import type { FormItem, SettingParams } from '../src/platform/settings/types'
|
||||
import type { ComfyCommandImpl } from '../src/stores/commandStore'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
@@ -123,8 +123,8 @@ test('collect-i18n-general', async ({ comfyPage }) => {
|
||||
SERVER_CONFIG_ITEMS.map((config) => [
|
||||
normalizeI18nKey(config.id),
|
||||
{
|
||||
name: config.name,
|
||||
tooltip: config.tooltip
|
||||
name: (config as unknown as FormItem).name,
|
||||
tooltip: (config as unknown as FormItem).tooltip
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import zipdir from 'zip-dir'
|
||||
|
||||
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
|
||||
const sourceDir = process.argv[2] || './dist'
|
||||
const outputPath = process.argv[3] || './dist.zip'
|
||||
|
||||
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
|
||||
if (err) {
|
||||
console.error('Error zipping "dist" directory:', err)
|
||||
console.error(`Error zipping "${sourceDir}" directory:`, err)
|
||||
} else {
|
||||
console.log('Successfully zipped "dist" directory.')
|
||||
process.stdout.write(
|
||||
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition
|
||||
extractFilenameFromContentDisposition,
|
||||
openFileInNewTab
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
let mockIsCloud = false
|
||||
const { mockIsCloud } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
|
||||
}))
|
||||
|
||||
// Global stubs
|
||||
const createObjectURLSpy = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
@@ -26,7 +37,7 @@ describe('downloadUtil', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsCloud = false
|
||||
mockIsCloud.value = false
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||
@@ -154,7 +165,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('streams downloads via blob when running in cloud', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -173,6 +184,7 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -183,28 +195,29 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('logs an error when cloud fetch fails', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: vi.fn()
|
||||
} as Partial<Response> as Response)
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve()
|
||||
await Promise.resolve() // let fetchAsBlob throw
|
||||
await Promise.resolve() // let .catch handler run
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -223,6 +236,7 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -231,7 +245,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -253,6 +267,7 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -260,7 +275,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -278,6 +293,7 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -285,6 +301,99 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('openFileInNewTab', () => {
|
||||
let windowOpenSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens URL directly when not in cloud mode', async () => {
|
||||
mockIsCloud.value = false
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
expect(mockTab.location.href).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('revokes blob URL after timeout in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('closes blank tab and logs error when cloud fetch fails', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(mockTab.close).toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('revokes blob URL immediately if tab was closed by user', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
expect(mockTab.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
@@ -112,14 +114,23 @@ export function extractFilenameFromContentDisposition(
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadViaBlobFetch = async (
|
||||
/**
|
||||
* Fetch a URL and return its body as a Blob.
|
||||
* Shared by download and open-in-new-tab cloud paths.
|
||||
*/
|
||||
async function fetchAsBlob(url: string): Promise<Response> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function downloadViaBlobFetch(
|
||||
href: string,
|
||||
fallbackFilename: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
): Promise<void> {
|
||||
const response = await fetchAsBlob(href)
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
@@ -129,3 +140,44 @@ const downloadViaBlobFetch = async (
|
||||
const blob = await response.blob()
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file URL in a new browser tab.
|
||||
* On cloud, fetches the resource as a blob first to avoid GCS redirects
|
||||
* that would trigger an auto-download instead of displaying the file.
|
||||
*
|
||||
* Opens the tab synchronously to preserve the user-gesture context
|
||||
* (browsers block window.open after an await), then navigates it to
|
||||
* the blob URL once the fetch completes.
|
||||
*/
|
||||
export async function openFileInNewTab(url: string): Promise<void> {
|
||||
if (!isCloud) {
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
// Open immediately to preserve user-gesture activation.
|
||||
const tab = window.open('', '_blank')
|
||||
|
||||
try {
|
||||
const response = await fetchAsBlob(url)
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (tab && !tab.closed) {
|
||||
tab.location.href = blobUrl
|
||||
// Revoke after the tab has had time to load the blob.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
||||
} else {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
tab?.close()
|
||||
console.error('Failed to open image:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.errorOpenImage', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
})
|
||||
|
||||
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
|
||||
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
@@ -296,11 +296,13 @@ describe('TopMenuSection', () => {
|
||||
describe('inline progress summary', () => {
|
||||
const configureSettings = (
|
||||
pinia: ReturnType<typeof createTestingPinia>,
|
||||
qpoV2Enabled: boolean
|
||||
qpoV2Enabled: boolean,
|
||||
showRunProgressBar = true
|
||||
) => {
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
||||
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
return undefined
|
||||
})
|
||||
@@ -332,6 +334,19 @@ describe('TopMenuSection', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render inline progress summary when run progress bar is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true, false)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('teleports inline progress summary when actionbar is floating', async () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
const actionbarTarget = document.createElement('div')
|
||||
|
||||
@@ -56,43 +56,6 @@
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="md"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'assets'
|
||||
: isQueueProgressOverlayEnabled
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="relative px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="activeJobsCount > 0"
|
||||
data-testid="active-jobs-indicator"
|
||||
variant="dot"
|
||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu
|
||||
ref="queueContextMenu"
|
||||
:model="queueContextMenuItems"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
@@ -127,13 +90,15 @@
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
|
||||
>
|
||||
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
|
||||
<QueueInlineProgressSummary
|
||||
:hidden="shouldHideInlineProgressSummary"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
<QueueInlineProgressSummary
|
||||
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
|
||||
class="pr-1"
|
||||
:hidden="isQueueOverlayExpanded"
|
||||
:hidden="shouldHideInlineProgressSummary"
|
||||
/>
|
||||
<QueueNotificationBannerHost
|
||||
v-if="shouldShowQueueNotificationBanners"
|
||||
@@ -146,14 +111,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
@@ -163,16 +125,14 @@ import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
@@ -185,17 +145,11 @@ const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { t, n } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
@@ -208,25 +162,19 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
const isQueueProgressOverlayEnabled = computed(
|
||||
() => !isQueuePanelV2Enabled.value
|
||||
)
|
||||
const shouldShowInlineProgressSummary = computed(
|
||||
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
||||
() =>
|
||||
isQueuePanelV2Enabled.value &&
|
||||
isActionbarEnabled.value &&
|
||||
isRunProgressBarEnabled.value
|
||||
)
|
||||
const shouldShowQueueNotificationBanners = computed(
|
||||
() => isActionbarEnabled.value
|
||||
@@ -241,24 +189,12 @@ const inlineProgressSummaryTarget = computed(() => {
|
||||
}
|
||||
return progressTarget.value
|
||||
})
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
const shouldHideInlineProgressSummary = computed(
|
||||
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
|
||||
)
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.manageExtensions'))
|
||||
)
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
@@ -281,27 +217,6 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
if (isQueuePanelV2Enabled.value) {
|
||||
sidebarTabStore.toggleSidebarTab('assets')
|
||||
return
|
||||
}
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
98
src/components/actionbar/BatchCountEdit.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
|
||||
import BatchCountEdit from './BatchCountEdit.vue'
|
||||
|
||||
const maxBatchCount = 16
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (settingId: string) =>
|
||||
settingId === 'Comfy.QueueButton.BatchCountLimit' ? maxBatchCount : 1
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
increment: 'Increment',
|
||||
decrement: 'Decrement'
|
||||
},
|
||||
menu: {
|
||||
batchCount: 'Batch Count'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createWrapper(initialBatchCount = 1) {
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
queueSettingsStore: {
|
||||
batchCount: initialBatchCount
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(BatchCountEdit, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
|
||||
return { wrapper, queueSettingsStore }
|
||||
}
|
||||
|
||||
describe('BatchCountEdit', () => {
|
||||
it('doubles the current batch count when increment is clicked', async () => {
|
||||
const { wrapper, queueSettingsStore } = createWrapper(3)
|
||||
|
||||
await wrapper.get('button[aria-label="Increment"]').trigger('click')
|
||||
|
||||
expect(queueSettingsStore.batchCount).toBe(6)
|
||||
})
|
||||
|
||||
it('halves the current batch count when decrement is clicked', async () => {
|
||||
const { wrapper, queueSettingsStore } = createWrapper(9)
|
||||
|
||||
await wrapper.get('button[aria-label="Decrement"]').trigger('click')
|
||||
|
||||
expect(queueSettingsStore.batchCount).toBe(4)
|
||||
})
|
||||
|
||||
it('clamps typed values to queue limits on blur', async () => {
|
||||
const { wrapper, queueSettingsStore } = createWrapper(2)
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('999')
|
||||
await input.trigger('blur')
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.batchCount).toBe(maxBatchCount)
|
||||
expect((input.element as HTMLInputElement).value).toBe(
|
||||
String(maxBatchCount)
|
||||
)
|
||||
|
||||
await input.setValue('0')
|
||||
await input.trigger('blur')
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.batchCount).toBe(1)
|
||||
expect((input.element as HTMLInputElement).value).toBe('1')
|
||||
})
|
||||
})
|
||||
@@ -1,71 +1,129 @@
|
||||
<template>
|
||||
<div
|
||||
v-tooltip.bottom="{
|
||||
value: $t('menu.batchCount'),
|
||||
value: t('menu.batchCount'),
|
||||
showDelay: 600
|
||||
}"
|
||||
class="batch-count"
|
||||
:aria-label="$t('menu.batchCount')"
|
||||
class="batch-count h-full"
|
||||
:aria-label="t('menu.batchCount')"
|
||||
>
|
||||
<InputNumber
|
||||
v-model="batchCount"
|
||||
class="w-14"
|
||||
:min="minQueueCount"
|
||||
:max="maxQueueCount"
|
||||
fluid
|
||||
show-buttons
|
||||
:pt="{
|
||||
incrementButton: {
|
||||
class: 'w-6',
|
||||
onmousedown: () => {
|
||||
handleClick(true)
|
||||
}
|
||||
},
|
||||
decrementButton: {
|
||||
class: 'w-6',
|
||||
onmousedown: () => {
|
||||
handleClick(false)
|
||||
}
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full w-14 overflow-hidden rounded-l-lg bg-secondary-background"
|
||||
>
|
||||
<input
|
||||
ref="batchCountInputRef"
|
||||
v-model="batchCountInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:aria-label="t('menu.batchCount')"
|
||||
:class="inputClass"
|
||||
@focus="onInputFocus"
|
||||
@input="onInput"
|
||||
@blur="onInputBlur"
|
||||
@keydown.enter.prevent="onInputEnter"
|
||||
/>
|
||||
<div class="flex h-full w-6 flex-col">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('g.increment')"
|
||||
:class="cn(stepButtonClass, incrementButtonClass)"
|
||||
:disabled="isIncrementDisabled"
|
||||
@click="incrementBatchCount"
|
||||
>
|
||||
<TinyChevronIcon rotate-up />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('g.decrement')"
|
||||
:class="cn(stepButtonClass, decrementButtonClass)"
|
||||
:disabled="isDecrementDisabled"
|
||||
@click="decrementBatchCount"
|
||||
>
|
||||
<TinyChevronIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TinyChevronIcon from './TinyChevronIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const { batchCount } = storeToRefs(queueSettingsStore)
|
||||
const minQueueCount = 1
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const minQueueCount = 1
|
||||
const maxQueueCount = computed(() =>
|
||||
settingStore.get('Comfy.QueueButton.BatchCountLimit')
|
||||
)
|
||||
|
||||
const handleClick = (increment: boolean) => {
|
||||
let newCount: number
|
||||
if (increment) {
|
||||
const originalCount = batchCount.value - 1
|
||||
newCount = Math.min(originalCount * 2, maxQueueCount.value)
|
||||
} else {
|
||||
const originalCount = batchCount.value + 1
|
||||
newCount = Math.floor(originalCount / 2)
|
||||
}
|
||||
const batchCountInputRef = ref<HTMLInputElement | null>(null)
|
||||
const batchCountInput = ref(String(batchCount.value))
|
||||
const isEditing = ref(false)
|
||||
|
||||
batchCount.value = newCount
|
||||
const isIncrementDisabled = computed(
|
||||
() => batchCount.value >= maxQueueCount.value
|
||||
)
|
||||
const isDecrementDisabled = computed(() => batchCount.value <= minQueueCount)
|
||||
const inputClass =
|
||||
'h-full min-w-0 flex-1 border-none bg-secondary-background pl-1 pr-0 text-center text-sm font-normal tabular-nums text-base-foreground outline-none'
|
||||
const stepButtonClass =
|
||||
'h-1/2 w-full rounded-none border-none p-0 text-muted-foreground hover:bg-secondary-background-hover disabled:cursor-not-allowed disabled:opacity-50'
|
||||
const incrementButtonClass = 'rounded-tr-none border-b border-border-subtle'
|
||||
const decrementButtonClass = 'rounded-br-none'
|
||||
|
||||
watch(batchCount, (nextBatchCount) => {
|
||||
if (!isEditing.value) {
|
||||
batchCountInput.value = String(nextBatchCount)
|
||||
}
|
||||
})
|
||||
|
||||
const clampBatchCount = (nextBatchCount: number): number =>
|
||||
Math.min(Math.max(nextBatchCount, minQueueCount), maxQueueCount.value)
|
||||
|
||||
const setBatchCount = (nextBatchCount: number) => {
|
||||
batchCount.value = clampBatchCount(nextBatchCount)
|
||||
batchCountInput.value = String(batchCount.value)
|
||||
}
|
||||
|
||||
const incrementBatchCount = () => {
|
||||
setBatchCount(batchCount.value * 2)
|
||||
}
|
||||
|
||||
const decrementBatchCount = () => {
|
||||
setBatchCount(Math.floor(batchCount.value / 2))
|
||||
}
|
||||
|
||||
const onInputFocus = () => {
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
batchCountInput.value = input.value.replace(/[^0-9]/g, '')
|
||||
}
|
||||
|
||||
const onInputBlur = () => {
|
||||
isEditing.value = false
|
||||
const parsedInput = Number.parseInt(batchCountInput.value, 10)
|
||||
setBatchCount(Number.isNaN(parsedInput) ? minQueueCount : parsedInput)
|
||||
}
|
||||
|
||||
const onInputEnter = () => {
|
||||
batchCountInputRef.value?.blur()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputtext) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||