Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2db82a80 | ||
|
|
1820fb19ea | ||
|
|
7edf08f1e2 | ||
|
|
1721778cb5 | ||
|
|
6bac864c4d | ||
|
|
8dc81364a0 | ||
|
|
957ca3c142 | ||
|
|
ad73422472 | ||
|
|
1a7e0dfcd7 | ||
|
|
0b85600d38 | ||
|
|
ca65cc7961 | ||
|
|
6adba639bb | ||
|
|
1e86b73168 | ||
|
|
0fa595a9b7 |
@@ -23,6 +23,7 @@ CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
|
||||
tools/
|
||||
.ci-comfyui/
|
||||
coverage/
|
||||
TESTING.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -175,3 +175,6 @@ node_modules/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Disposable pinned host checkout used by CI
|
||||
.ci-comfyui/
|
||||
|
||||
@@ -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.
|
||||
@@ -1,7 +1,7 @@
|
||||
# ComfyUI-KikoTools
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -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
|
||||

|
||||
|
||||
#### 🤖 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
|
||||
|
||||

|
||||
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
|
||||
|
||||

|
||||
|
||||
#### ⏱️ 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
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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\]/);
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 } },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
}]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user