Compare commits

..
Author SHA1 Message Date
Vito eb2db82a80 Merge pull request #106 from ComfyAssets/test/pinned-comfyui-ci
test(ci): automate real ComfyUI checks and prepare 1.2.0
2026-10-16 18:32:11 -07:00
Vito Sansevero 1820fb19ea test(ci): enforce reviewed CPU dependency versions 2026-10-16 17:29:08 -07:00
Vito Sansevero 7edf08f1e2 docs: repair legacy README references 2026-10-16 17:26:23 -07:00
Vito Sansevero 1721778cb5 test(ci): automate pinned ComfyUI checks and prepare 1.2.0 2026-10-16 17:23:11 -07:00
Vito 6bac864c4d Merge pull request #104 from ComfyAssets/fix/follow-execution-lifecycle
fix(follow_execution): honor live settings and stabilize subscriptions
2026-10-16 17:05:02 -07:00
Vito Sansevero 8dc81364a0 test(follow_execution): verify native menus in both renderers 2026-10-16 16:58:34 -07:00
Vito Sansevero 957ca3c142 fix(follow_execution): honor live settings and stabilize subscriptions 2026-10-16 16:50:58 -07:00
Vito ad73422472 Merge pull request #101 from ComfyAssets/chore/deprecate-gemini
chore(gemini): soft-deprecate legacy prompt node
2026-10-16 16:42:35 -07:00
Vito 1a7e0dfcd7 Merge branch 'main' into chore/deprecate-gemini 2026-10-16 16:36:20 -07:00
Vito 0b85600d38 Merge pull request #102 from ComfyAssets/feat/shared-custom-colors
feat(custom_colors): adopt shared ComfyAssets picker
2026-10-16 16:34:34 -07:00
Vito Sansevero ca65cc7961 test(loader): await initial gallery before keyboard controls 2026-10-16 16:25:33 -07:00
Vito Sansevero 6adba639bb feat(custom_colors): adopt shared ComfyAssets picker 2026-10-16 16:17:28 -07:00
Vito Sansevero 1e86b73168 chore(gemini): soft-deprecate legacy prompt node 2026-10-16 15:44:31 -07:00
Vito 0fa595a9b7 Merge pull request #98 from ComfyAssets/feat/shared-embedding-autocomplete
feat(autocomplete): unify UI and restore tag management
2026-10-16 15:38:45 -07:00
30 changed files with 1096 additions and 136 deletions
+1
View File
@@ -23,6 +23,7 @@ CONTRIBUTING.md
CODE_OF_CONDUCT.md
tools/
.ci-comfyui/
coverage/
TESTING.md
AGENTS.md
+1 -1
View File
@@ -5,7 +5,7 @@
"py3.10 on ubuntu-latest", "py3.11 on ubuntu-latest", "py3.12 on ubuntu-latest", "py3.13 on ubuntu-latest",
"py3.10 on windows-latest", "py3.11 on windows-latest", "py3.12 on windows-latest", "py3.13 on windows-latest",
"py3.10 on macos-latest", "py3.11 on macos-latest", "py3.12 on macos-latest", "py3.13 on macos-latest",
"coverage gate (85%)", "frontend syntax", "frontend workflows and coverage", "code-quality"
"coverage gate (85%)", "frontend syntax", "frontend workflows and coverage", "real ComfyUI compatibility", "code-quality"
]
},
"enforce_admins": true,
+56
View File
@@ -54,6 +54,62 @@ jobs:
if-no-files-found: ignore
retention-days: 14
comfyui:
name: real ComfyUI compatibility
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: tools/testing/package-lock.json
- name: Read the reviewed runtime pins
id: runtime
run: |
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
import runtime from './tools/testing/comfyui-runtime.json' with { type: 'json' };
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
JS
- name: Check out pinned ComfyUI
uses: actions/checkout@v7
with:
repository: ${{ steps.runtime.outputs.repository }}
ref: ${{ steps.runtime.outputs.revision }}
path: .ci-comfyui
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ steps.runtime.outputs.pythonVersion }}
cache: pip
cache-dependency-path: |
requirements-dev.txt
.ci-comfyui/requirements.txt
tools/testing/comfyui-runtime.json
- name: Install CPU runtime and test dependencies
run: |
python -m pip install --upgrade pip
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
- run: npm ci --prefix tools/testing --ignore-scripts
- run: npx --prefix tools/testing playwright install --with-deps chromium
- name: Classic and Nodes 2.0 startup and compatibility
env:
KIKO_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
KIKO_TEST_PYTHON: python
run: npm run --prefix tools/testing test:comfyui
- name: Upload real-host reports and startup logs
if: always()
uses: actions/upload-artifact@v7
with:
name: real-comfyui-reports
path: tools/testing/test-results/
if-no-files-found: ignore
retention-days: 14
coverage:
name: coverage gate (85%)
runs-on: ubuntu-latest
+3
View File
@@ -175,3 +175,6 @@ node_modules/
coverage/
playwright-report/
test-results/
# Disposable pinned host checkout used by CI
.ci-comfyui/
+38
View File
@@ -0,0 +1,38 @@
# Changelog
## 1.2.0
### Unified ComfyAssets UI
- Consistent blue controls and strong red destructive actions that follow
ComfyUI's light/dark theme.
- Updated Local Image Loader, Save Image viewer, Seed History, Workflow Timer,
dimension swaps, Node Dimensions, Display Text/Any and Embedding Autocomplete.
- Larger adjustable gallery thumbnails and saved-image previews that use the
available space; responsive layouts, keyboard controls and lifecycle cleanup.
- Shared Custom Colors picker with hex entry, HSV sliders and presets.
- Restored autocomplete tag management, four-column CSV parsing, worker-based
imports and persistent tag caching. Tag packs remain a separate download;
see [tag-pack provenance and terms](docs/tag-packs/README.md).
- Follow Execution honors live enablement, avoids duplicate subscriptions and
safely groups navigation menus.
### Compatibility and testing
- Classic and Nodes 2.0 coverage includes fresh startup in each renderer,
switching both ways, themes, saved workflows and removal cleanup.
- Required CPU-only real-ComfyUI CI joins the existing Python, JavaScript,
Chromium, touch, Firefox and WebKit suites. Runtime pins and failure artifacts
make compatibility checks repeatable.
- Retained [screenshots](docs/screenshots/README.md) and documented
[testing commands](TESTING.md).
### Deprecation and upgrade
Gemini Prompt Engineer is deprecated and hidden from normal new-node discovery.
Its registration, schema and execution remain available for saved workflows;
ongoing Google API/model compatibility maintenance has ended.
Restart ComfyUI and hard-refresh the browser after upgrading. Existing node
interfaces, workflow values and setting IDs are retained. No workflow conversion
is required. Follow Execution remains opt-in through its Enable setting.
+71 -36
View File
@@ -1,7 +1,7 @@
# ComfyUI-KikoTools
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)
![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
![ComfyUI](https://img.shields.io/badge/ComfyUI-compatible-green.svg)
![Tests](https://github.com/ComfyAssets/ComfyUI-KikoTools/workflows/Tests/badge.svg)
![Code Quality](https://github.com/ComfyAssets/ComfyUI-KikoTools/workflows/Code%20Quality/badge.svg)
@@ -17,6 +17,20 @@ I tried forking, patching, and submitting merge requests, but eventually decided
I’m sharing them here with the community, and I hope you find them as useful as I do.
## 1.2.0 — unified ComfyAssets UI
A consistent interface across the KikoTools controls, with ComfyUI-following
light/dark styling and Classic/Nodes 2.0 support. Existing workflow interfaces
and settings are retained. See the [release notes](CHANGELOG.md),
[migration overview](docs/ui-migration.md) and [screenshots](docs/screenshots/).
## ComfyUI Classic and Nodes 2.0
KikoTools supports both ComfyUI Classic and Nodes 2.0. Our shared ComfyAssets
controls follow ComfyUI's dark/light theme, with automated tests covering renderer
switching, workflow persistence and cleanup. See the retained
[UI screenshots](docs/screenshots/) for examples from the migrations.
## 🚀 Features
### ✨ Current Tools
@@ -30,7 +44,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images |
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display |
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts |
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | Deprecated; retained for existing workflows | 🧠 Prompts |
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
@@ -176,28 +190,17 @@ Advanced text display node with intelligent formatting and enhanced user interac
![Display Text Example](examples/workflows/display_text_example.png)
#### 🤖 Gemini Prompt Engineer
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation
- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details
- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions
- **Custom System Prompts**: Override templates with your own analysis instructions
- **Flexible API Key Management**: Environment variable, config file, or direct input
- **Visual Status Feedback**: Real-time processing indicators and error states
- **Help Integration**: Built-in setup guide and documentation
- **Dynamic Model Refresh**: Fetch latest Gemini models with refresh button
- **Model Caching**: Persistent model list storage for offline access
- **Enhanced SDXL Prompts**: Improved formatting with layered structure and quality boosters
**Deprecated — retained for existing workflows only.** ComfyUI hides this node
from normal new-node discovery using its deprecated-node metadata. The
`GeminiPrompt` identifier, inputs, outputs and execution remain available so
saved workflows can still load.
**Use Cases:**
- Reverse-engineer prompts from reference images
- Convert artistic descriptions between different AI model formats
- Generate consistent style descriptions across workflows
- Create detailed scene breakdowns for complex compositions
- Analyze and replicate lighting/mood from existing artwork
- Access latest Gemini models including 2.0 and 2.5 versions
![Gemini Prompt Example](examples/workflows/gemini_prompt_example.png)
Google API/model compatibility updates and UI migration have ended. Existing
workflows still depend on Google's service and the optional `google-genai`
package; deprecation does not guarantee future API compatibility. See the
[legacy Gemini documentation](examples/documentation/gemini_prompt.md) for the
existing configuration and behavior.
#### 🔍 Display Any
Universal debugging node that displays any type of input value or tensor information.
@@ -429,8 +432,6 @@ Download models, LoRAs, and other assets directly from CivitAI, HuggingFace, and
- Build reproducible workflows with automatic model fetching
- Quickly test new models from the community
![Model Downloader Example](examples/workflows/model_downloader_example.png)
#### ⏱️ Workflow Timer
Real-time execution timer that displays workflow duration with millisecond precision.
@@ -451,6 +452,30 @@ Real-time execution timer that displays workflow duration with millisecond preci
- **Workflow Timer: Color** - Custom color picker for timer display
- **Workflow Timer: Enable Glow** - Toggle pulsing glow effect on/off
#### 🎨 Custom Colors
Right-click a node and choose **🫶 Custom Colors** to change its full color,
title or background. The shared picker supports hex entry, HSV sliders and
presets, and follows ComfyUI's dark/light theme. Changes apply immediately;
**Done** or **Escape** closes the picker. Multiple selected nodes receive the
same change. Right-click a group for its color option.
Existing Custom Colors settings control the available menu options and automatic
title shading. Node and group colors are saved with the workflow.
Screenshots: [Dark](docs/screenshots/custom-colors/dark.png) ·
[Light](docs/screenshots/custom-colors/light.png).
#### 🧭 Follow Execution
Enable **Follow Execution: Enable** in ComfyUI settings, then right-click the
canvas to follow/stop execution, show the executing node, or navigate to a node
by type. **Auto-start** begins following when a workflow starts. Disabling the
feature stops following immediately; enabling it again waits for a manual start
or the next workflow start when Auto-start is enabled. The same host menus work
in Classic and Nodes 2.0. See the [Classic](docs/screenshots/follow-execution/classic.png)
and [Nodes 2.0](docs/screenshots/follow-execution/nodes2.png) menu screenshots.
#### 🗂️ Project File Path
Builds an organized save prefix to wire into a save node's `filename_prefix`.
@@ -679,6 +704,9 @@ Gemini Prompt → Display Text → Copy to Clipboard
**Smart Detection:** Automatically formats SDXL-style prompts
### Gemini Prompt Engineer Example
**Legacy example for the deprecated node; existing workflows only.**
```
Load Image → Gemini Prompt → Display Text → Text Generation Model
🖼️ reference ↘ type: SDXL ↘ split view ↘ "detailed portrait..."
@@ -773,11 +801,11 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
| **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) |
| **Display Text** | Advanced text display with smart prompt detection and split view | ✅ Complete | [Docs](examples/documentation/display_text.md) |
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) |
| **Gemini Prompt Engineer** | Legacy image analysis and prompt generation | Deprecated | [Docs](examples/documentation/gemini_prompt.md) |
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](examples/documentation/image_scale_down_by.md) |
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](examples/documentation/film_grain.md) |
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](#-image-scale-down-by) |
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](#-film-grain) |
| **Flux Sampler Params** | FLUX-optimized parameter generator with batch support | ✅ Complete | [Docs](examples/documentation/flux_sampler_params.md) |
| **LoRA Folder Batch** | Batch process multiple LoRAs from folders | ✅ Complete | [Docs](examples/documentation/lora_folder_batch.md) |
| **Plot Parameters** | Visualize parameter effects with graphs | ✅ Complete | [Docs](examples/documentation/plot_parameters.md) |
@@ -785,8 +813,8 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
| **Scheduler Select Helper** | Optimal scheduler selection for samplers | ✅ Complete | [Docs](examples/documentation/scheduler_select_helper.md) |
| **Text Encode Sampler Params** | Combined text encoding and parameter management | ✅ Complete | [Docs](examples/documentation/text_encode_sampler_params.md) |
| **Local Image Loader** | Visual gallery browser for local media files | ✅ Complete | [Docs](examples/documentation/local_image_loader.md) |
| **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | ✅ Complete | [Docs](examples/documentation/model_downloader.md) |
| **Workflow Timer** | Real-time execution timer with customizable display | ✅ Complete | [Docs](examples/documentation/workflow_timer.md) |
| **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | ✅ Complete | [Docs](#-model-downloader) |
| **Workflow Timer** | Real-time execution timer with customizable display | ✅ Complete | [Docs](#️-workflow-timer) |
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
@@ -942,7 +970,7 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
### Prerequisites
- Python 3.8+
- Python 3.10+
- ComfyUI installation
- PyTorch 2.0+
@@ -1080,21 +1108,28 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 21 (15 core tools + 6 xyz-helpers)
- **Features**: Embedding Autocomplete (settings-based, not a node)
- **Categories**: 9 emoji-based categories for better organization
- **Registered Nodes**: 33, including the deprecated Gemini node
- **Extensions**: Embedding Autocomplete, Custom Colors and Follow Execution
- **Organization**: Tools grouped under ComfyAssets
- **Download Platforms**: 3 (CivitAI, HuggingFace, Custom URLs)
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
- **AI Integration**: Gemini API with 40+ model support
- **Legacy AI Integration**: Deprecated Gemini node retained for saved workflows
- **Test Coverage**: Enforced 85% Python statement/branch coverage and per-file frontend coverage floors
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
- **Python Version**: 3.10+
- **ComfyUI Compatibility**: Classic and Nodes 2.0; tested runtime pins in [TESTING.md](TESTING.md)
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-genai for Gemini)
## 🙏 Attribution
### UI inspiration
[Pixaroma's ComfyUI toolkit](https://github.com/pixaroma/ComfyUI-Pixaroma)
inspired the reusable controls and editor organization. ComfyAssets uses its own
implementation and styling; no Pixaroma toolkit code or assets are vendored.
### xyz-helpers Tools
The xyz-helpers collection was adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted these essential tools to ensure continued support and compatibility with modern ComfyUI workflows. We're grateful for cubiq's original work and contributions to the ComfyUI community.
+74 -37
View File
@@ -9,7 +9,8 @@ original implementation, then run the relevant suites after the fix.
| --- | --- | --- |
| Python | Tool logic, tensors, image files, API validation | ComfyUI services and external models where needed |
| JavaScript | Production extension hooks and callback behavior | Minimal host objects; controlled timers for focused regressions |
| Browser | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint |
| Browser fixtures | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint |
| Real ComfyUI | Pinned server/frontend, LiteGraph, native menus, DOM widgets, initial renderers, switching and workflow serialization | Disposable media routes; simulated execution/progress events; model/service boundaries |
Browser workflows cover swaps, local image browsing/selection, pagination,
sorting, hidden folders, directory autocomplete, saved paths,
@@ -67,53 +68,89 @@ CI artifacts include Python JUnit results, Python HTML/XML coverage, and
and screenshots. Open traces using Playwright's `show-trace` command. Local
frontend coverage is at `coverage/js/index.html`.
Real ComfyUI/LiteGraph compatibility, GPU execution, cloud inference, network
model downloads, and visual design still need targeted live checks when changed.
The browser host is a test double; these workflows do not run the real ComfyUI
frontend or certify every node's UI. Type checking and security jobs retain their
existing advisory behavior.
The fixture suite covers plugin behavior without a real ComfyUI frontend. The
separate required real-host job covers the integration described below. GPU/model
execution, cloud inference and model downloads still need targeted checks when
changed; the compatibility suite does not queue workflows or download weights.
Type checking and security jobs retain their existing advisory behavior.
### Actual ComfyUI frontend smoke test
## Pinned real-ComfyUI compatibility
The Local Image Loader also has an opt-in suite using the actual ComfyUI frontend,
graph, DOM widgets, renderer switch and palette settings. Start a local ComfyUI
0.39.0 with frontend 1.55.14 and KikoTools installed, then run:
The required **real ComfyUI compatibility** CI job provisions a CPU-only host.
`tools/testing/comfyui-runtime.json` pins upstream commit
`e8665ca8e4089ab917567cdb491308e1397663d4` (server 0.39.0), frontend 1.55.14,
Python 3.12 and the CPU PyTorch/torchvision pair. The launcher rejects a different
revision or modified tracked upstream files. The suite verifies server/frontend
versions and attaches them to each test's report.
Two projects, `classic-start` and `nodes2-start`, select the renderer during
startup, before creating nodes. Both run the complete suite, including live
renderer switching, zoom/resize, palette changes, workflow save/reload and
cleanup. Test-local settings start from a blank workspace and skip first-run
onboarding. They do not change the server's saved preferences.
The migrated controls covered are Local Image Loader, Save Image, Seed History,
Workflow Timer and its color picker, both dimension swap nodes, Node Dimensions,
Display Text/Any, Embedding Autocomplete/settings/tag imports, Custom Colors and
Follow Execution. Native canvas menus are opened with actual right clicks.
Execution/progress events are simulated; generation is never queued.
### Managed local host
Use a separate clean checkout of the pinned ComfyUI commit and a Python 3.12
environment containing its `requirements.txt` and this repository's
`requirements-dev.txt`. Install the CPU torch/torchvision versions from the
runtime manifest first, using the official CPU wheel index. The CI workflow in
`.github/workflows/tests.yml` contains the full provisioning recipe.
```sh
KIKO_COMFY_URL=http://127.0.0.1:8188/ node \
tools/testing/node_modules/@playwright/test/cli.js test \
--config tools/testing/comfyui.config.mjs
KIKO_COMFY_ROOT=/path/to/pinned/ComfyUI \
KIKO_TEST_PYTHON=/path/to/cpu-venv/bin/python \
npm run --prefix tools/testing test:comfyui
```
The suite requires those exact versions and records them in its artifacts. It
serves the current checkout's extensions to a separate browser context and loads
only KikoTools extensions. Media requests use the existing real Python routes
with disposable fixture data on port 8771. Other HTTP writes are intercepted,
so user settings, saved workflows and selections are not written to the live
server. It does not execute a workflow or download models.
Playwright owns this host on `127.0.0.1:8189` and a fixture server on port 8771.
The launcher creates a temporary base/user/model directory, installs only a
symlink to the current KikoTools checkout, disables other custom nodes and
partner services, and uses an in-memory database. It removes owned state after
normal shutdown, startup failure or process termination. Existing installations
and model folders are not used. An occupied port fails instead of reusing an
unrelated server.
Checks cover Classic/Nodes 2.0/live switching, theme changes, workflow save/reload
and removal for the loader. Save Image additionally checks real image output,
window close/reopen through the node control across renderer switches, theme
changes, exclusion of the launcher from serialized values and owner removal.
Its `/view` requests also use disposable fixture output rather than live files.
Seed History checks keyboard-operable rows through renderer switches, list growth
and scrolling on resize, palettes, saved history/node dimensions, serialization
and polling cleanup on removal.
Workflow Timer covers execution events, Classic/Nodes 2.0 switching, themes,
custom node colors, persisted time/dimensions and the actual settings picker.
Both dimension swap nodes cover keyboard swaps across renderers and zoom,
responsive button sizing, palettes, native workflow values/saved sizes and
removal cleanup. Latent batch size is preserved.
Screenshots/traces appear in
`tools/testing/test-results/comfyui/`. This opt-in smoke suite is separate from
the required fixture CI job; fully provisioned, pinned ComfyUI CI and testing
alongside other node packs remain follow-up work in issue #81.
### Existing local host
An opt-in mode still supports an already running host with the same pinned
versions:
```sh
KIKO_COMFY_URL=http://127.0.0.1:8188/ \
npm run --prefix tools/testing test:comfyui
```
Only loopback URLs are accepted. A separate browser context serves the current
checkout's extensions. Media requests use real plugin Python routes with
disposable fixture data. Other HTTP writes are intercepted, preserving server
settings, saved workflows and selections. This mode does not start or stop the
existing server.
### Reports and pin updates
CI retains `real-comfyui-reports` for 14 days, including server startup logs at
`tools/testing/test-results/comfyui-host.log`, browser traces, screenshots and
version attachments. Startup logs survive failure. Reviewed captures copied to
[docs/screenshots](docs/screenshots/README.md) are committed separately from
throwaway test artifacts.
To update the host, review and change the runtime manifest, provision the new
checkout/dependencies, and run both startup projects before merging. A version
assertion update alone is not compatibility validation. Cross-pack installation
and shared-source version compatibility remain tracked in #81.
## Required merge checks
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
Python coverage, frontend syntax, frontend workflows/coverage, and the existing
Python coverage, frontend syntax, frontend workflows/coverage, real ComfyUI
compatibility, and the existing
`code-quality` job (including Black and fatal lint checks). Administrators follow
the same checks and branches must be current with main. No extra human approval
is required. Workflows run on every pull request without path filters.
+22
View File
@@ -0,0 +1,22 @@
# ComfyAssets UI screenshots
Retained captures from the KikoTools UI migrations. Real-ComfyUI tests cover
Classic and Nodes 2.0, renderer switching, saved workflows and cleanup.
| Interface | Captures |
| --- | --- |
| Local Image Loader | [Classic](live-image-loader/classic.png), [Nodes 2.0](live-image-loader/nodes2.png) |
| Saved-image viewer | [Dark](save-image-viewer/dark.png), [Light](save-image-viewer/light.png) |
| Seed History | [Dark](seed-history/dark.png), [Light](seed-history/light.png) |
| Workflow Timer | [Dark](workflow-timer/dark.png), [Light](workflow-timer/light.png), [Color picker](workflow-timer/color-picker.png) |
| Dimension swap | [Selector dark](dimension-swap/selector-dark.png), [Selector light](dimension-swap/selector-light.png), [Latent dark](dimension-swap/latent-dark.png), [Latent light](dimension-swap/latent-light.png) |
| Node Dimensions | [Dark](node-dimensions/dark.png), [Light](node-dimensions/light.png) |
| Display Text | [Dark](display-output/text-dark.png), [Light](display-output/text-light.png) |
| Display Any | [Dark](display-output/any-dark.png), [Light](display-output/any-light.png) |
| Embedding Autocomplete | [Suggestions dark](embedding-autocomplete/suggestions-dark.png), [Suggestions light](embedding-autocomplete/suggestions-light.png), [Settings dark](embedding-autocomplete/settings-dark.png), [Settings light](embedding-autocomplete/settings-light.png) |
| Custom Colors | [Dark](custom-colors/dark.png), [Light](custom-colors/light.png) |
| Follow Execution | [Classic](follow-execution/classic.png), [Nodes 2.0](follow-execution/nodes2.png) |
Screenshots record the tested appearance at capture time. Updated Playwright
captures are temporary test artifacts until copied into this directory and
committed for review.
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+44
View File
@@ -0,0 +1,44 @@
# KikoTools UI migration
KikoTools' custom controls use the original ComfyAssets foundation in `web/ui/`.
They share blue primary actions, red destructive actions, keyboard controls and
ComfyUI-following dark/light colors. Layouts resize with the available space.
Pixaroma's toolkit inspired the organization; its code/assets are not vendored.
| Interface | Shared implementation | Retained examples |
| --- | --- | --- |
| Local Image Loader | `views/image-browser.js`, controls and scoped controller | [Loader](screenshots/live-image-loader/) |
| Save Image | Floating window, batch selection and responsive preview | [Viewer](screenshots/save-image-viewer/) |
| Seed History | `views/seed-history.js`, scoped watcher and disclosure | [History](screenshots/seed-history/) |
| Workflow Timer | Shared clock, controls and color picker | [Timer](screenshots/workflow-timer/) |
| Width Height / Empty Latent swaps | `ui/dimension-swap.js`, `ui/dimensions.js` | [Swaps](screenshots/dimension-swap/) |
| Node Dimensions | `views/node-dimensions.js`, native dialog shell | [Dimensions](screenshots/node-dimensions/) |
| Display Text / Any | Shared output view, controller and widget adapter | [Output](screenshots/display-output/) |
| Embedding Autocomplete | Shared suggestions/settings, worker parsing and cache | [Autocomplete](screenshots/embedding-autocomplete/) |
| Custom Colors | Shared color picker, native node/group menus | [Colors](screenshots/custom-colors/) |
| Follow Execution | Native host menus/settings, idempotent execution listeners | [Menus](screenshots/follow-execution/) |
`createScope()` owns listeners, observers and delayed work. Node removal preserves
previous lifecycle hooks while disposing owned resources. Host-specific text
widget handling lives in `ui/text-widget-adapter.js`; floating windows and native
dialogs manage their own focus and cleanup. See [module notes](../web/ui/README.md).
Pure Python nodes continue using ComfyUI's native widgets. Gemini Prompt
Engineer retains its legacy UI for saved workflows under the agreed deprecation;
its UI migration was cancelled.
The [test guide](../TESTING.md) distinguishes fixture coverage, real-host
compatibility and model/GPU behavior. Required real-host CI uses a reviewed
upstream commit, fresh Classic/Nodes 2.0 starts and disposable CPU-only state.
## Remaining shared-toolset work
Before PromptManager adopts the foundation, extract a versioned shared source
package with reproducible browser artifacts. Then test both node packs alone and
together, including different bundled foundation versions, isolated settings
and identical styling. These requirements remain open in issue #81; the KikoTools
migration does not establish cross-pack compatibility by itself.
The approved design-preview draft [#82](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
records the original direction. This inventory describes the shipped interfaces
and supersedes that draft's implementation sequence.
+12 -1
View File
@@ -1,4 +1,15 @@
# Gemini Prompt Engineer
# Gemini Prompt Engineer (Deprecated)
**Legacy documentation for existing workflows.** This node is soft-deprecated
and hidden from normal new-node discovery through ComfyUI's deprecated-node
metadata. Users who enable deprecated-node visibility may still find it.
The `GeminiPrompt` registration, display name, input/output schema and execution
remain available for saved workflows. Existing configuration and model-refresh
behavior remain in place. Google API/model compatibility updates and UI migration
have ended; the historical model and setup details below may no longer match
Google's current service. No replacement or automatic workflow conversion is
provided.
The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models.
+5
View File
@@ -12,6 +12,8 @@ from .models import get_available_models
class GeminiPromptNode(ComfyAssetsBaseNode):
"""Analyzes images using Gemini AI to generate optimized prompts for various AI models."""
DEPRECATED = True
@classmethod
def INPUT_TYPES(cls):
"""Define input types for the node."""
@@ -54,6 +56,9 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = """
Deprecated: retained for existing workflows only. This node no longer receives
Google API/model compatibility updates.
Analyzes images using Google's Gemini AI to generate optimized prompts.
Supports multiple prompt formats:
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.1.2"
version = "1.2.0"
license = {text = "MIT"}
dependencies = []
+227
View File
@@ -41,6 +41,14 @@ test('plugin startup registers every frontend extension and exposes real autocom
});
test('shared loader controls follow host themes, resize and support keyboard selection', async ({ page }) => {
let firstListing = true;
await page.route('**/kiko_local_image_loader/images?*', async route => {
if (firstListing) {
firstListing = false;
await new Promise(resolve => setTimeout(resolve, 1000));
}
await route.continue();
});
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
const loader = page.locator('.ca-image-browser');
@@ -49,6 +57,7 @@ test('shared loader controls follow host themes, resize and support keyboard sel
await expect(loader).toHaveAttribute('data-ca-theme', 'dark');
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#fafafa'));
await expect(loader).toHaveAttribute('data-ca-theme', 'light');
await expect(loader.getByRole('button', { name: 'Select image cat & dog.png', exact: true })).toBeVisible();
const toggle = loader.getByRole('switch', { name: 'Hide dot folders' });
await toggle.focus();
await page.keyboard.press('Space');
@@ -1599,3 +1608,221 @@ test('autocomplete stops an oversized URL stream before it reaches the parser',
expect(result.cancelled).toBe(true);
expect(result.error).toMatch(/large/i);
});
async function openCustomColor(page, kind = 'Full') {
await page.evaluate(kind => {
const extension = kikoHost.extensions.find(item => item.name === 'kikotools.customColors');
const node = kikoHost.app.graph._nodes[0];
extension.getNodeMenuItems(node)[1].submenu.options.find(item => item.content === `🫶 Custom ${kind}`).callback();
}, kind);
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.locator('ca-color-picker')).toHaveCSS('display', 'none');
}
test('custom colors share the themed picker and preserve full, title, background and selection behavior', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput');
await createNode(page, 'TextInput');
await page.evaluate(() => {
const [first, second] = kikoHost.app.graph._nodes;
first.bgcolor = '#123456';
first.setColorOption = function (value) { Object.assign(this, value); };
kikoHost.app.canvas.selected_nodes = { [first.id]: first, [second.id]: second };
window.LGraphCanvas = { active_canvas: kikoHost.app.canvas };
});
await openCustomColor(page);
const dialog = page.getByRole('dialog');
await expect(dialog.getByLabel('Hex color', { exact: true })).toHaveValue('#123456');
for (const [color, theme] of [['#fff', 'light'], ['#202020', 'dark']]) {
await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color);
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
}
await dialog.getByLabel('Hex color', { exact: true }).fill('#112233');
await dialog.getByLabel('Hex color', { exact: true }).press('Tab');
expect(await page.evaluate(() => kikoHost.app.graph._nodes.map(({ color, bgcolor }) => ({ color, bgcolor })))).toEqual([
{ color: '#253647', bgcolor: '#112233' }, { color: '#253647', bgcolor: '#112233' },
]);
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
await expect(page.locator('ca-color-picker')).toHaveCount(0);
await page.evaluate(() => { kikoHost.app.canvas.selected_nodes = {}; });
for (const kind of ['Title', 'BG']) {
await openCustomColor(page, kind);
await dialog.getByRole('button', { name: 'Use color #0877BB', exact: true }).click();
await page.keyboard.press('Escape');
await expect(page.locator('ca-color-picker')).toHaveCount(0);
}
expect(await page.evaluate(() => kikoHost.app.graph._nodes.map(({ color, bgcolor }) => ({ color, bgcolor })))).toEqual([
{ color: '#0877bb', bgcolor: '#0877bb' }, { color: '#253647', bgcolor: '#112233' },
]);
});
test('custom colors replace open pickers, preserve removal hooks and release detached dialogs', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'TextInput');
await openCustomColor(page);
await openCustomColor(page, 'Title');
await expect(page.locator('dialog')).toHaveCount(1);
expect(await page.evaluate(id => kikoHost.removeNode(id).removed, id)).toBe(true);
await expect(page.locator('dialog')).toHaveCount(0);
await expect(page.locator('ca-color-picker')).toHaveCount(0);
});
test('custom colors retain group application and menu setting filters', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput');
await page.evaluate(() => {
window.testGroup = { color: '#abc', setColorOption(value) { this.color = value.groupcolor; } };
kikoHost.app.canvas.setDirty = () => { window.groupDirtied = true; };
kikoHost.app.canvas.graph_mouse = [5, 10];
kikoHost.app.canvas.graph = { getGroupOnPos: (x, y) => x === 5 && y === 10 ? testGroup : null };
const extension = kikoHost.extensions.find(item => item.name === 'kikotools.customColors');
extension.getCanvasMenuItems(kikoHost.app.canvas)[1].submenu.options[0].callback();
});
const dialog = page.getByRole('dialog');
await expect(dialog.getByLabel('Hex color', { exact: true })).toHaveValue('#AABBCC');
await dialog.getByRole('button', { name: 'Use color #0877BB', exact: true }).click();
expect(await page.evaluate(() => [testGroup.color, groupDirtied])).toEqual(['#0877bb', true]);
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
expect(await page.evaluate(() => {
const extension = kikoHost.extensions.find(item => item.name === 'kikotools.customColors');
const node = kikoHost.app.graph._nodes[0];
for (const key of ['show_full', 'show_title', 'show_bg']) kikoHost.settings.set(`kikotools.custom_colors.${key}`, false);
const empty = [extension.getNodeMenuItems(node), extension.getCanvasMenuItems(kikoHost.app.canvas)];
kikoHost.settings.set('kikotools.custom_colors.enabled', false);
return [...empty, extension.getNodeMenuItems(node), extension.getCanvasMenuItems(kikoHost.app.canvas)];
})).toEqual([[], [], [], []]);
});
test('custom colors keep unshaded fallback colors and restore keyboard focus', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput');
await page.evaluate(() => {
kikoHost.settings.set('kikotools.custom_colors.auto_shade', false);
const opener = document.createElement('button');
opener.textContent = 'Open custom colors';
opener.onclick = () => {
const extension = kikoHost.extensions.find(item => item.name === 'kikotools.customColors');
extension.getNodeMenuItems(kikoHost.app.graph._nodes[0])[1].submenu.options[0].callback();
};
document.body.append(opener);
});
const opener = page.getByRole('button', { name: 'Open custom colors', exact: true });
await opener.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Use color #0877BB', exact: true }).click();
expect(await page.evaluate(() => {
const node = kikoHost.app.graph._nodes[0];
return [node.color, node.bgcolor];
})).toEqual(['#0877bb', '#0877bb']);
await page.keyboard.press('Escape');
await expect(opener).toBeFocused();
await expect(page.locator('ca-color-picker')).toHaveCount(0);
await opener.click();
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
await expect(opener).toBeFocused();
});
test('custom colors handle absent settings, empty canvas menus, legacy groups and detached openers', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput');
expect(await page.evaluate(() => {
const extension = kikoHost.extensions.find(item => item.name === 'kikotools.customColors');
kikoHost.settings.delete('kikotools.custom_colors.enabled');
return [extension.getCanvasMenuItems({}), extension.getCanvasMenuItems({ graph_mouse: [0, 0], graph: { getGroupOnPos: () => null } }), extension.getNodeMenuItems(kikoHost.app.graph._nodes[0])[1].submenu.options.length];
})).toEqual([[], [], 3]);
await page.evaluate(() => {
const extension = kikoHost.extensions.find(item => item.name === 'kikotools.customColors');
window.testGroup = { color: '#000000' };
kikoHost.app.canvas.setDirty = () => {};
const focusTarget = document.createElement('button');
focusTarget.textContent = 'Canvas focus target';
kikoHost.app.canvas.canvas = focusTarget;
const temporaryOpener = document.createElement('button');
document.body.append(temporaryOpener, focusTarget);
temporaryOpener.focus();
extension.getCanvasMenuItems({ graph_mouse: [0, 0], graph: { getGroupOnPos: () => testGroup } })[1].submenu.options[0].callback();
temporaryOpener.remove();
});
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Use color #FFFFFF', exact: true }).click();
expect(await page.evaluate(() => testGroup.color)).toBe('#ffffff');
await page.keyboard.press('Escape');
await expect(page.getByRole('button', { name: 'Canvas focus target', exact: true })).toBeFocused();
await page.evaluate(() => {
delete kikoHost.app.canvas.selected_nodes;
const extension = kikoHost.extensions.find(item => item.name === 'kikotools.customColors');
class BareNode {}
extension.beforeRegisterNodeDef(BareNode);
new BareNode().onRemoved();
});
await openCustomColor(page);
await dialog.getByRole('button', { name: 'Use color #000000', exact: true }).click();
expect(await page.evaluate(() => kikoHost.app.graph._nodes[0].color)).toBe('#141414');
await dialog.getByRole('button', { name: 'Use color #FFFFFF', exact: true }).click();
expect(await page.evaluate(() => kikoHost.app.graph._nodes[0].color)).toBe('#ffffff');
await page.keyboard.press('Escape');
});
test('Follow Execution honors live settings, manual controls and repeated setup', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'TextInput');
expect(await page.evaluate(async id => {
const { app, extensions } = kikoHost;
const extension = extensions.find(item => item.name === 'kikotools.followExecution');
const node = app.graph._nodes[0];
node.getTitle = () => node.title;
const centered = [];
app.canvas.centerOnNode = node => centered.push(node.id);
app.runningNodeId = id;
const set = (name, value) => app.ui.settings.setSettingValue(`kikotools.follow_execution.${name}`, value);
set('enabled', true);
extension.getCanvasMenuItems()[1].callback();
kikoHost.emit('executing', id);
const afterManual = centered.length;
set('enabled', false);
kikoHost.emit('executing', id);
const afterDisable = centered.length;
set('enabled', true);
kikoHost.emit('executing', id);
const afterEnable = centered.length;
set('auto_start', true);
for (let i = 0; i < 3; i++) await extension.setup();
kikoHost.emit('execution_start');
kikoHost.emit('executing', id);
const afterRepeatedSetup = centered.length;
extension.getCanvasMenuItems()[1].callback();
kikoHost.emit('executing', id);
const afterStop = centered.length;
extension.getCanvasMenuItems().find(item => item?.content === '🫶 Show executing node').callback();
const afterShow = centered.length;
kikoHost.emit('executing', 'missing');
kikoHost.emit('executing', null);
return { afterManual, afterDisable, afterEnable, afterRepeatedSetup, afterStop, afterShow, final: centered.length };
}, id)).toEqual({ afterManual: 2, afterDisable: 2, afterEnable: 2, afterRepeatedSetup: 3, afterStop: 3, afterShow: 4, final: 4 });
});
test('Follow Execution retains safe sorted navigation menus and setting filters', async ({ page }) => {
await openHost(page);
for (let i = 0; i < 4; i++) await createNode(page, 'TextInput');
expect(await page.evaluate(() => {
const { app, extensions } = kikoHost;
const extension = extensions.find(item => item.name === 'kikotools.followExecution');
app.ui.settings.setSettingValue('kikotools.follow_execution.enabled', true);
['constructor', '__proto__', 'toString', 'constructor'].forEach((type, index) => {
const node = app.graph._nodes[index];
Object.assign(node, { type, pos: [40 - index * 10, 0], getTitle: () => type });
});
const menu = extension.getCanvasMenuItems().find(item => item?.content === '🫶 Go to node');
const types = menu.submenu.options.map(item => item.content);
const constructors = menu.submenu.options[1].submenu.options;
constructors[0].callback();
const centered = app.centeredNode;
app.ui.settings.setSettingValue('kikotools.follow_execution.show_goto_node', false);
const hiddenGoTo = !extension.getCanvasMenuItems().some(item => item?.content === '🫶 Go to node');
app.ui.settings.setSettingValue('kikotools.follow_execution.enabled', false);
return { types, centered, hiddenGoTo, disabled: extension.getCanvasMenuItems() };
})).toEqual({ types: ['__proto__', 'constructor', 'toString'], centered: 4, hiddenGoTo: true, disabled: [] });
});
+151 -3
View File
@@ -2,15 +2,17 @@ import { test, expect } from '../../tools/testing/node_modules/@playwright/test/
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { resolve, sep } from 'node:path';
import runtime from '../../tools/testing/comfyui-runtime.json' with { type: 'json' };
const fixture = 'http://127.0.0.1:8771';
const web = resolve(fileURLToPath(new URL('../../web/', import.meta.url)));
test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
const info = await (await request.get(`${baseURL}system_stats`)).json();
expect(info.system.comfyui_version).toBe('0.39.0');
expect(info.system.required_frontend_version).toBe('1.55.14');
expect(info.system.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe('1.55.14');
expect(info.system.comfyui_version).toBe(runtime.serverVersion);
if (process.env.KIKO_COMFY_ROOT) expect(info.system.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.system.required_frontend_version).toBe(runtime.frontendVersion);
expect(info.system.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
await testInfo.attach('host-versions.json', { body: JSON.stringify(info.system, null, 2), contentType: 'application/json' });
await expect(await request.post(`${fixture}/__test__/reset`)).toBeOK();
await context.route('**/*', async route => {
@@ -22,6 +24,13 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
return route.fulfill({ response });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) return route.fulfill({ json: {} });
if (path === '/settings') {
const response = await route.fetch();
return route.fulfill({ json: { ...await response.json(),
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
'Comfy.Notification.ShowVersionUpdates': false } });
}
if (path === '/extensions') {
const response = await route.fetch();
const extensions = (await response.json()).filter(path => path.startsWith('/extensions/kikotools/'));
@@ -42,6 +51,7 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
const { app } = await import('/scripts/app.js');
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.KikoLocalImageLoader;
});
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.clear();
@@ -727,3 +737,141 @@ test('embedding Unicode and emoticon completion preserves prompt boundaries in b
}
}
});
test('Custom Colors preserves node colors across renderers and workflow reload', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('EmptyLatentImage');
app.graph.add(node);
node.bgcolor = '#123456';
app.canvas.centerOnNode(node);
});
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
const menu = app.collectNodeMenuItems(node).find(item => item?.content === '🫶 Custom Colors');
menu.submenu.options.find(item => item.content === '🫶 Custom Full').callback();
});
const dialog = page.getByRole('dialog', { name: 'Custom full color', exact: true });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Hex color', { exact: true }).fill('#112233');
await dialog.getByLabel('Hex color', { exact: true }).press('Tab');
if (renderer) {
for (const [color, theme] of [['#fff', 'light'], ['#202020', 'dark']]) {
await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color);
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`custom-colors-${theme}.png`) });
}
}
await page.keyboard.press('Escape');
await expect(page.locator('ca-color-picker')).toHaveCount(0);
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
const node = app.graph._nodes[0];
return { color: node.color, bgcolor: node.bgcolor };
})).toEqual({ color: '#253647', bgcolor: '#112233' });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
app.collectNodeMenuItems(node).find(item => item?.content === '🫶 Custom Colors').submenu.options[0].callback();
app.graph.remove(node);
});
await expect(page.locator('dialog.ca-color-dialog')).toHaveCount(0);
});
test('Follow Execution honors live settings and host menus in both renderers', async ({ page }, testInfo) => {
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('EmptyLatentImage');
app.graph.add(node);
await app.ui.settings.setSettingValue('kikotools.follow_execution.auto_start', false);
});
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes[0].pos[0] += 10000;
await app.ui.settings.setSettingValue('kikotools.follow_execution.enabled', true);
});
const canvas = page.locator('#graph-canvas');
await canvas.click({ button: 'right', position: { x: 800, y: 500 } });
await expect(page.getByText('🫶 Follow execution', { exact: true })).toBeVisible();
await expect(page.getByText('🫶 Go to node', { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(renderer ? 'follow-execution-nodes2.png' : 'follow-execution-classic.png') });
await canvas.click({ position: { x: 100, y: 800 } });
await expect(page.getByText('🫶 Follow execution', { exact: true })).toBeHidden();
const result = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const { api } = await import('/scripts/api.js');
const node = app.graph._nodes[0];
const original = app.canvas.centerOnNode;
const centered = [];
app.canvas.centerOnNode = function (node) { centered.push(node.id); return original.apply(this, arguments); };
const set = (key, value) => app.ui.settings.setSettingValue(`kikotools.follow_execution.${key}`, value);
const menu = () => app.collectCanvasMenuItems(app.canvas);
const toggle = () => menu().find(item => /🫶 (Stop following execution|Follow execution)/.test(item?.content)).callback();
const emit = () => api.dispatchEvent(new CustomEvent('executing', { detail: String(node.id) }));
const progress = nodes => api.dispatchEvent(new CustomEvent('progress_state', { detail: { prompt_id: 'follow-execution-test', nodes } }));
try {
await set('enabled', true);
await set('auto_start', false);
progress({});
await new Promise(requestAnimationFrame);
const extension = app.extensions.find(item => item.name === 'kikotools.followExecution');
await extension.setup();
await extension.setup();
toggle();
emit();
const manual = centered.length;
await set('enabled', false);
emit();
const disabled = centered.length;
await set('enabled', true);
emit();
const reenabled = centered.length;
await set('auto_start', true);
api.dispatchEvent(new CustomEvent('execution_start', { detail: { prompt_id: 'follow-execution-test' } }));
emit();
const autoStart = centered.length;
toggle();
emit();
const stopped = centered.length;
progress({ [node.id]: { state: 'running', value: 0, max: 1, node_id: String(node.id), prompt_id: 'follow-execution-test' } });
await new Promise(requestAnimationFrame);
const showItem = menu().find(item => item?.content === '🫶 Show executing node');
if (!showItem) throw new Error(JSON.stringify({ running: app.runningNodeId, menus: menu().map(item => item?.content) }));
showItem.callback();
const show = centered.length;
menu().find(item => item?.content === '🫶 Go to node').submenu.options
.find(item => item.content === 'EmptyLatentImage').submenu.options[0].callback();
const goTo = centered.length;
await set('show_goto_node', false);
const hideGoTo = !menu().some(item => item?.content === '🫶 Go to node');
await set('show_goto_node', true);
await set('enabled', false);
progress({});
api.dispatchEvent(new CustomEvent('executing', { detail: null }));
return { manual, disabled, reenabled, autoStart, stopped, show, goTo, hideGoTo };
} finally {
app.canvas.centerOnNode = original;
}
});
expect(result).toEqual({ manual: 1, disabled: 1, reenabled: 1, autoStart: 2, stopped: 2, show: 3, goTo: 4, hideGoTo: true });
}
expect(errors).toEqual([]);
});
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
const output = execFileSync(process.execPath, ['tools/testing/node_modules/@playwright/test/cli.js',
'test', '--config', 'tools/testing/comfyui.config.mjs', '--list'], {
cwd: root, encoding: 'utf8', env: { ...process.env,
KIKO_COMFY_ROOT: '', KIKO_COMFY_URL: 'http://127.0.0.1:8199/' },
});
assert.match(output, /\[classic-start\]/);
assert.match(output, /\[nodes2-start\]/);
});
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { launchComfyHost } from '../../tools/testing/comfyui-host.mjs';
async function fixture(t, program) {
const directory = await mkdtemp(join(tmpdir(), 'kiko-host-test-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const checkout = join(directory, 'host');
const repository = join(directory, 'plugin');
await mkdir(checkout);
await mkdir(repository);
await writeFile(join(repository, '__init__.py'), '');
await writeFile(join(checkout, 'main.py'), program);
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
git(['init', '--quiet']);
git(['add', 'main.py']);
git(['-c', 'user.name=Kiko Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
return { checkout, repository, python: process.execPath, port: 8199,
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
}
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
test('managed ComfyUI host uses disposable CPU/offline state and preserves source directories', async t => {
const options = await fixture(t, idle);
const host = await launchComfyHost(options);
t.after(() => host.stop());
await once(host.child.stdout, 'data');
const args = host.child.spawnargs;
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'kikotools');
assert.equal(await realpath(join(host.stateDirectory, 'custom_nodes/kikotools')), await realpath(options.repository));
await access(join(host.stateDirectory, 'user'));
await host.stop();
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
await access(join(options.checkout, 'main.py'));
await access(join(options.repository, '__init__.py'));
});
test('managed ComfyUI host rejects a mismatched or modified upstream checkout', async t => {
const options = await fixture(t, idle);
await assert.rejects(launchComfyHost({ ...options, pins: { revision: '0'.repeat(40) } }), /revision/i);
await writeFile(join(options.checkout, 'main.py'), 'modified');
await assert.rejects(launchComfyHost(options), /modified|dirty/i);
});
test('managed ComfyUI host preserves crash diagnostics, exit status and cleanup', async t => {
const options = await fixture(t, `console.error('startup failed'); process.exit(7);`);
const host = await launchComfyHost(options);
const result = await host.finished;
assert.equal(result.code, 7);
assert.match(await readFile(options.logPath, 'utf8'), /startup failed/);
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
});
test('managed ComfyUI host reports spawn failures and releases its temporary state', async t => {
const options = await fixture(t, idle);
const host = await launchComfyHost({ ...options, python: join(options.checkout, 'missing-python') });
const result = await host.finished;
assert.equal(result.code, 1);
assert.match(result.error.message, /ENOENT/);
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
});
test('managed ComfyUI host fails before spawning when its log cannot be opened', async t => {
const options = await fixture(t, `require('node:fs').writeFileSync(process.argv[1] + '.started', 'yes');`);
await assert.rejects(launchComfyHost({ ...options, logPath: options.checkout }), /EISDIR|directory/i);
await assert.rejects(access(join(options.checkout, 'main.py.started')), { code: 'ENOENT' });
});
+99
View File
@@ -210,3 +210,102 @@ test('gallery HTTP errors cover null bodies, message details, and generic status
[503, {}, 'Request failed (HTTP 503)'],
]) assert.equal(await describe({ status, json: async () => body }), expected);
});
async function followFixture() {
const settings = new Map();
const definitions = new Map();
const centered = [];
const api = new EventTarget();
let extension;
const node = { id: '7', type: 'DisplayAny', pos: [10, 20], getTitle: () => 'Display' };
const app = {
registerExtension: value => { extension = value; },
ui: { settings: {
addSetting(value) { definitions.set(value.id, value); settings.set(value.id, value.defaultValue); },
getSettingValue: key => settings.get(key),
} },
graph: { _nodes: [node], getNodeById: id => app.graph._nodes.find(node => node.id === id) },
canvas: { centerOnNode: node => centered.push(node) }, runningNodeId: '7',
};
load('kiko_follow_execution.js', { app, api });
await extension.init();
function set(key, value) {
const id = `kikotools.follow_execution.${key}`;
settings.set(id, value);
definitions.get(id).onChange?.(value);
}
const emit = (type, detail = null) => {
const event = new Event(type);
event.detail = detail;
api.dispatchEvent(event);
};
return { extension, app, node, settings, centered, emit, set };
}
test('Follow Execution stops immediately when disabled without a menu or execution restart', async () => {
const f = await followFixture();
f.set('enabled', true);
f.set('auto_start', true);
await f.extension.setup();
f.emit('executing', '7');
assert.equal(f.centered.length, 1);
f.set('enabled', false);
f.emit('executing', '7');
assert.equal(f.centered.length, 1);
f.set('enabled', true);
f.emit('executing', '7');
assert.equal(f.centered.length, 1);
f.emit('execution_start');
f.emit('executing', '7');
assert.equal(f.centered.length, 2);
});
test('Follow Execution setup subscribes once per event across repeated setup', async () => {
const f = await followFixture();
f.set('enabled', true);
f.set('auto_start', true);
for (let i = 0; i < 3; i++) await f.extension.setup();
f.emit('executing', '7');
assert.deepEqual(f.centered, [f.node]);
});
test('Follow Execution menus safely group prototype-like types and sort nodes by position', async () => {
const f = await followFixture();
f.set('enabled', true);
await f.extension.setup();
f.app.graph._nodes = ['constructor', '__proto__', 'toString', 'constructor'].map((type, index) => ({
id: String(index), type, pos: [30 - index * 10, 0], getTitle: () => type,
}));
const menu = f.extension.getCanvasMenuItems().find(item => item?.content === '🫶 Go to node');
const options = menu.submenu.options;
assert.deepEqual(Array.from(options, item => item.content), ['__proto__', 'constructor', 'toString']);
const constructors = options[1].submenu.options;
assert.match(constructors[0].content, /#3 \(0, 0\)/);
assert.match(constructors[1].content, /#0 \(30, 0\)/);
constructors[0].callback();
assert.equal(f.centered[0], f.app.graph._nodes[3]);
});
test('Follow Execution ignores a stale follow menu after disabling the feature', async () => {
const f = await followFixture();
f.set('enabled', true);
await f.extension.setup();
const follow = f.extension.getCanvasMenuItems()[1];
f.set('enabled', false);
follow.callback();
assert.equal(f.centered.length, 0);
f.set('enabled', true);
f.emit('executing', '7');
assert.equal(f.centered.length, 0);
});
test('Follow Execution does not resume after disabling and re-enabling between events', async () => {
const f = await followFixture();
f.set('enabled', true);
f.set('auto_start', true);
await f.extension.setup();
f.set('enabled', false);
f.set('enabled', true);
f.emit('executing', '7');
assert.equal(f.centered.length, 0);
});
+23
View File
@@ -24,6 +24,29 @@ from kikotools.tools.gemini_prompt.prompts import (
class TestGeminiPromptNode:
"""Test cases for GeminiPromptNode."""
def test_deprecated_registration_preserves_legacy_workflow_lookup(self) -> None:
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
cls = NODE_CLASS_MAPPINGS["GeminiPrompt"]
assert cls is GeminiPromptNode
assert NODE_DISPLAY_NAME_MAPPINGS["GeminiPrompt"] == "Gemini Prompt Engineer"
assert getattr(cls, "DEPRECATED", False) is True
assert "existing workflows" in cls.DESCRIPTION.lower()
assert "no longer receives" in cls.DESCRIPTION.lower()
def test_legacy_workflow_schema_is_retained(self) -> None:
with patch(
"kikotools.tools.gemini_prompt.node.get_available_models",
return_value=(["legacy-model"], None),
):
inputs = GeminiPromptNode.INPUT_TYPES()
assert set(inputs["required"]) == {"image", "prompt_type", "model"}
assert set(inputs["optional"]) == {"api_key", "custom_prompt", "refresh_models"}
assert inputs["required"]["model"][0] == ["legacy-model"]
assert callable(getattr(GeminiPromptNode(), GeminiPromptNode.FUNCTION))
assert GeminiPromptNode.RETURN_TYPES == ("STRING", "STRING")
assert GeminiPromptNode.RETURN_NAMES == ("prompt", "negative_prompt")
def test_node_properties(self):
"""Test node has correct properties."""
assert GeminiPromptNode.CATEGORY == "🫶 ComfyAssets/🧠 Prompts"
+79
View File
@@ -0,0 +1,79 @@
import { spawn, execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, realpath, rm, symlink, access, open } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath, pathToFileURL } from 'node:url';
import runtime from './comfyui-runtime.json' with { type: 'json' };
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
export async function launchComfyHost({ checkout, repository = repositoryRoot,
python = process.env.KIKO_TEST_PYTHON || 'python', port = 8189, pins = runtime,
logPath = join(repositoryRoot, 'tools/testing/test-results/comfyui-host.log'), forwardOutput = true }) {
if (!checkout) throw new Error('Set KIKO_COMFY_ROOT to the pinned ComfyUI checkout');
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Use an unprivileged ComfyUI port');
checkout = await realpath(checkout);
repository = await realpath(repository);
await access(join(checkout, 'main.py'));
await access(join(repository, '__init__.py'));
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
if (git(['rev-parse', 'HEAD']) !== pins.revision) throw new Error(`ComfyUI revision must be ${pins.revision}`);
try { git(['diff', '--quiet', 'HEAD', '--']); }
catch { throw new Error('ComfyUI checkout has modified tracked files'); }
const stateDirectory = await mkdtemp(join(tmpdir(), 'kiko-comfy-host-'));
let log, child;
try {
for (const directory of ['user', 'input', 'output', 'temp', 'custom_nodes']) await mkdir(join(stateDirectory, directory));
await symlink(repository, join(stateDirectory, 'custom_nodes/kikotools'), 'junction');
await mkdir(dirname(logPath), { recursive: true });
const handle = await open(logPath, 'w');
log = handle.createWriteStream();
child = spawn(python, [join(checkout, 'main.py'), '--cpu', '--offline',
'--listen', '127.0.0.1', '--port', String(port), '--base-directory', stateDirectory,
'--user-directory', join(stateDirectory, 'user'), '--database-url', 'sqlite:///:memory:',
'--disable-all-custom-nodes', '--whitelist-custom-nodes', 'kikotools'], {
cwd: checkout, stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PYTHONUNBUFFERED: '1', HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1' },
});
} catch (error) {
log?.end();
await rm(stateDirectory, { recursive: true, force: true });
throw error;
}
let error, stopping = false;
for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
stream.on('data', chunk => { log.write(chunk); if (forwardOutput) output.write(chunk); });
}
child.on('error', value => { error = value; log.write(`ComfyUI failed to start: ${value.message}\n`); });
const finished = new Promise(resolve => child.once('close', (code, signal) => resolve({ code, signal })))
.then(async result => {
const code = error ? 1 : result.code ?? (stopping ? 0 : 1);
log.write(`ComfyUI stopped: exit=${code}, signal=${result.signal || 'none'}\n`);
await new Promise(resolve => log.end(resolve));
await rm(stateDirectory, { recursive: true, force: true });
return { ...result, code, error };
});
return { child, stateDirectory, finished, async stop() {
stopping = true;
if (child.pid && child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
const deadline = setTimeout(() => child.kill('SIGKILL'), 5000);
deadline.unref();
try { return await finished; }
finally { clearTimeout(deadline); }
} };
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const host = await launchComfyHost({ checkout: process.env.KIKO_COMFY_ROOT,
port: Number(process.env.KIKO_COMFY_PORT || 8189) });
const stop = () => { host.stop().catch(error => { console.error(error.message); process.exitCode = 1; }); };
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
const result = await host.finished;
process.removeListener('SIGINT', stop);
process.removeListener('SIGTERM', stop);
if (result.code) console.error(`ComfyUI host exited with status ${result.code}; see tools/testing/test-results/comfyui-host.log`);
process.exitCode = result.code;
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+9
View File
@@ -0,0 +1,9 @@
{
"repository": "Comfy-Org/ComfyUI",
"revision": "e8665ca8e4089ab917567cdb491308e1397663d4",
"serverVersion": "0.39.0",
"frontendVersion": "1.55.14",
"pythonVersion": "3.12",
"torchVersion": "2.11.0",
"torchvisionVersion": "0.26.0"
}
+24 -10
View File
@@ -1,9 +1,25 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
if (!process.env.KIKO_COMFY_URL) throw new Error('Set KIKO_COMFY_URL to a local ComfyUI instance');
const host = new URL(process.env.KIKO_COMFY_URL);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(host.hostname)) throw new Error('The ComfyUI smoke suite only accepts a loopback host');
const managed = Boolean(process.env.KIKO_COMFY_ROOT);
const hostURL = process.env.KIKO_COMFY_URL || (managed ? 'http://127.0.0.1:8189/' : null);
if (!hostURL) throw new Error('Set KIKO_COMFY_URL for an existing host or KIKO_COMFY_ROOT for a managed host');
const host = new URL(hostURL);
if (!['http:', 'https:'].includes(host.protocol) || !['127.0.0.1', 'localhost', '[::1]'].includes(host.hostname)) throw new Error('The ComfyUI smoke suite only accepts a loopback host');
if (managed && (host.protocol !== 'http:' || host.hostname !== '127.0.0.1' || !host.port)) throw new Error('A managed host needs an HTTP 127.0.0.1 URL with an explicit port');
const servers = [{
command: 'node tools/testing/start-browser-server.mjs --port 8771',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: 'http://127.0.0.1:8771/__test__/', reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
}];
if (managed) servers.unshift({
command: 'node tools/testing/comfyui-host.mjs',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: new URL('system_stats', host).href, timeout: 120000, reuseExistingServer: false,
env: { KIKO_COMFY_PORT: host.port },
gracefulShutdown: { signal: 'SIGTERM', timeout: 10000 },
});
export default defineConfig({
testDir: '../../tests/comfyui',
@@ -13,11 +29,9 @@ export default defineConfig({
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: {
command: 'node tools/testing/start-browser-server.mjs --port 8771',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: 'http://127.0.0.1:8771/__test__/',
reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
webServer: servers,
projects: [
{ name: 'classic-start', metadata: { initialRenderer: false } },
{ name: 'nodes2-start', metadata: { initialRenderer: true } },
],
});
+1
View File
@@ -5,6 +5,7 @@
"engines": { "node": ">=24" },
"scripts": {
"test": "node --test ../../tests/js/*.test.mjs",
"test:comfyui": "playwright test --config comfyui.config.mjs",
"test:browser": "playwright test --config playwright.config.mjs",
"test:coverage": "node frontend-coverage.mjs",
"coverage:update": "node frontend-coverage.mjs --update-baseline"
+40 -41
View File
@@ -1,31 +1,15 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
import { createColorPicker } from "./ui/color-picker.js";
// Custom colors feature with extended options based on PR #433
// Adds custom color pickers for nodes with full, title, and background options
const colorShade = (col, amt) => {
col = col.replace(/^#/, "");
if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
const colorShade = (color, amount) => '#' + color.slice(1).match(/../g)
.map(channel => Math.min(255, parseInt(channel, 16) + amount).toString(16).padStart(2, '0'))
.join('');
let [r, g, b] = col.match(/.{2}/g);
[r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? "0" : "") + r;
const gg = (g.length < 2 ? "0" : "") + g;
const bb = (b.length < 2 ? "0" : "") + b;
return `#${rr}${gg}${bb}`;
};
// One lazily-created hidden color input, shared by every menu entry.
// applyPicked holds what the currently open picker should do on change.
let picker;
let applyPicked;
let activePicker;
let pickerNode;
const isEnabled = () => {
const setting = app.ui.settings.getSettingValue("kikotools.custom_colors.enabled");
@@ -42,9 +26,7 @@ const getSettings = () => ({
// Helper function to apply color to node(s)
// Uses setColorOption like the built-in colors when possible
const applyColorToNodes = (colorValue, colorType, node) => {
if (!colorValue) return;
const graphcanvas = LGraphCanvas.active_canvas;
const graphcanvas = globalThis.LGraphCanvas?.active_canvas || app.canvas;
const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1)
? [node]
: Object.values(graphcanvas.selected_nodes);
@@ -74,7 +56,6 @@ const applyColorToNodes = (colorValue, colorType, node) => {
// Groups have a single color
const applyColorToGroup = (colorValue, group) => {
if (!colorValue) return;
if (group.setColorOption) {
group.setColorOption({ groupcolor: colorValue });
} else {
@@ -83,19 +64,26 @@ const applyColorToGroup = (colorValue, group) => {
app.canvas.setDirty(true, true);
};
// Open the shared hidden color input; apply(color) runs when a color is picked
const openPicker = (initialColor, apply) => {
if (!picker) {
picker = $el("input", {
type: "color",
parent: document.body,
style: { display: "none" },
});
picker.onchange = () => applyPicked?.(picker.value);
}
applyPicked = apply;
picker.value = initialColor || "#000000";
picker.click();
const openPicker = (initialColor, apply, node, label) => {
activePicker?.remove();
const previousFocus = document.activeElement;
const expanded = /^#[0-9a-f]{3}$/i.test(initialColor)
? '#' + [...initialColor.slice(1)].map(value => value + value).join('') : initialColor;
const picker = createColorPicker(expanded, apply, app.ui.settings, label);
picker.hidden = true;
activePicker = picker;
pickerNode = node;
document.body.append(picker);
picker.dialog.addEventListener('close', () => {
picker.remove();
if (activePicker === picker) {
activePicker = null;
pickerNode = null;
if (previousFocus?.isConnected) previousFocus.focus();
else app.canvas.canvas?.focus();
}
}, { once: true });
picker.dialog.showModal();
};
// Wrap menu options in the "Custom Colors" submenu (or nothing if empty)
@@ -148,6 +136,14 @@ app.registerExtension({
});
},
beforeRegisterNodeDef(nodeType) {
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
if (pickerNode === this) activePicker?.remove();
return onRemoved?.apply(this, arguments);
};
},
// New API: return items to append to node right-click menu
getNodeMenuItems(node) {
if (!isEnabled()) return [];
@@ -157,7 +153,10 @@ app.registerExtension({
const addOption = (label, type, initialColor) => options.push({
content: label,
callback: () => openPicker(initialColor, (color) => applyColorToNodes(color, type, node)),
callback: () => openPicker(
initialColor, (color) => applyColorToNodes(color, type, node),
node, `Custom ${type === 'bg' ? 'background' : type} color`,
),
});
if (settings.showFull) addOption("🫶 Custom Full", 'full', node.bgcolor);
@@ -181,7 +180,7 @@ app.registerExtension({
return customColorsMenu([{
content: "🫶 Custom Group Color",
callback: () => openPicker(group.color, (color) => applyColorToGroup(color, group)),
callback: () => openPicker(group.color, (color) => applyColorToGroup(color, group), null, 'Group color'),
}]);
},
});
+13 -6
View File
@@ -23,12 +23,19 @@ const checkAutoStart = () => {
};
const centerNode = (id) => {
checkEnabled();
if (!followExecution || !id || !isEnabled) return;
const node = app.graph.getNodeById(id);
if (!node) return;
app.canvas.centerOnNode(node);
};
const onExecuting = ({ detail }) => centerNode(detail);
const onExecutionStart = () => {
checkEnabled();
checkAutoStart();
};
app.registerExtension({
name: "kikotools.followExecution",
async init() {
@@ -39,6 +46,7 @@ app.registerExtension({
type: "boolean",
defaultValue: false,
tooltip: "Enable follow execution feature in canvas right-click menu",
onChange: checkEnabled,
});
app.ui.settings.addSetting({
@@ -63,13 +71,10 @@ app.registerExtension({
checkAutoStart();
// Listen for execution events
api.addEventListener("executing", ({ detail }) => centerNode(detail));
api.addEventListener("executing", onExecuting);
// Listen for execution start to handle auto-start
api.addEventListener("execution_start", () => {
checkEnabled();
checkAutoStart();
});
api.addEventListener("execution_start", onExecutionStart);
},
// New API: return items to append to canvas right-click menu
@@ -82,6 +87,8 @@ app.registerExtension({
{
content: followExecution ? "🫶 Stop following execution" : "🫶 Follow execution",
callback: () => {
checkEnabled();
if (!isEnabled) return;
followExecution = !followExecution;
if (followExecution) {
centerNode(app.runningNodeId);
@@ -112,7 +119,7 @@ app.registerExtension({
p[n.type] = [n];
}
return p;
}, {});
}, Object.create(null));
items.push({
content: "🫶 Go to node",
+10
View File
@@ -190,3 +190,13 @@ boundaries. Literal bracketed names are tried before interpreting leading
parentheses/brackets as prompt weighting; insertion preserves surrounding
weight syntax. URL downloads enforce their byte limit while reading the body,
cancel oversized/aborted streams, and decode UTF-8 across chunk boundaries.
### Custom Colors
`../kiko_custom_colors.js` opens the same shared color picker as Workflow Timer
from the host's node and group context menus. Hex entry, HSV sliders and presets
apply immediately. Full/Title/Background options, selected-node behavior, title
auto-shading and existing setting IDs are retained. Closing or replacing the
picker removes its dialog and theme observers; removing its target node also
closes it without replacing other node removal behavior. Keyboard focus returns
to the available opener, with the canvas as a fallback.
+1
View File
@@ -143,6 +143,7 @@
@keyframes ca-timer-glow { 0%, 100% { text-shadow: 0 0 8px currentColor; } 50% { text-shadow: 0 0 16px currentColor; } }
@media (prefers-reduced-motion: reduce) { .ca-workflow-timer .kiko-timer-display { animation: none; text-shadow: none; } }
.ca-color-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.ca-color-picker[hidden] { display: none; }
.ca-color-value { font-size: 12px; color: var(--ca-muted); font-family: ui-monospace, monospace; }
.ca-color-swatch { inline-size: 24px; block-size: 20px; border: 1px solid var(--ca-border); border-radius: 3px; background: var(--ca-picked-color); }
.ca-color-dialog { box-sizing: border-box; inline-size: 360px; max-inline-size: calc(100dvw - 32px); max-block-size: calc(100dvh - 32px); overflow: auto; padding: 16px; border: 1px solid var(--ca-border); border-radius: 8px; background: var(--ca-surface); color: var(--ca-text); box-shadow: 0 8px 28px #00000040; }