Compare commits
60 Commits
remove-usa
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160c615bc4 | ||
|
|
eb61c0bb4d | ||
|
|
4689581674 | ||
|
|
e4b456bb2c | ||
|
|
482ad401d4 | ||
|
|
9082f6bc3c | ||
|
|
7c34a0e0f6 | ||
|
|
8c3738fb77 | ||
|
|
aee207f16c | ||
|
|
164379bf4b | ||
|
|
9108b7535a | ||
|
|
2ff14fadc2 | ||
|
|
87341f2c6e | ||
|
|
02a38110cd | ||
|
|
09989b7aff | ||
|
|
0f455c73bb | ||
|
|
a94574d379 | ||
|
|
78fe639540 | ||
|
|
e333ad459e | ||
|
|
aab09b5f99 | ||
|
|
1f0ca18737 | ||
|
|
3b5649232d | ||
|
|
724827d8cc | ||
|
|
be70f6c1e6 | ||
|
|
514425b560 | ||
|
|
c25f9a0e93 | ||
|
|
d7546e68ef | ||
|
|
2634acdd8c | ||
|
|
b3a5317500 | ||
|
|
19dc48b69a | ||
|
|
f811b173c6 | ||
|
|
b29dbec916 | ||
|
|
0f4bceafdd | ||
|
|
d601aba721 | ||
|
|
ddcfdb924d | ||
|
|
d6c902187d | ||
|
|
dd8520020e | ||
|
|
a0e8f7d960 | ||
|
|
02e926471f | ||
|
|
8998d92e1b | ||
|
|
1267c4b9b3 | ||
|
|
ddc2159bed | ||
|
|
5687b44422 | ||
|
|
26fa84ce1b | ||
|
|
e37bba7250 | ||
|
|
35f15d18b4 | ||
|
|
a82c984520 | ||
|
|
54f13930a4 | ||
|
|
c972dca61e | ||
|
|
cf8952e205 | ||
|
|
1dcaf5d0dc | ||
|
|
f707098f05 | ||
|
|
d2917be3a7 | ||
|
|
2639248867 | ||
|
|
b41f162607 | ||
|
|
3678e65bec | ||
|
|
16ddcfdbaf | ||
|
|
ef5198be25 | ||
|
|
38675e658f | ||
|
|
bd95150f82 |
12
.github/workflows/ci-lint-format.yaml
vendored
@@ -26,6 +26,14 @@ 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
|
||||
|
||||
@@ -60,6 +68,10 @@ 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
Normal file
@@ -0,0 +1,118 @@
|
||||
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,9 +6,6 @@ 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
|
||||
@@ -63,8 +60,7 @@ jobs:
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting" \
|
||||
"$(date -u '${{ env.DATE_FORMAT }}')"
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
|
||||
9
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -4,8 +4,10 @@ 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:
|
||||
@@ -182,10 +184,6 @@ 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 }}
|
||||
@@ -194,8 +192,7 @@ jobs:
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting" \
|
||||
"${{ steps.start-time.outputs.time }}"
|
||||
"starting"
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
|
||||
@@ -6,9 +6,6 @@ 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
|
||||
@@ -63,8 +60,7 @@ jobs:
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting" \
|
||||
"$(date -u '${{ env.DATE_FORMAT }}')"
|
||||
"starting"
|
||||
|
||||
- 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,8 +24,7 @@ jobs:
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting" \
|
||||
"$(date -u '+%m/%d/%Y, %I:%M:%S %p')"
|
||||
"starting"
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
|
||||
2
.github/workflows/ci-tests-unit.yaml
vendored
@@ -4,8 +4,10 @@ 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,6 +64,7 @@ browser_tests/local/
|
||||
dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
"block-no-empty": true,
|
||||
"no-descending-specificity": null,
|
||||
"no-duplicate-at-import-rules": true,
|
||||
"at-rule-disallowed-list": ["apply"],
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": [
|
||||
"tailwind",
|
||||
"apply",
|
||||
"layer",
|
||||
"config",
|
||||
"theme",
|
||||
|
||||
@@ -37,6 +37,10 @@ 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"
|
||||
|
||||
@@ -4,3 +4,39 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.p-button-secondary {
|
||||
border: none;
|
||||
background-color: var(--color-neutral-600);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
background-color: var(--color-neutral-550);
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
background-color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
transition: opacity var(--default-transition-duration);
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.task-div .p-card:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -101,13 +101,15 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
|
||||
* required to style those generated nodes.
|
||||
*/
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
option-value="value"
|
||||
:disabled="isSwitching"
|
||||
:pt="dropdownPt"
|
||||
:size="props.size"
|
||||
:size="size"
|
||||
class="language-selector"
|
||||
@change="onLocaleChange"
|
||||
>
|
||||
@@ -36,16 +36,10 @@ import { i18n, loadLocale, st } from '@/i18n'
|
||||
type VariantKey = 'dark' | 'light'
|
||||
type SizeKey = 'small' | 'large'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>(),
|
||||
{
|
||||
variant: 'dark',
|
||||
size: 'small'
|
||||
}
|
||||
)
|
||||
const { variant = 'dark', size = 'small' } = defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>()
|
||||
|
||||
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
@@ -104,10 +98,8 @@ const VARIANT_PRESETS = {
|
||||
const selectedLocale = ref<string>(i18n.global.locale.value)
|
||||
const isSwitching = ref(false)
|
||||
|
||||
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
|
||||
const variantPreset = computed(
|
||||
() => VARIANT_PRESETS[props.variant as VariantKey]
|
||||
)
|
||||
const sizePreset = computed(() => SIZE_PRESETS[size])
|
||||
const variantPreset = computed(() => VARIANT_PRESETS[variant])
|
||||
|
||||
const dropdownPt = computed(() => ({
|
||||
root: {
|
||||
@@ -195,13 +187,17 @@ async function onLocaleChange(event: SelectChangeEvent) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-dropdown-panel .p-dropdown-item) {
|
||||
@apply transition-colors;
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: var(--default-transition-duration);
|
||||
}
|
||||
|
||||
:deep(.p-dropdown) {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-neutral-900),
|
||||
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -269,26 +269,43 @@ const onFocus = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
@apply px-12;
|
||||
padding-inline: calc(var(--spacing) * 12);
|
||||
|
||||
.p-accordionpanel {
|
||||
@apply border-0 bg-transparent;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-radius 0.5s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-700) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
border-top-right-radius: var(--radius-xl);
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.p-accordionheader-toggle-icon {
|
||||
@@ -299,11 +316,24 @@ const onFocus = async () => {
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
border: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: var(--radius-xl);
|
||||
border-bottom-left-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
background-color: transparent;
|
||||
padding-top: calc(var(--spacing) * 3);
|
||||
padding-right: calc(var(--spacing) * 5);
|
||||
padding-bottom: calc(var(--spacing) * 5);
|
||||
padding-left: calc(var(--spacing) * 5);
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
class="task-div relative grid min-h-52 max-w-48"
|
||||
:class="{ 'opacity-75': isLoading }"
|
||||
:class="
|
||||
cn(
|
||||
'task-div group/task-card relative grid min-h-52 max-w-48',
|
||||
isLoading && 'opacity-75'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Card
|
||||
class="relative h-full max-w-48 overflow-hidden"
|
||||
:class="{ 'opacity-65': runner.state !== 'error' }"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-full max-w-48 overflow-hidden',
|
||||
runner.state !== 'error' && 'opacity-65'
|
||||
)
|
||||
"
|
||||
:pt="cardPt"
|
||||
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
|
||||
>
|
||||
<template #header>
|
||||
@@ -43,7 +52,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 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,6 +64,7 @@ import { computed } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -83,51 +93,9 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
|
||||
const cardPt = {
|
||||
header: { class: 'z-0' },
|
||||
body: { class: 'z-[1] grow justify-between' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.task-card-ok {
|
||||
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
|
||||
|
||||
font-size: 4rem;
|
||||
text-shadow: 0.25rem 0 0.5rem black;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.p-card {
|
||||
@apply transition-opacity;
|
||||
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
|
||||
&.opacity-65 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-card-header) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
:deep(.p-card-body) {
|
||||
z-index: 1;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-div {
|
||||
> i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover > i {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<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) }}
|
||||
{{ $t(`desktopDialogs.${id}.title`, title) }}
|
||||
</h1>
|
||||
<p class="whitespace-pre-wrap">
|
||||
{{ t(`desktopDialogs.${id}.message`, message) }}
|
||||
{{ $t(`desktopDialogs.${id}.message`, message) }}
|
||||
</p>
|
||||
<div class="flex w-full gap-2">
|
||||
<Button
|
||||
@@ -12,7 +12,7 @@
|
||||
:key="button.label"
|
||||
class="rounded-lg first:mr-auto"
|
||||
:label="
|
||||
t(
|
||||
$t(
|
||||
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
|
||||
button.label
|
||||
)
|
||||
@@ -31,7 +31,6 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { getDialog } from '@/constants/desktopDialogs'
|
||||
import type { DialogAction } from '@/constants/desktopDialogs'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -41,31 +40,3 @@ const handleButtonClick = async (button: DialogAction) => {
|
||||
await electronAPI().Dialog.clickButton(button.returnValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.p-button-secondary {
|
||||
@apply text-white border-none bg-neutral-600;
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
@apply bg-neutral-550;
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
@apply bg-neutral-500;
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
@apply bg-coral-red-600;
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
@apply bg-coral-red-500;
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
@apply bg-coral-red-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="relative m-8 text-center">
|
||||
<!-- Header -->
|
||||
<h1 class="download-bg pi-download text-4xl font-bold">
|
||||
{{ t('desktopUpdate.title') }}
|
||||
{{ $t('desktopUpdate.title') }}
|
||||
</h1>
|
||||
|
||||
<div class="m-8">
|
||||
<span>{{ t('desktopUpdate.description') }}</span>
|
||||
<span>{{ $t('desktopUpdate.description') }}</span>
|
||||
</div>
|
||||
|
||||
<ProgressSpinner class="m-8 w-48 h-48" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<Button
|
||||
style="transform: translateX(-50%)"
|
||||
class="fixed bottom-0 left-1/2 my-8"
|
||||
:label="t('maintenance.consoleLogs')"
|
||||
:label="$t('maintenance.consoleLogs')"
|
||||
icon="pi pi-desktop"
|
||||
icon-pos="left"
|
||||
severity="secondary"
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
<TerminalOutputDrawer
|
||||
v-model="terminalVisible"
|
||||
:header="t('g.terminal')"
|
||||
:default-message="t('desktopUpdate.terminalDefaultMessage')"
|
||||
:header="$t('g.terminal')"
|
||||
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +44,6 @@ import Toast from 'primevue/toast'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
@@ -61,10 +60,10 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.download-bg::before {
|
||||
@apply m-0 absolute text-muted;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: 2rem;
|
||||
|
||||
@@ -183,33 +183,37 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-steppanel) {
|
||||
@apply mt-8 flex justify-center bg-transparent;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Remove default padding/margin from StepPanels to make scrollbar flush */
|
||||
:deep(.p-steppanels) {
|
||||
@apply p-0 m-0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Ensure StepPanel content container has no top/bottom padding */
|
||||
:deep(.p-steppanel-content) {
|
||||
@apply p-0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
|
||||
:deep(.p-steppanels::-webkit-scrollbar) {
|
||||
@apply w-4;
|
||||
width: calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-track) {
|
||||
@apply bg-transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
|
||||
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
|
||||
border: 4px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -77,7 +77,7 @@ const createMockElectronAPI = () => {
|
||||
}
|
||||
|
||||
const ensureElectronAPI = () => {
|
||||
const globalWindow = window as unknown as { electronAPI?: unknown }
|
||||
const globalWindow = window as { electronAPI?: unknown }
|
||||
if (!globalWindow.electronAPI) {
|
||||
globalWindow.electronAPI = createMockElectronAPI()
|
||||
}
|
||||
|
||||
@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
@@ -129,6 +129,7 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
|
||||
const electron = electronAPI()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
|
||||
|
||||
@@ -220,14 +221,14 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-tag) {
|
||||
--p-tag-gap: 0.375rem;
|
||||
}
|
||||
|
||||
.backspan::before {
|
||||
@apply m-0 absolute text-muted;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: -2rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseViewTemplate>
|
||||
<div class="sad-container">
|
||||
<div class="sad-container grid items-center justify-evenly">
|
||||
<!-- Right side image -->
|
||||
<img
|
||||
class="sad-girl"
|
||||
@@ -79,10 +79,7 @@ const continueToInstall = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.sad-container {
|
||||
@apply grid items-center justify-evenly;
|
||||
grid-template-columns: 25rem 1fr;
|
||||
|
||||
& > * {
|
||||
|
||||
@@ -232,8 +232,6 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
/* Hide the xterm scrollbar completely */
|
||||
:deep(.p-terminal) .xterm-viewport {
|
||||
overflow: hidden !important;
|
||||
|
||||
183
browser_tests/assets/subgraphs/subgraph-duplicate-links.json
Normal file
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
|
||||
"pos": [600, 400],
|
||||
"size": [200, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 5,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph With Duplicate Links",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [200, 400, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 400, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "out-latent-1",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": [920, 420]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [400, 100],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [100, 200],
|
||||
"size": [200, 106],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1, 3, 4, 5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.38.14"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
170
browser_tests/assets/subgraphs/subgraph-with-preview-node.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"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,7 +26,6 @@ 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'
|
||||
@@ -174,7 +173,6 @@ 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
|
||||
@@ -219,7 +217,6 @@ 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,7 +11,10 @@ export class CommandHelper {
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ commandId, metadata }) => {
|
||||
return window['app'].extensionManager.command.execute(commandId, {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
|
||||
return app.extensionManager.command.execute(commandId, {
|
||||
metadata
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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,6 +115,16 @@ 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,6 +33,10 @@ 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 (currentGraph.constructor.name !== 'Subgraph') {
|
||||
if (!('inputNode' in currentGraph)) {
|
||||
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 unknown as CanvasPointerEvent,
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
@@ -121,7 +121,7 @@ export class SubgraphHelper {
|
||||
|
||||
if (node.onPointerDown) {
|
||||
node.onPointerDown(
|
||||
event as unknown as CanvasPointerEvent,
|
||||
event as Partial<CanvasPointerEvent> 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 unknown as CanvasPointerEvent
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const TestIds = {
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
missingNodes: 'missing-nodes-warning',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
},
|
||||
@@ -43,11 +44,21 @@ export const TestIds = {
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
},
|
||||
selectionToolbox: {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
},
|
||||
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}`
|
||||
@@ -69,7 +80,9 @@ export type TestIdValue =
|
||||
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (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,7 +128,8 @@ class NodeSlotReference {
|
||||
nodeSize: [node.size[0], node.size[1]],
|
||||
rawConnectionPos: [rawPos[0], rawPos[1]],
|
||||
convertedPos: [convertedPos[0], convertedPos[1]],
|
||||
currentGraphType: window.app!.canvas.graph!.constructor.name
|
||||
currentGraphType:
|
||||
'inputNode' in window.app!.canvas.graph! ? 'Subgraph' : 'LGraph'
|
||||
}
|
||||
)
|
||||
|
||||
@@ -461,18 +462,44 @@ 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?.constructor?.name === 'Subgraph'
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
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,9 +1,5 @@
|
||||
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> }
|
||||
}>({
|
||||
|
||||
64
browser_tests/helpers/promotedWidgets.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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,7 +14,9 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.filename
|
||||
})
|
||||
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
.toBe(`*${workflowName} - ComfyUI`)
|
||||
})
|
||||
|
||||
// Failing on CI
|
||||
@@ -51,7 +53,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
})
|
||||
|
||||
test('Can display default title', async ({ comfyPage }) => {
|
||||
expect(await comfyPage.page.title()).toBe('ComfyUI')
|
||||
await expect.poll(() => 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()
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
await expect.poll(() => 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 })
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,11 +245,18 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.serialize()
|
||||
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 }>
|
||||
}
|
||||
})
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
for (const node of parsed.nodes) {
|
||||
const nodes = parsed.nodes
|
||||
for (const node of nodes) {
|
||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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')
|
||||
@@ -15,8 +16,9 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Wait for the element with the .comfy-missing-nodes selector to be visible
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -25,8 +27,9 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
// Wait for the element with the .comfy-missing-nodes selector to be visible
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
|
||||
// Verify the missing node text includes subgraph context
|
||||
@@ -38,13 +41,14 @@ 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 comfyPage.page
|
||||
.locator('.p-dialog')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click({ force: true })
|
||||
await comfyPage.page.locator('.p-dialog').waitFor({ state: 'hidden' })
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
|
||||
// Wait for any async operations to complete after dialog closes
|
||||
await comfyPage.nextFrame()
|
||||
@@ -55,9 +59,14 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
|
||||
// Undo and redo the change
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test.describe('Execution error', () => {
|
||||
@@ -95,15 +104,13 @@ test.describe('Missing models warning', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display a warning when missing models are found in node properties', async ({
|
||||
@@ -114,15 +121,13 @@ test.describe('Missing models warning', () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display a warning when no missing models are found', async ({
|
||||
@@ -163,8 +168,10 @@ test.describe('Missing models warning', () => {
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display warning when model metadata exists but widget values have changed', async ({
|
||||
@@ -177,8 +184,10 @@ test.describe('Missing models warning', () => {
|
||||
)
|
||||
|
||||
// The missing models warning should NOT appear
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
@@ -190,13 +199,15 @@ test.describe('Missing models warning', () => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = comfyPage.page.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await downloadButton.click()
|
||||
await downloadAllButton.click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||
@@ -220,13 +231,14 @@ test.describe('Missing models warning', () => {
|
||||
test('Should disable warning dialog when checkbox is checked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await checkbox.click()
|
||||
const changeSettingPromise = comfyPage.page.waitForRequest(
|
||||
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
await closeButton.click()
|
||||
await checkbox.click()
|
||||
await changeSettingPromise
|
||||
|
||||
await closeButton.click()
|
||||
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
@@ -401,7 +413,7 @@ test.describe('Signin dialog', () => {
|
||||
test('Paste content to signin dialog should not paste node on canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeNum = (await comfyPage.nodeOps.getNodes()).length
|
||||
const nodeNum = await comfyPage.nodeOps.getNodeCount()
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyLatentWidgetClick
|
||||
})
|
||||
@@ -424,6 +436,6 @@ test.describe('Signin dialog', () => {
|
||||
await input.press('Control+v')
|
||||
await expect(input).toHaveValue('test_password')
|
||||
|
||||
expect(await comfyPage.nodeOps.getNodes()).toHaveLength(nodeNum)
|
||||
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 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(menu).toBeVisible()
|
||||
await expect(themeSubmenu).toBeVisible()
|
||||
|
||||
// Check that Light theme is active
|
||||
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
|
||||
await expect(async () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeSubmenu).toBeVisible()
|
||||
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Screenshot with light theme active
|
||||
await comfyPage.attachScreenshot('theme-menu-light-active')
|
||||
|
||||
// Verify ColorPalette setting is set to "light"
|
||||
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
|
||||
'light'
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
|
||||
.toBe('light')
|
||||
|
||||
// Close menu to see theme change
|
||||
await topbar.closeTopbarMenu()
|
||||
@@ -228,20 +228,22 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
await topbar.switchTheme('dark')
|
||||
|
||||
// Verify menu stays open and Dark theme shows as active
|
||||
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)
|
||||
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 })
|
||||
|
||||
// Screenshot with dark theme active
|
||||
await comfyPage.attachScreenshot('theme-menu-dark-active')
|
||||
|
||||
// Verify ColorPalette setting is set to "dark"
|
||||
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
|
||||
'dark'
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
|
||||
.toBe('dark')
|
||||
|
||||
// Close menu
|
||||
await topbar.closeTopbarMenu()
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
@@ -1,6 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
@@ -10,6 +12,17 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
const getColorPickerButton = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
|
||||
|
||||
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
|
||||
|
||||
const getColorPickerGroup = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
@@ -132,28 +145,24 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should be visible
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
await expect(colorPickerButton).toBeVisible()
|
||||
|
||||
// Click color picker button
|
||||
await colorPickerButton.click()
|
||||
|
||||
// Color picker dropdown should be visible
|
||||
const colorPickerDropdown = comfyPage.page.locator(
|
||||
'.color-picker-container'
|
||||
)
|
||||
await expect(colorPickerDropdown).toBeVisible()
|
||||
const colorPickerGroup = getColorPickerGroup(comfyPage)
|
||||
await expect(colorPickerGroup).toBeVisible()
|
||||
|
||||
// Select a color (e.g., blue)
|
||||
const blueColorOption = colorPickerDropdown.locator(
|
||||
'i[data-testid="blue"]'
|
||||
const blueColorOption = colorPickerGroup.getByTestId(
|
||||
TestIds.selectionToolbox.colorBlue
|
||||
)
|
||||
await blueColorOption.click()
|
||||
|
||||
// Dropdown should close after selection
|
||||
await expect(colorPickerDropdown).not.toBeVisible()
|
||||
await expect(colorPickerGroup).not.toBeVisible()
|
||||
|
||||
// Node should have the selected color class/style
|
||||
// Note: Exact verification method depends on how color is applied to nodes
|
||||
@@ -172,22 +181,21 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
|
||||
// Initially should show default color
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
|
||||
// Click color picker and select a color
|
||||
await colorPickerButton.click()
|
||||
const redColorOption = comfyPage.page.locator(
|
||||
'.color-picker-container i[data-testid="red"]'
|
||||
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
|
||||
TestIds.selectionToolbox.colorRed
|
||||
)
|
||||
await redColorOption.click()
|
||||
|
||||
// Button should now show the selected color
|
||||
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
|
||||
})
|
||||
|
||||
test('color picker shows mixed state for differently colored selections', async ({
|
||||
@@ -195,17 +203,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select first node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Select second node and color it differently
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="red"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorRed)
|
||||
.click()
|
||||
|
||||
// Select both nodes
|
||||
@@ -215,9 +223,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
])
|
||||
|
||||
// Color picker should show null/mixed state
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
})
|
||||
|
||||
@@ -226,9 +232,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// First color a node
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
// Clear selection
|
||||
@@ -238,10 +244,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should show the correct color
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
|
||||
})
|
||||
|
||||
test('colorization via color picker can be undone', async ({
|
||||
@@ -249,9 +253,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select a node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
// Undo the colorization
|
||||
|
||||
@@ -83,17 +83,15 @@ test.describe('Workflows sidebar', () => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
|
||||
const originalNodeCount = (await comfyPage.nodeOps.getNodes()).length
|
||||
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toEqual(originalNodeCount + 1)
|
||||
|
||||
await tab.getPersistedItem('workflow1.json').click()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
|
||||
.toEqual(1)
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).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?.constructor?.name === 'Subgraph'
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
@@ -375,6 +375,45 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Unpacking', () => {
|
||||
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-duplicate-links'
|
||||
)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
|
||||
return { error: 'No subgraph node found' }
|
||||
}
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const linkCount = graph.links.size
|
||||
const nodes = graph.nodes
|
||||
const ksampler = nodes.find((n) => n.type === 'KSampler')
|
||||
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||
|
||||
const linkedInputCount = ksampler.inputs.filter(
|
||||
(i) => i.link != null
|
||||
).length
|
||||
|
||||
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||
// because the subgraph output has no downstream connection.
|
||||
expect(result.linkCount).toBe(1)
|
||||
// KSampler should have exactly 1 linked input (latent_image)
|
||||
expect(result.linkedInputCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
622
browser_tests/tests/subgraphPromotion.spec.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Check whether we're currently in a subgraph.
|
||||
*/
|
||||
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
|
||||
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
const parentLink = breadcrumb.getByRole('link').first()
|
||||
await expect(parentLink).toBeVisible()
|
||||
await parentLink.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Widget Promotion',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.describe('Auto-promotion on Convert to Subgraph', () => {
|
||||
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select just the KSampler node (id 3) which has a "seed" widget
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SubgraphNode should exist
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
// The KSampler has a "seed" widget which is in the recommended list.
|
||||
// The promotion store should have at least the seed widget promoted.
|
||||
const nodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// SubgraphNode should have widgets (promoted views)
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('CLIPTextEncode text widget is auto-promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select the positive CLIPTextEncode node (id 6)
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||
expect(promotedNames.length).toBeGreaterThan(0)
|
||||
|
||||
// CLIPTextEncode is in the recommendedNodes list, so its text widget
|
||||
// should be promoted
|
||||
expect(promotedNames).toContain('text')
|
||||
})
|
||||
|
||||
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select the SaveImage node (id 9 in default workflow)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.click('title')
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
|
||||
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
|
||||
expect(promotedNames).toContain('filename_prefix')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
|
||||
test('Promoted text widget is visible on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The subgraph node (id 11) should have a text widget promoted
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets all render on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
const count = await textareas.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// SubgraphNode (id 11) should render with its body
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
// It should have the Enter Subgraph button
|
||||
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||
await expect(enterButton).toBeVisible()
|
||||
|
||||
// The promoted text widget should render inside the node
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
|
||||
// Widgets section should exist and have at least one widget
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
const count = await widgets.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Reactivity', () => {
|
||||
test('Value changes on promoted widget sync to interior widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
// Type into the promoted textarea on the SubgraphNode
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Interior CLIPTextEncode textarea should have the same value
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
})
|
||||
|
||||
test('Value changes on interior widget sync to promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'interior-value-sync-test'
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Type into the interior CLIPTextEncode textarea
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await interiorTextarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent graph
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
|
||||
// Promoted textarea on SubgraphNode should have the same value
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(testContent)
|
||||
})
|
||||
|
||||
test('Value persists through repeated navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'persistence-through-navigation'
|
||||
|
||||
// Set value on promoted widget
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
|
||||
// Navigate in and out multiple times
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(testContent)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Manual Promote/Demote via Context Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Can promote a widget from inside a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get the KSampler node (id 1) inside the subgraph
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
|
||||
// Right-click on the KSampler's "steps" widget (index 2) to promote it
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Can un-promote a widget from inside a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// First promote a canvas-rendered widget (KSampler "steps")
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back and verify promotion took effect
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
expect(initialWidgetCount).toBeGreaterThan(0)
|
||||
|
||||
// Navigate back in and un-promote
|
||||
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode2.navigateIntoSubgraph()
|
||||
const stepsWidget2 = await (
|
||||
await comfyPage.nodeOps.getNodeRefById('1')
|
||||
).getWidget(2)
|
||||
const widgetPos2 = await stepsWidget2.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos2,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const unpromoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Un-Promote Widget/ })
|
||||
|
||||
await expect(unpromoteEntry).toBeVisible()
|
||||
await unpromoteEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
|
||||
// SubgraphNode should have fewer widgets
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pseudo-Widget Promotion', () => {
|
||||
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The SaveImage node is in the recommendedNodes list, so its
|
||||
// filename_prefix widget should be auto-promoted
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(promotedNames.length).toBeGreaterThan(0)
|
||||
expect(promotedNames).toContain('filename_prefix')
|
||||
})
|
||||
|
||||
test('Converting SaveImage to subgraph promotes its widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select SaveImage (id 9)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.click('title')
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is a recommended node, so filename_prefix should be promoted
|
||||
const nodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||
expect(promotedNames.length).toBeGreaterThan(0)
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
const serialized = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.serialize()
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
|
||||
return window.app!.loadGraphData(workflow)
|
||||
}, serialized as ComfyWorkflowJSON)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
|
||||
expect(subgraphNodeIds.length).toBeGreaterThan(1)
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Vue Mode - Promoted Preview Content', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
// The node body should exist
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promotions exist
|
||||
const namesBefore = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(namesBefore.length).toBeGreaterThan(0)
|
||||
|
||||
// Delete the subgraph node
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node no longer exists, so promoted widgets should be gone
|
||||
const nodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.canvas.graph!.getNodeById('11')
|
||||
})
|
||||
expect(nodeExists).toBe(false)
|
||||
})
|
||||
|
||||
test('Removing I/O slot removes associated promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(initialWidgetCount).toBeGreaterThan(0)
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Remove the text input slot
|
||||
await comfyPage.subgraph.rightClickInputSlot('text')
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Widget count should be reduced
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
@@ -2,15 +2,20 @@ 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.workflow.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]
|
||||
@@ -25,16 +30,19 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return imagePreview
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"]').hover()
|
||||
await imagePreview.locator('[role="img"]').focus()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
@@ -42,9 +50,11 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('shows image context menu options', async ({ comfyPage }) => {
|
||||
await loadImageOnNode(comfyPage)
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.lg-node-header')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
@@ -55,4 +65,69 @@ 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'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 107 KiB |
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +20,16 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
await loadCheckpointNode.getByText('Load Checkpoint').click()
|
||||
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container')
|
||||
.locator('i[data-testid="blue"]')
|
||||
const colorPickerButton = comfyPage.page.getByTestId(
|
||||
TestIds.selectionToolbox.colorPickerButton
|
||||
)
|
||||
await colorPickerButton.click()
|
||||
|
||||
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
await colorPickerGroup
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
@@ -9,17 +9,20 @@ test.describe('Vue Upload 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()
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-upload-widgets.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
58
docs/WIDGET_SERIALIZATION.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 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()`
|
||||
45
docs/adr/0006-primitive-node-copy-paste-lifecycle.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 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 explicit typings or helpers
|
||||
data as unknown as SomeType // Avoid; prefer `as Partial<SomeType> as SomeType` or explicit typings
|
||||
```
|
||||
|
||||
### Accessing Internal State
|
||||
|
||||
@@ -36,6 +36,25 @@ 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,6 +39,10 @@ Prefer Vue native options when available:
|
||||
- Use inline Tailwind CSS only (no `<style>` blocks)
|
||||
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
|
||||
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
|
||||
- Exception: when third-party libraries render runtime DOM outside Vue templates
|
||||
(for example xterm internals inside PrimeVue terminal wrappers), scoped
|
||||
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
|
||||
exception is required.
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ 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'
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -4,21 +4,40 @@ export default {
|
||||
'tests-ui/**': () =>
|
||||
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||
|
||||
'./**/*.{css,vue}': (stagedFiles: string[]) => {
|
||||
const joinedPaths = toJoinedRelativePaths(stagedFiles)
|
||||
return [`pnpm exec stylelint --allow-empty-input ${joinedPaths}`]
|
||||
},
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
|
||||
...formatAndEslint(stagedFiles),
|
||||
'pnpm typecheck'
|
||||
]
|
||||
'./**/*.{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
|
||||
}
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames: string[]) {
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
function toJoinedRelativePaths(fileNames: string[]) {
|
||||
const relativePaths = fileNames.map((f) =>
|
||||
path.relative(process.cwd(), f).replace(/\\/g, '/')
|
||||
)
|
||||
return relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.1",
|
||||
"version": "1.41.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,10 +29,10 @@
|
||||
"knip": "knip --cache",
|
||||
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
|
||||
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
|
||||
"lint:no-cache": "oxlint src --type-aware && eslint src",
|
||||
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "oxlint src --type-aware && eslint src --cache",
|
||||
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
|
||||
@@ -99,6 +99,10 @@
|
||||
--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;
|
||||
|
||||
@@ -113,6 +117,7 @@
|
||||
--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);
|
||||
@@ -151,9 +156,11 @@
|
||||
:root {
|
||||
--fg-color: #000;
|
||||
--bg-color: #fff;
|
||||
--default-transition-duration: 0.1s;
|
||||
--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;
|
||||
@@ -218,6 +225,10 @@
|
||||
--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);
|
||||
@@ -377,6 +388,10 @@
|
||||
--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);
|
||||
@@ -412,6 +427,7 @@
|
||||
--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);
|
||||
@@ -510,6 +526,15 @@
|
||||
--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);
|
||||
@@ -1877,3 +1902,22 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3952,7 +3952,7 @@ export interface components {
|
||||
* @description The subscription tier level
|
||||
* @enum {string}
|
||||
*/
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
/**
|
||||
* @description The subscription billing duration
|
||||
* @enum {string}
|
||||
|
||||
538
pnpm-lock.yaml
generated
@@ -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> [start_time]
|
||||
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status>
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
@@ -31,8 +31,6 @@ 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}"
|
||||
@@ -135,23 +133,8 @@ post_comment() {
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post concise starting comment
|
||||
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
|
||||
)
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright: ⏳ Running..."
|
||||
post_comment "$comment"
|
||||
|
||||
else
|
||||
@@ -300,7 +283,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> [start_time]
|
||||
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status>
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
@@ -31,7 +31,6 @@ 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}"
|
||||
@@ -120,50 +119,9 @@ post_comment() {
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# 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 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..."
|
||||
post_comment "$comment"
|
||||
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
@@ -192,56 +150,57 @@ elif [ "$STATUS" = "completed" ]; then
|
||||
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
|
||||
WORKFLOW_URL="${WORKFLOW_URL:-}"
|
||||
|
||||
# Generate completion comment based on conclusion
|
||||
# Generate compact header based on conclusion
|
||||
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
status_icon="✅"
|
||||
status_text="Build completed successfully!"
|
||||
footer_text="🎉 Your Storybook is ready for review!"
|
||||
status_text="Built"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
|
||||
status_icon="⏭️"
|
||||
status_text="Build skipped."
|
||||
footer_text="ℹ️ Chromatic was skipped for this PR."
|
||||
status_text="Skipped"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
|
||||
status_icon="🚫"
|
||||
status_text="Build cancelled."
|
||||
footer_text="ℹ️ The Chromatic run was cancelled."
|
||||
status_text="Cancelled"
|
||||
else
|
||||
status_icon="❌"
|
||||
status_text="Build failed!"
|
||||
footer_text="⚠️ Please check the workflow logs for error details."
|
||||
status_text="Failed"
|
||||
fi
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
$status_icon **$status_text**
|
||||
# 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>
|
||||
|
||||
⏰ 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
|
||||
comment="$comment
|
||||
details="$details
|
||||
- ❌ Storybook deployment failed"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
comment="$comment
|
||||
- 🎨 $deployment_url"
|
||||
else
|
||||
comment="$comment
|
||||
- ⚠️ Build failed - $deployment_url"
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
details="$details
|
||||
- ⚠️ Build failed — $deployment_url"
|
||||
fi
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
comment="$comment
|
||||
details="$details
|
||||
- ⏭️ Storybook deployment skipped (build did not succeed)"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
|
||||
---
|
||||
$footer_text"
|
||||
details="$details
|
||||
|
||||
</details>"
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
$header
|
||||
|
||||
$details"
|
||||
|
||||
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 { FormItem, SettingParams } from '../src/platform/settings/types'
|
||||
import type { 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 as unknown as FormItem).name,
|
||||
tooltip: (config as unknown as FormItem).tooltip
|
||||
name: config.name,
|
||||
tooltip: config.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 unknown as Response)
|
||||
} as Partial<Response> 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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
126
src/components/appMode/AppModeToolbar.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isWorkflowsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
|
||||
)
|
||||
|
||||
function enterBuilderMode() {
|
||||
appModeStore.setMode('builder:select')
|
||||
}
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
|
||||
}
|
||||
|
||||
function openTemplates() {
|
||||
useWorkflowTemplateSelectorDialog().show('sidebar')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 pointer-events-auto">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar">
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.labels.menu')"
|
||||
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</template>
|
||||
</WorkflowActionsDropdown>
|
||||
|
||||
<Button
|
||||
v-if="appModeStore.enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilderMode"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex flex-col w-10 rounded-lg bg-secondary-background overflow-hidden"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isWorkflowsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.templates'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.templates')"
|
||||
class="size-10"
|
||||
@click="openTemplates"
|
||||
>
|
||||
<i class="icon-[comfy--template] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -103,13 +103,12 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
data-testid="subgraph-breadcrumb"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -13,17 +14,10 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
icon="pi pi-bars"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 ml-1.5 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@@ -41,7 +35,6 @@
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:ref="(el) => setItemRef(item, el)"
|
||||
:item="item"
|
||||
:is-active="item.key === activeItemKey"
|
||||
/>
|
||||
@@ -60,6 +53,7 @@ import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -77,12 +71,6 @@ const ICON_WIDTH = 20
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
|
||||
const setItemRef = (item: MenuItem, el: unknown) => {
|
||||
if (item.key === 'root') {
|
||||
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
|
||||
}
|
||||
}
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
@@ -136,13 +124,6 @@ const items = computed(() => {
|
||||
|
||||
const activeItemKey = computed(() => items.value.at(-1)?.key)
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_menu_selected'
|
||||
})
|
||||
rootItemRef.value?.toggleMenu(event)
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
|
||||
}
|
||||
@@ -214,8 +195,6 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
@@ -224,7 +203,7 @@ onUpdated(() => {
|
||||
|
||||
.subgraph-breadcrumb,
|
||||
:deep(.p-breadcrumb) {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb) {
|
||||
@@ -233,7 +212,10 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
height: calc(var(--spacing) * 8);
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
@@ -255,7 +237,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
@apply rounded-lg;
|
||||
border-radius: var(--radius-lg);
|
||||
border-color: var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
@@ -289,18 +271,16 @@ onUpdated(() => {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
@apply hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item:nth-last-child(3),
|
||||
.p-breadcrumb-separator:nth-last-child(2),
|
||||
.p-breadcrumb-item:nth-last-child(1) {
|
||||
@apply flex;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,9 +78,7 @@ interface Props {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
const { item, isActive = false } = defineProps<Props>()
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
@@ -103,7 +101,7 @@ const rename = async (
|
||||
) => {
|
||||
if (newName && newName !== initialName) {
|
||||
// Synchronize the node titles with the new name
|
||||
props.item.updateTitle?.(newName)
|
||||
item.updateTitle?.(newName)
|
||||
|
||||
if (workflowStore.activeSubgraph) {
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
@@ -127,13 +125,13 @@ const rename = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
const isRoot = item.key === 'root'
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (hasMissingNodes.value && isRoot) {
|
||||
return t('breadcrumbsMenu.missingNodesWarning')
|
||||
}
|
||||
return props.item.label
|
||||
return item.label
|
||||
})
|
||||
|
||||
const startRename = async () => {
|
||||
@@ -145,7 +143,7 @@ const startRename = async () => {
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
itemLabel.value = item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
@@ -165,12 +163,12 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
if (event.detail === 1) {
|
||||
if (props.isActive) {
|
||||
if (isActive) {
|
||||
menu.value?.toggle(event)
|
||||
} else {
|
||||
props.item.command?.({ item: props.item, originalEvent: event })
|
||||
item.command?.({ item: item, originalEvent: event })
|
||||
}
|
||||
} else if (props.isActive && event.detail === 2) {
|
||||
} else if (isActive && event.detail === 2) {
|
||||
menu.value?.hide()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
@@ -180,35 +178,27 @@ const handleClick = (event: MouseEvent) => {
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
await rename(itemLabel.value, item.label as string)
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleMenu
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-label {
|
||||
@apply whitespace-nowrap text-ellipsis overflow-hidden;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active-breadcrumb-item {
|
||||
|
||||
41
src/components/builder/BuilderDialog.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="flex w-full min-w-96 flex-col rounded-2xl bg-base-background">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="header-icon" />
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
<slot name="title" />
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="-mr-1"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
117
src/components/builder/BuilderSaveDialogContent.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<BuilderDialog @close="onClose">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveAs') }}
|
||||
</template>
|
||||
|
||||
<!-- Filename -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :for="inputId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.filename') }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="filename"
|
||||
autofocus
|
||||
type="text"
|
||||
class="flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground focus:outline-none"
|
||||
@keydown.enter="filename.trim() && onSave(filename.trim(), openAsApp)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save as type -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveAsLabel') }}
|
||||
</label>
|
||||
<div role="radiogroup" class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-for="option in saveTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="openAsApp === option.value"
|
||||
:class="
|
||||
cn(
|
||||
itemClasses,
|
||||
openAsApp === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="openAsApp = option.value"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="openAsApp === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="onClose">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!filename.trim()"
|
||||
@click="onSave(filename.trim(), openAsApp)"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { defaultFilename, onSave, onClose } = defineProps<{
|
||||
defaultFilename: string
|
||||
onSave: (filename: string, openAsApp: boolean) => void
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const inputId = useId()
|
||||
const filename = ref(defaultFilename)
|
||||
const openAsApp = ref(true)
|
||||
|
||||
const saveTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
|
||||
const itemClasses =
|
||||
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
|
||||
</script>
|
||||
51
src/components/builder/BuilderSaveSuccessDialogContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<BuilderDialog @close="onClose">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--circle-check-big] size-4 text-green-500" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveSuccess') }}
|
||||
</template>
|
||||
|
||||
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
|
||||
</p>
|
||||
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessAppPrompt') }}
|
||||
</p>
|
||||
<p v-else class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
:variant="savedAsApp ? 'muted-textonly' : 'secondary'"
|
||||
size="lg"
|
||||
@click="onClose"
|
||||
>
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="savedAsApp && onViewApp"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="onViewApp"
|
||||
>
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
workflowName: string
|
||||
savedAsApp: boolean
|
||||
onViewApp?: () => void
|
||||
onClose: () => void
|
||||
}>()
|
||||
</script>
|
||||
105
src/components/builder/BuilderToolbar.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
|
||||
:aria-label="t('builderToolbar.label')"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<template
|
||||
v-for="(step, index) in [selectStep, arrangeStep]"
|
||||
:key="step.id"
|
||||
>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === step.id && 'bg-interface-builder-mode-background',
|
||||
activeStep !== step.id &&
|
||||
'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@click="appModeStore.setMode(step.id)"
|
||||
>
|
||||
<StepBadge :step :index :model-value="activeStep" />
|
||||
<StepLabel :step />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
|
||||
</template>
|
||||
|
||||
<!-- Save -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!appModeStore.hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="appModeStore.setMode('builder:select')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
</button>
|
||||
</ConnectOutputPopover>
|
||||
<button
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === 'save'
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="appModeStore.setBuilderSaving(true)"
|
||||
>
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { AppMode } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const activeStep = computed(() =>
|
||||
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
||||
)
|
||||
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
|
||||
const selectStep: BuilderToolbarStep<AppMode> = {
|
||||
id: 'builder:select',
|
||||
title: t('builderToolbar.select'),
|
||||
subtitle: t('builderToolbar.selectDescription'),
|
||||
icon: 'icon-[lucide--mouse-pointer-click]'
|
||||
}
|
||||
|
||||
const arrangeStep: BuilderToolbarStep<AppMode> = {
|
||||
id: 'builder:arrange',
|
||||
title: t('builderToolbar.arrange'),
|
||||
subtitle: t('builderToolbar.arrangeDescription'),
|
||||
icon: 'icon-[lucide--layout-panel-left]'
|
||||
}
|
||||
|
||||
const saveStep: BuilderToolbarStep<'save'> = {
|
||||
id: 'save',
|
||||
title: t('builderToolbar.save'),
|
||||
subtitle: t('builderToolbar.saveDescription'),
|
||||
icon: 'icon-[lucide--cloud-upload]'
|
||||
}
|
||||
</script>
|
||||