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
Vito Sansevero f8ce4cb982 fix(autocomplete): address review findings and WebKit readiness 2026-10-16 15:31:46 -07:00
Vito Sansevero 42b6009f1b feat(autocomplete): restore tag imports and advanced settings 2026-10-16 15:11:56 -07:00
Vito Sansevero 963ac0a4af docs(autocomplete): document versioned Danbooru tag pack and terms 2026-10-16 14:45:29 -07:00
Vito Sansevero b4bd8b98b1 feat(ui): unify Embedding Autocomplete controls and suggestions 2026-10-16 13:49:11 -07:00
Vito ed6675721d Merge pull request #95 from ComfyAssets/feat/shared-display-views
feat(ui): unify Display Text and Display Any output views
2026-10-16 13:25:23 -07:00
Vito Sansevero b0c0d53676 feat(ui): unify Display Text and Display Any output views 2026-10-16 13:20:28 -07:00
Vito b9103ad9c4 Merge pull request #93 from ComfyAssets/feat/shared-node-dimensions-dialog
feat(ui): migrate Node Dimensions to shared dialog controls
2026-10-16 13:09:13 -07:00
59 changed files with 3290 additions and 1085 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.
+76 -41
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`.
@@ -477,8 +502,8 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
**Key Features:**
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
- **Custom Word Lists**: Load tag databases (like Danbooru tags) from any URL
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks
- **Custom Word Lists**: Import CSV/TXT files or browser-accessible URLs, including [versioned Danbooru tag packs](docs/tag-packs/README.md), with local offline caching
- **Safe Rendering**: Tag data stays plain text, with validated CSV fields and file-size limits
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
@@ -492,9 +517,9 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
- Load custom word lists from URLs with security validation
**Security Features:**
- Validates all loaded content to prevent script injection
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.)
- Safe character whitelist for tags
- Validates CSV structure, tag names, categories, counts, and file size
- Renders tag data as plain text rather than executable HTML or JavaScript
- Rejects control characters and HTML error documents
- File size limits to prevent memory exhaustion
- Clear error messages for rejected content
@@ -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: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+48
View File
@@ -0,0 +1,48 @@
# Autocomplete tag packs
ComfyAssets prepares filtered autocomplete data from Danbooru's public
`tags.json` and `tag_aliases.json` APIs. Dataset releases are versioned separately
from KikoTools and stored as release assets, keeping bulk data out of Git history.
## Danbooru — Illustrious / NoobAI, 2026-10-16
[Download the CSV and manifest](https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16).
This snapshot contains 165,158 canonical tags, their categories, post counts, and
aliases. The adjacent manifest records the retrieval date, model targets,
selection filters, schema, byte size, and SHA-256 checksum.
The UTF-8 CSV has no header. Columns are `tag,category,post_count,aliases`.
Aliases share a comma-separated field; use a CSV parser that respects quoting.
An empty alias field means no aliases. Category codes are 0 (general), 1
(artist), 3 (copyright), 4 (character), and 5 (meta).
In ComfyUI Settings, search **Embedding Autocomplete**, open its settings,
then download the pack and choose the CSV under **Tag list file**. Enable
**Show Custom Words** and select **Save Settings**. Importing stages the list;
Cancel leaves the active list unchanged. The browser caches the saved file in
IndexedDB for offline use and restores it after a refresh. Clearing browser site
data removes this cache. There is no server-side autocomplete folder to populate.
Alternatively, enter a CSV/TXT URL and select **Load URL**, then save. Remote
servers must allow browser cross-origin requests; for GitHub release assets,
download and import the file locally. Existing one-column word lists and
`tag,frequency` lists remain supported. The default URL is unchanged.
Parsing runs in a Web Worker with a 32 MB UTF-8 limit. Search checks canonical
names and aliases, inserts the canonical tag, and ranks exact matches ahead of
prefixes and substrings, then by descending post count. The number of displayed
results is bounded by Maximum suggestions. Categories are retained in records;
category-specific controls are not currently exposed.
## Source and terms
Source data: [Danbooru](https://danbooru.donmai.us/), collected 2026-10-16.
Selection and packaging: ComfyAssets. This is an independent filtered snapshot,
not an official Danbooru release or a guarantee of model compatibility.
Refer to [Danbooru's Terms of Service](https://danbooru.donmai.us/terms) and the
[official terms template](https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb).
The template's Copyright Infringement section identifies tags as factual
information that is generally not copyrightable. Its API Usage section requires
compliance with API rate limits. This reference does not claim an explicit
dataset license or extend KikoTools' software license to upstream data.
@@ -0,0 +1,35 @@
{
"source": "danbooru.donmai.us tags.json + tag_aliases.json",
"pulled": "2026-10-16",
"target_models": "Illustrious 1.0-3.x, NoobAI-XL",
"filters": {
"created_before": "2025-01-01",
"min_post_count": {
"general": 50,
"artist": 30,
"copyright": 20,
"character": 20
},
"artists_kept": true,
"deprecated_removed": true,
"meta_tags": "removed except small allowlist"
},
"tag_count": 165158,
"schema_version": 1,
"file": "danbooru-illustrious-noobai-2026-10-16.csv",
"encoding": "UTF-8",
"header_row": false,
"columns": [
"tag",
"category",
"post_count",
"aliases"
],
"aliases_format": "Comma-separated inside a CSV-quoted field when necessary",
"size_bytes": 3979593,
"sha256": "733531a60f2e2d38925f53381b18cff21513ef14550d1f2ca670a0f55b86b798",
"prepared_by": "ComfyAssets",
"terms_url": "https://danbooru.donmai.us/terms",
"terms_source_url": "https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb",
"release_tag": "tags-2026-10-16"
}
+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 = []
+847 -8
View File
@@ -14,7 +14,7 @@ test.beforeEach(async ({ page, request, context, browserName, baseURL }) => {
page.errors.push(`Unexpected external request: ${url.origin}`);
return route.abort();
});
await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })));
await page.addInitScript(() => { if (!localStorage.getItem('kikotools.embedding_autocomplete.settings')) localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
@@ -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');
@@ -280,14 +289,14 @@ test('real display node output renders as text and replaces previous widgets', a
await expect(response).toBeOK();
const data = await response.json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: data.ui });
await expect(page.getByLabel('text_positive')).toHaveValue('<img src=x onerror="window.injected=true">');
await expect(page.getByLabel('text_negative')).toHaveValue('blur');
await expect(page.getByLabel('Positive prompt', { exact: true })).toHaveValue('<img src=x onerror="window.injected=true">');
await expect(page.getByLabel('Negative prompt', { exact: true })).toHaveValue('blur');
expect(await page.evaluate(() => window.injected)).toBeUndefined();
const next = await (await request.post('/__test__/execute/DisplayText', { data: { text: 'Replacement' } })).json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: next.ui });
await expect(page.locator('textarea')).toHaveCount(1);
await expect(page.getByLabel('text_display')).toHaveValue('Replacement');
await expect(page.getByLabel('text_display')).toHaveAttribute('readonly', '');
await expect(page.getByLabel('Text output', { exact: true })).toHaveValue('Replacement');
await expect(page.getByLabel('Text output', { exact: true })).toHaveAttribute('readonly', '');
});
test('seed history persists across reloads and removal clears its watcher', async ({ page }) => {
@@ -810,15 +819,15 @@ test('Display Any renders real multiline output and restores serialized values w
await expect(response).toBeOK();
const result = await response.json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: result.ui });
await expect(page.locator('textarea')).toHaveCount(3);
await expect(page.locator('textarea')).toHaveCount(1);
expect(await page.locator('textarea').evaluateAll(elements => elements.map(element => element.value).join('\n'))).toBe(result.ui.text[0]);
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.configure({ widgets_values: ['Restored value'] });
node.onConfigure();
node.onConfigure?.();
}, id);
await expect(page.locator('textarea')).toHaveCount(1);
await expect(page.getByLabel('display_0')).toHaveValue('Restored value');
await expect(page.getByLabel('Value output', { exact: true })).toHaveValue('Restored value');
});
test('gallery pagination sorting and hidden-folder controls use real directory pages', async ({ page, request }) => {
@@ -987,3 +996,833 @@ test('legacy selects retain Enter confirmation', async ({ page }) => {
expect(await page.evaluate(() => window.selectedLegacy)).toEqual(['chosen']);
expect(await page.evaluate(() => window.leakedDialogKeys)).toBe(0);
});
for (const name of ['DisplayText', 'DisplayAny']) {
test(`${name}: shared output follows themes, resizes, copies and preserves empty output`, async ({ page }) => {
await openHost(page);
const id = await createNode(page, name, name === 'DisplayAny' ? { mode: 'tensor shape' } : {});
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.onExecuted({ text: ['first\nsecond <b>literal</b>'] });
window.copiedOutput = [];
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => window.copiedOutput.push(text) } });
}, id);
const panel = page.locator('.ca-text-output');
await expect(panel).toBeVisible();
for (const [color, theme] of [['#fafafa', 'light'], ['#202020', 'dark']]) {
await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
}
const field = panel.getByRole('textbox');
await expect(field).toHaveAttribute('readonly', '');
await expect(field).toHaveValue('first\nsecond <b>literal</b>');
await field.focus();
await page.keyboard.press('ControlOrMeta+A');
expect(await field.evaluate(element => element.value.substring(element.selectionStart, element.selectionEnd))).toBe('first\nsecond <b>literal</b>');
await panel.getByRole('button', { name: /Copy/ }).focus();
await page.keyboard.press('Enter');
await expect(panel.getByRole('status')).toHaveText('Copied.');
expect(await page.evaluate(() => window.copiedOutput)).toEqual(['first\nsecond <b>literal</b>']);
for (const width of [240, 450, 800]) {
await panel.evaluate((element, width) => { element.style.width = `${width}px`; element.style.height = '400px'; }, width);
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
}
await page.evaluate(id => kikoHost.nodes.get(String(id)).onExecuted({ text: [''] }), id);
await expect(panel.getByRole('textbox')).toHaveValue('');
await expect(panel.getByRole('status')).toBeEmpty();
const snapshot = await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
const data = {};
node.onSerialize(data);
node.onExecuted({ text: ['replacement'] });
node.configure(data);
return data;
}, id);
expect(snapshot.kikoDisplay).toEqual({ version: 1, text: '' });
await expect(panel.getByRole('textbox')).toHaveValue('');
await page.evaluate(id => kikoHost.removeNode(id), id);
await expect(panel).toHaveCount(0);
});
}
test('display prompt copy feedback handles failure, replacement and removal safely', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'DisplayText');
await page.evaluate(id => {
kikoHost.nodes.get(String(id)).onExecuted({ text: ['Positive prompt: sun\nNegative prompt: rain'] });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: () => Promise.reject(new Error('denied')) } });
}, id);
const panel = page.locator('.ca-text-output');
await panel.getByRole('button', { name: 'Copy negative', exact: true }).click();
await expect(panel.getByRole('status')).toContainText('Could not copy');
await expect(panel.getByRole('textbox', { name: 'Negative prompt', exact: true })).toHaveValue('rain');
await page.evaluate(() => { navigator.clipboard.writeText = () => new Promise(resolve => window.finishCopy = resolve); });
await panel.getByRole('button', { name: 'Copy positive', exact: true }).click();
await page.evaluate(id => {
kikoHost.nodes.get(String(id)).onExecuted({ text: ['new output'] });
window.finishCopy();
}, id);
await expect(panel.getByRole('status')).toBeEmpty();
await panel.getByRole('button', { name: 'Copy text', exact: true }).click();
await page.evaluate(id => { kikoHost.removeNode(id); window.finishCopy(); }, id);
await expect(panel).toHaveCount(0);
});
test('display adapters preserve lifecycle context, arguments, results and saved snapshots', async ({ page }) => {
await openHost(page);
const result = await page.evaluate(async () => {
const calls = [];
let node;
class HookNode {
constructor() { this.widgets = []; this.size = [600, 500]; this.inputs = []; }
addDOMWidget(name, type, element) { document.body.append(element); const widget = { name, element }; this.widgets.push(widget); return widget; }
setSize(size) { this.size = size; }
setDirtyCanvas() {}
onNodeCreated(...args) { calls.push(['created', this === node, args]); return 'created-result'; }
onExecuted(...args) { calls.push(['executed', this === node, args]); return 'executed-result'; }
configure(...args) { calls.push(['configure', this === node, args]); return 'configure-result'; }
onSerialize(...args) { calls.push(['serialize', this === node, args.length]); args[0].external = 'retained'; return 'serialize-result'; }
onRemoved(...args) { calls.push(['removed', this === node, args]); return 'removed-result'; }
}
await kikoHost.extensions.find(extension => extension.name === 'ComfyAssets.DisplayAny').beforeRegisterNodeDef(HookNode, { name: 'DisplayAny' }, kikoHost.app);
node = new HookNode();
const created = node.onNodeCreated('creation');
const executed = node.onExecuted({ text: ['first'] }, 'execution');
const data = {};
const serialized = node.onSerialize(data, 'serialization');
const configured = node.configure({ kikoDisplay: { version: 1, text: 'restored' } }, 'configuration');
const removed = node.onRemoved('removal');
node.onExecuted({ text: ['too late'] });
return { created, executed, serialized, configured, removed, data, text: node.displayOutput.text, calls, disposed: node.displayOutput.scope.signal.aborted, size: node.size };
});
expect(result).toMatchObject({ created: 'created-result', executed: 'executed-result', serialized: 'serialize-result', configured: 'configure-result', removed: 'removed-result', data: { external: 'retained', kikoDisplay: { version: 1, text: 'first' } }, text: 'restored', disposed: true, size: [600, 500] });
expect(result.calls.slice(0, 5)).toEqual([
['created', true, ['creation']],
['executed', true, [{ text: ['first'] }, 'execution']],
['serialize', true, 2],
['configure', true, [{ kikoDisplay: { version: 1, text: 'restored' } }, 'configuration']],
['removed', true, ['removal']],
]);
});
test('display copy feedback expires and malformed execution messages retain the latest output', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'DisplayText');
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.onExecuted({ text: ['latest'] });
for (const message of [null, {}, { text: [] }, { text: [12] }]) node.onExecuted(message);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
}, id);
const panel = page.locator('.ca-text-output');
await expect(panel.getByRole('textbox')).toHaveValue('latest');
await panel.getByRole('button', { name: 'Copy text', exact: true }).click();
await expect(panel.getByRole('status')).toHaveText('Copied.');
await expect(panel.getByRole('status')).toBeEmpty({ timeout: 4000 });
});
async function mountAutocompleteSettings(page) {
await page.evaluate(() => {
const table = document.createElement('table');
table.id = 'autocomplete-test-settings';
const body = document.createElement('tbody');
body.append(kikoHost.settingDefinitions.get('kikotools.embeddingAutocomplete').type());
table.append(body);
document.body.append(table);
});
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
return page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true });
}
test('autocomplete shared settings save preferences, cancel edits and follow themes', async ({ page }) => {
await openHost(page);
await page.evaluate(() => Object.assign(window.kikotools.autocomplete.settings, { quickTrigger: 'keep', maxSuggestions: 7, insertOnTab: false }));
const dialog = await mountAutocompleteSettings(page);
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true })).toBeChecked();
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Open Settings', exact: true })).toBeFocused();
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.showLoras)).toBe(true);
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
await page.evaluate(() => document.querySelector('#autocomplete-test-settings button').click());
await expect(dialog).toHaveCount(1);
for (const [color, theme] of [['#fafafa', '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.setViewportSize({ width: 320, height: 480 });
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')))).toMatchObject({ showLoras: false, quickTrigger: 'keep', maxSuggestions: 7, insertOnTab: false });
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
await expect(dialog.getByRole('switch', { name: 'Show LoRAs', exact: true })).not.toBeChecked();
await page.evaluate(() => document.getElementById('autocomplete-test-settings').remove());
await expect(dialog).toHaveCount(0);
});
test('autocomplete settings report storage failure and keep the previous preferences', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
await dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true }).uncheck();
await page.evaluate(() => { window.restoreAutocompleteStorage = Storage.prototype.setItem; Storage.prototype.setItem = () => { throw new Error('blocked'); }; });
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Could not save');
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.enabled)).toBe(true);
await page.evaluate(() => Storage.prototype.setItem = window.restoreAutocompleteStorage);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
});
test('autocomplete suggestions retain focus, expose selection, and survive unrelated removal', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
const other = await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.getByLabel('text', { exact: true }).first();
await prompt.fill('em');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list.getByRole('option')).toHaveCount(2);
await prompt.press('ArrowDown');
const selected = list.getByRole('option', { selected: true });
await expect(selected).toHaveCount(1);
await expect(prompt).toHaveAttribute('aria-activedescendant', await selected.getAttribute('id'));
await expect(prompt).toBeFocused();
await page.evaluate(other => kikoHost.removeNode(other), other);
await expect(list).toBeVisible();
await prompt.press('Tab');
await expect(prompt).toHaveValue(/embedding:/);
await expect(list).toBeHidden();
await expect(prompt).not.toHaveAttribute('aria-activedescendant');
await prompt.fill('<lora:in');
await expect(list).toBeVisible();
await list.getByRole('option').click();
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
await expect(prompt).toBeFocused();
});
test('autocomplete respects composition, read-only fields, custom words and keyboard preferences', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
const prompt = page.getByLabel('text', { exact: true });
await page.evaluate(() => {
Object.assign(window.kikotools.autocomplete.settings, { showEmbeddings: false, showLoras: false, showCustomWords: true, insertOnEnter: false });
window.kikotools.autocomplete.customWords = [{ name: 'blue_sky', value: 'blue_sky', display: 'blue_sky', type: 'custom' }];
});
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
await prompt.fill('blue');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list).toBeVisible();
await prompt.press('ArrowDown');
await prompt.dispatchEvent('keydown', { key: 'Enter', isComposing: true });
await expect(prompt).toHaveValue('blue');
await prompt.press('Enter');
await expect(prompt).not.toHaveValue('blue sky, ');
await prompt.fill('blue');
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue('blue sky, ');
await prompt.fill('blue');
await prompt.press('Escape');
await expect(list).toBeHidden();
await prompt.evaluate(element => { element.readOnly = true; element.dispatchEvent(new Event('input', { bubbles: true })); });
await expect(list).toBeHidden();
});
test('autocomplete restores existing attributes and lifecycle hooks on detach', async ({ page }) => {
await openHost(page);
const result = await page.evaluate(() => {
const autocomplete = window.kikotools.autocomplete;
const input = document.createElement('textarea');
input.setAttribute('aria-controls', 'original-controls');
input.setAttribute('aria-autocomplete', 'none');
input.readOnly = true;
const calls = [];
const widget = { inputEl: input, onRemoved(...args) { calls.push([this === widget, args]); return 'widget-result'; } };
const node = { onRemoved(...args) { calls.push([this === node, args]); return 'node-result'; } };
const original = widget.onRemoved;
autocomplete.attachToWidget(widget, node);
const ignored = !autocomplete.activeWidgets.has(widget);
input.readOnly = false;
autocomplete.attachToWidget(widget, node);
autocomplete.attachToWidget(widget, node);
const value = widget.onRemoved('widget-arg', 7);
const restored = widget.onRemoved === original;
autocomplete.attachToWidget(widget, node);
const removed = node.onRemoved('node-arg');
autocomplete.detachFromWidget(widget);
autocomplete.detachFromWidget(widget);
return { ignored, value, removed, restored, calls, controls: input.getAttribute('aria-controls'), autocomplete: input.getAttribute('aria-autocomplete'), popup: input.hasAttribute('aria-haspopup'), active: autocomplete.activeWidgets.has(widget) };
});
expect(result).toEqual({ ignored: true, value: 'widget-result', removed: 'node-result', restored: true, calls: [[true, ['widget-arg', 7]], [true, ['node-arg']]], controls: 'original-controls', autocomplete: 'none', popup: false, active: false });
});
test('autocomplete popup fits narrow viewports and cleanup cancels pending refresh', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
await page.setViewportSize({ width: 320, height: 480 });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const prompt = page.getByLabel('text', { exact: true });
await prompt.fill('em');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
const box = await list.boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(320);
expect(box.y + box.height).toBeLessThanOrEqual(480);
await prompt.dispatchEvent('blur');
await page.waitForTimeout(40);
await expect(list).toBeVisible();
await page.evaluate(() => {
const autocomplete = window.kikotools.autocomplete;
window.lateAutocompleteFetches = 0;
autocomplete.fetchResources = () => window.lateAutocompleteFetches++;
autocomplete.fetchResourcesDebounced();
autocomplete.cleanup();
});
await expect(list).toHaveCount(0);
await page.waitForTimeout(550);
expect(await page.evaluate(() => window.lateAutocompleteFetches)).toBe(0);
await expect(prompt).not.toHaveAttribute('aria-haspopup');
});
test('autocomplete binds replacement renderer fields without changing native widget ownership', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
await page.evaluate(id => {
const wrapper = document.createElement('div');
wrapper.setAttribute('node-id', String(id));
const label = document.createElement('label');
label.htmlFor = 'replacement-prompt';
label.textContent = 'text';
const textarea = document.createElement('textarea');
textarea.id = 'replacement-prompt';
wrapper.append(label, textarea);
document.body.append(wrapper);
}, id);
const prompt = page.locator('#replacement-prompt');
await prompt.fill('embedding:ca');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list).toBeVisible();
await prompt.press('ArrowDown');
await prompt.press('Enter');
await expect(prompt).toHaveValue('embedding:portraits/cat, ');
await prompt.fill('embedding:ca');
await expect(list).toBeVisible();
await page.evaluate(() => {
window.removedRendererBridge = window.kikotools.autocomplete.currentWidget;
document.getElementById('replacement-prompt').parentElement.remove();
});
await expect(prompt).toHaveCount(0);
await expect(list).toBeHidden();
expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.removedRendererBridge))).toBe(false);
const native = page.getByLabel('text', { exact: true });
await native.fill('embedding:ca');
await expect(list).toBeVisible();
await native.press('ArrowDown');
await native.press('Tab');
await expect(native).toHaveValue('embedding:portraits/cat, ');
});
test('autocomplete starts a new query without carrying over the previous selection', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.getByLabel('text', { exact: true });
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await prompt.fill('embedding:ca');
await prompt.press('ArrowDown');
await prompt.press('ArrowDown');
await expect(list.getByRole('option', { selected: true })).toContainText('forest');
await prompt.fill('embedding:ca');
await prompt.press('ArrowDown');
await expect(list.getByRole('option', { selected: true })).toContainText('cat');
});
test('autocomplete keyboard selection ignores a stationary pointer over a new result list', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.getByLabel('text', { exact: true });
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await prompt.fill('embedding:ca');
await list.getByRole('option').first().hover();
await prompt.fill('embedding:ca');
await page.waitForTimeout(50);
await expect(list.getByRole('option', { selected: true })).toHaveCount(0);
await prompt.press('ArrowDown');
await prompt.press('Enter');
await expect(prompt).toHaveValue('embedding:portraits/cat, ');
});
test('autocomplete delayed attachment cannot revive a removed node and teardown restores host hooks', async ({ page }) => {
await openHost(page);
await page.evaluate(async () => {
const id = await kikoHost.createNode('TextInput', { text: '' });
window.quicklyRemovedPrompt = kikoHost.nodes.get(String(id)).widgets.find(widget => widget.inputEl);
kikoHost.removeNode(id);
});
await page.waitForTimeout(70);
expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.quicklyRemovedPrompt))).toBe(false);
expect(await page.evaluate(() => {
const wrapped = kikoHost.app.graph.add;
const stringWidget = kikoHost.widgets.STRING;
window.kikotools.autocomplete.cleanup();
return { graphRestored: kikoHost.app.graph.add !== wrapped, widgetRestored: kikoHost.widgets.STRING !== stringWidget };
})).toEqual({ graphRestored: true, widgetRestored: true });
});
test('autocomplete tag import preserves aliases and ranking across reload and failed imports', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check();
await dialog.getByRole('switch', { name: 'Show Embeddings', exact: true }).uncheck();
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'tags.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_sky,0,100,azure\nblue_eyes,0,900,"blue_eye,bright_eyes"') });
await expect(dialog.getByRole('status')).toContainText('2 tags ready');
await dialog.getByText('Triggers, matching and insertion', { exact: true }).click();
await dialog.getByRole('switch', { name: 'Replace underscores with spaces', exact: true }).uncheck();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
await createNode(page, 'TextInput', { text: '' });
const prompt = page.getByLabel('text', { exact: true });
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
await prompt.focus();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve)));
await prompt.fill('blue');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list.getByRole('option').first()).toContainText('blue_eyes');
await prompt.fill('azure');
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue('blue_sky, ');
await page.reload();
await expect.poll(() => page.evaluate(() => window.kikotools?.autocomplete.customWords.length)).toBe(2);
const reopened = await mountAutocompleteSettings(page);
await expect(reopened.getByText('2 tags loaded', { exact: true })).toBeVisible();
await expect(reopened.getByText('Current source: tags.csv', { exact: true })).toBeVisible();
await reopened.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'bad.csv', mimeType: 'text/csv', buffer: Buffer.from('bad,0,not-a-count,') });
await expect(reopened.getByRole('status')).toContainText('Could not load');
await reopened.getByRole('button', { name: 'Cancel', exact: true }).click();
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(2);
});
test('autocomplete restores advanced preferences and stages URL tags until save', async ({ page }) => {
await openHost(page);
await page.route('**/__test__/tags.csv', route => route.fulfill({ body: 'tag,category,post_count,aliases\nblue_eyes,0,120,azure' }));
const dialog = await mountAutocompleteSettings(page);
await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/tags.csv', page.url()).href);
await dialog.getByRole('button', { name: 'Load URL', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('1 tags ready');
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0);
await dialog.getByText('Triggers, matching and insertion', { exact: true }).click();
await dialog.getByLabel('Embedding trigger', { exact: true }).fill('emb:');
await dialog.getByLabel('LoRA trigger', { exact: true }).fill('l:');
await dialog.getByLabel('Quick embedding trigger', { exact: true }).fill('zz');
await dialog.getByLabel('Maximum suggestions', { exact: true }).fill('8');
await dialog.getByLabel('Minimum characters', { exact: true }).fill('3');
await dialog.getByRole('switch', { name: 'Group by directory', exact: true }).uncheck();
await dialog.getByRole('switch', { name: 'Insert on Enter', exact: true }).uncheck();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(() => ({ ...window.kikotools.autocomplete.settings }))).toMatchObject({ embeddingTrigger: 'emb:', loraTrigger: 'l:', quickTrigger: 'zz', maxSuggestions: 8, minChars: 3, sortByDirectory: false, insertOnEnter: false });
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords[0].frequency)).toBe(120);
});
test('autocomplete cancelling a staged import leaves the cached source unchanged', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'staged.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_eyes,0,10,azure') });
await expect(dialog.getByRole('status')).toContainText('1 tags ready');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords)).toEqual([]);
expect(await page.evaluate(async () => (await import('/extensions/kikotools/autocomplete/tag-storage.js')).tagCache())).toBeUndefined();
});
test('autocomplete reports tag download and cache errors without losing the current settings', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
await dialog.getByRole('button', { name: 'Load URL', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Enter a valid');
await page.route('**/__test__/bad-tags.csv', route => route.fulfill({ status: 404, body: 'Missing' }));
await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/bad-tags.csv', page.url()).href);
await dialog.getByRole('button', { name: 'Load URL', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('404');
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'valid.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_eyes,0,10,azure') });
await expect(dialog.getByRole('status')).toContainText('1 tags ready');
await dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true }).uncheck();
await page.evaluate(() => {
window.originalTagDBOpen = indexedDB.open.bind(indexedDB);
indexedDB.open = () => { throw new DOMException('Storage denied', 'SecurityError'); };
});
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Could not save tag list');
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.enabled)).toBe(true);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.tagSource)).toBeUndefined();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).tagSource)).toBeUndefined();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).enabled)).toBe(true);
await page.evaluate(() => indexedDB.open = window.originalTagDBOpen);
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(1);
});
test('autocomplete cancels pending tag downloads when its settings close', async ({ page }) => {
await openHost(page);
let held;
await page.route('**/__test__/slow-tags.csv', route => { held = route; });
const dialog = await mountAutocompleteSettings(page);
await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/slow-tags.csv', page.url()).href);
await dialog.getByRole('button', { name: 'Load URL', exact: true }).click();
await expect.poll(() => Boolean(held)).toBe(true);
await expect(dialog.getByRole('button', { name: 'Save Settings', exact: true })).toBeDisabled();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await held.fulfill({ body: 'blue_eyes,0,10,azure' });
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.pendingFetches.size)).toBe(0);
});
test('autocomplete imports 300000 tags off the UI thread and bounds broad matches', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
const text = Array.from({ length: 300000 }, (_, i) => `tag_${i},0,${i},alias_${i}`).join('\n');
await page.evaluate(() => { window.tagImportTicks = 0; window.tagImportTimer = setInterval(() => window.tagImportTicks++, 10); });
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'large.csv', mimeType: 'text/csv', buffer: Buffer.from(text) });
await expect(dialog.getByRole('status')).toContainText('300,000 tags ready', { timeout: 15000 });
expect(await page.evaluate(() => { clearInterval(window.tagImportTimer); return window.tagImportTicks; })).toBeGreaterThan(1);
await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
await createNode(page, 'TextInput', { text: '' });
const prompt = page.getByLabel('text', { exact: true });
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
await prompt.fill('alias_299999');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list.getByRole('option')).toHaveCount(1);
await expect(list.getByRole('option')).toContainText('tag_299999');
await prompt.fill('tag_');
await expect(list.getByRole('option')).toHaveCount(20);
});
test('autocomplete legacy URL loads retain the newest valid list when older requests finish late', async ({ page }) => {
await openHost(page);
let held;
await page.route('**/__test__/old-tags.txt', route => { held = route; });
await page.route('**/__test__/new-tags.txt', route => route.fulfill({ body: 'blue_sky,10\nblue_eyes,200' }));
await page.evaluate(() => { window.oldTagLoad = window.kikotools.autocomplete.loadCustomWords('/__test__/old-tags.txt'); });
await expect.poll(() => Boolean(held)).toBe(true);
expect(await page.evaluate(() => window.kikotools.autocomplete.loadCustomWords('/__test__/new-tags.txt'))).toBe(2);
await held.fulfill({ body: 'obsolete,1000' });
expect(await page.evaluate(() => window.oldTagLoad)).toBe(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.map(tag => tag.name))).toEqual(['blue_eyes', 'blue_sky']);
expect(await page.evaluate(() => window.kikotools.autocomplete.loadCustomWords('file:///tmp/tags.csv'))).toBe(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(2);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWordsError)).toContain('HTTP or HTTPS');
});
test('autocomplete shares one removable node hook across widgets in any cleanup order', async ({ page }) => {
await openHost(page);
const result = await page.evaluate(() => {
const autocomplete = window.kikotools.autocomplete;
const restored = [];
const calls = [];
const make = () => {
const original = function (...args) { calls.push([this === node, args]); return 'original-result'; };
const node = { onRemoved: original };
const widgets = [0, 1, 2].map(() => ({ inputEl: document.createElement('textarea') }));
widgets.forEach(widget => autocomplete.attachToWidget(widget, node));
return { node, original, widgets };
};
for (const order of [[0, 1, 2], [2, 0, 1]]) {
const { node, original, widgets } = make();
const hook = node.onRemoved;
autocomplete.detachFromWidget(widgets[order[0]]);
restored.push(node.onRemoved === hook);
autocomplete.detachFromWidget(widgets[order[1]]);
autocomplete.detachFromWidget(widgets[order[2]]);
restored.push(node.onRemoved === original);
}
const active = make();
const removal = active.node.onRemoved('removed', 7);
restored.push(active.node.onRemoved === active.original, active.widgets.every(widget => !autocomplete.activeWidgets.has(widget)));
const final = make();
autocomplete.cleanup();
restored.push(final.node.onRemoved === final.original);
return { restored, removal, calls };
});
expect(result).toEqual({ restored: Array(7).fill(true), removal: 'original-result', calls: [[true, ['removed', 7]]] });
});
test('autocomplete inserts Unicode and punctuation aliases at the correct prompt boundary', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
const prompt = page.getByLabel('text', { exact: true });
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
await page.evaluate(async () => {
const autocomplete = window.kikotools.autocomplete;
const { parseTagList } = await import('/extensions/kikotools/autocomplete/tags.js');
autocomplete.customWords = parseTagList('日本語,0,10,にほんご\nheart,0,20,<3\nhatsune_miku_(vocaloid),4,100,miku/vocaloid\n🎨🎨,0,1,\n(o_o),0,1,');
Object.assign(autocomplete.settings, { showCustomWords: true, showEmbeddings: false, showLoras: false, replaceUnderscores: false });
});
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
for (const [query, label, value] of [
['日本', '日本語', '日本語, '],
['にほん', '日本語', '日本語, '],
['sky, <3', 'heart', 'sky, heart, '],
['miku/vocal', 'hatsune_miku_(vocaloid)', 'hatsune_miku_(vocaloid), '],
['sky, (日本:1.2)', '日本語', 'sky, (日本語:1.2)'],
['🎨🎨', '🎨🎨', '🎨🎨, '],
['(o_o)', '(o_o)', '(o_o), '],
]) {
await prompt.fill(query);
if (query.includes(':1.2')) await prompt.evaluate(element => { element.setSelectionRange(element.value.indexOf(':1.2'), element.value.indexOf(':1.2')); element.dispatchEvent(new Event('input', { bubbles: true })); });
await expect(list.getByRole('option').first()).toContainText(label);
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue(value);
}
});
test('autocomplete stops an oversized URL stream before it reaches the parser', async ({ page }) => {
await openHost(page);
const result = await page.evaluate(async () => {
const autocomplete = window.kikotools.autocomplete;
let cancelled = false, sent = false;
const original = autocomplete.fetchWithAbort;
autocomplete.fetchWithAbort = async () => new Response(new ReadableStream({
pull(controller) { if (!sent) { sent = true; controller.enqueue(new Uint8Array(32 * 1024 * 1024 + 1).fill(97)); } else controller.close(); },
cancel() { cancelled = true; },
}, { highWaterMark: 0 }));
let error;
try { await autocomplete.fetchTagList('/__test__/oversized-tags.csv'); }
catch (failure) { error = failure.message; }
finally { autocomplete.fetchWithAbort = original; }
return { cancelled, error };
});
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: [] });
});
+414 -4
View File
@@ -2,26 +2,35 @@ 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 => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace(/^\/api\//, '/');
if (path.startsWith('/kiko_local_image_loader/') || path === '/view') {
if (path.startsWith('/kiko_local_image_loader/') || path.startsWith('/kikotools/autocomplete/') || path === '/view') {
const response = await route.fetch({ url: `${fixture}${path}${url.search}` });
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/'));
@@ -35,12 +44,14 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
}
return route.continue();
});
await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async () => {
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();
@@ -465,3 +476,402 @@ test('Node Dimensions applies and persists across Classic and Nodes 2.0', async
});
await expect(dialog).toHaveCount(0);
});
for (const name of ['DisplayText', 'DisplayAny']) {
test(`${name}: shared output persists through renderers, themes and legacy reload`, async ({ page }, testInfo) => {
const output = name === 'DisplayText'
? 'Positive prompt: A blue cabin beside a quiet lake, soft morning light, detailed reflections.\nNegative prompt: blur, noise, watermark'
: Array.from({ length: 45 }, (_, index) => `Line ${index}: tensor / diagnostic output`).join('\n');
await page.evaluate(async ({ name, output }) => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(name);
app.graph.add(node);
node.setSize([560, 520]);
const mode = node.widgets.find(widget => widget.name === 'mode');
if (mode) mode.value = 'tensor shape';
node.onExecuted({ text: [output] });
app.canvas.centerOnNode(node);
window.displayCopies = [];
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => window.displayCopies.push(text) } });
}, { name, output });
const panel = page.locator('.ca-text-output');
await expect(panel).toHaveCount(1);
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);
app.canvas.ds.scale = .85;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toBeVisible();
await expect(panel).toHaveCount(1);
await panel.getByRole('button', { name: name === 'DisplayText' ? 'Copy negative' : 'Copy value', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(panel.getByRole('status')).toHaveText('Copied.');
}
expect(await page.evaluate(() => window.displayCopies)).toEqual(Array(3).fill(name === 'DisplayText' ? 'blur, noise, watermark' : output));
const restored = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes[0];
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
return { snapshot: saved.kikoDisplay, size: Array.from(app.graph._nodes[0].size), mode: app.graph._nodes[0].widgets.find(widget => widget.name === 'mode')?.value };
});
expect(restored.snapshot).toEqual({ version: 1, text: output });
expect(restored.size).toEqual([560, 520]);
if (name === 'DisplayAny') {
expect(restored.mode).toBe('tensor shape');
await expect(panel.getByLabel('Value output', { exact: true })).toHaveValue(output);
} else {
await expect(panel.getByLabel('Positive prompt', { exact: true })).toHaveValue('A blue cabin beside a quiet lake, soft morning light, detailed reflections.');
await expect(panel.getByLabel('Negative prompt', { exact: true })).toHaveValue('blur, noise, watermark');
}
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`${name}-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes[0].setSize([280, 520]);
app.canvas.centerOnNode(app.graph._nodes[0]);
});
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
delete workflow.nodes[0].kikoDisplay;
workflow.nodes[0].widgets_values = name === 'DisplayText'
? ['✓ Positive Prompt', 'legacy positive', null, '✗ Negative Prompt', 'legacy negative', null]
: ['tensor shape', 'legacy first', 'legacy second', null];
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, name);
await expect(panel).toHaveCount(1);
if (name === 'DisplayText') {
await expect(panel.getByLabel('Positive prompt', { exact: true })).toHaveValue('legacy positive');
await expect(panel.getByLabel('Negative prompt', { exact: true })).toHaveValue('legacy negative');
} else {
await expect(panel.getByLabel('Value output', { exact: true })).toHaveValue('legacy first\nlegacy second');
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return app.graph._nodes[0].widgets.find(widget => widget.name === 'mode').value;
})).toBe('tensor shape');
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
window.removedDisplay = node;
app.graph.remove(node);
});
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => window.removedDisplay.displayOutput.scope.signal.aborted)).toBe(true);
});
}
test('embedding suggestions survive real renderers, zoom, themes and workflow reload', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('TextInput');
app.graph.add(node);
node.setSize([550, 350]);
app.canvas.centerOnNode(node);
});
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.locator('textarea:visible');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
for (const [renderer, zoom] of [[false, .65], [true, 1.1], [false, .9]]) {
await page.evaluate(async ({ renderer, zoom }) => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.ds.scale = zoom;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, { renderer, zoom });
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await prompt.fill('embedding:ca');
await expect(list).toBeVisible();
await prompt.press('ArrowDown');
await expect(list.getByRole('option', { selected: true })).toHaveCount(1);
await expect(prompt).toBeFocused();
const box = await list.boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(1600);
expect(box.y + box.height).toBeLessThanOrEqual(1100);
await prompt.press('Enter');
await expect(prompt).toHaveValue('embedding:portraits/cat, ');
await prompt.fill('<lora:in');
await list.getByRole('option').click();
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(app.graph.serialize());
app.canvas.centerOnNode(app.graph._nodes[0]);
});
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await prompt.fill('em');
await prompt.press('ArrowDown');
await expect(list).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`suggestions-${theme}.png`) });
await prompt.press('Escape');
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
window.removedCompletionWidget = node.widgets.find(widget => widget.inputEl);
app.graph.remove(node);
});
await expect(list).toBeHidden();
expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.removedCompletionWidget))).toBe(false);
});
test('embedding settings use the real host launcher and preserve preferences', async ({ page }, testInfo) => {
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
const search = page.getByPlaceholder(/search/i);
await search.fill('Embedding Autocomplete');
const trigger = page.getByRole('button', { name: 'Open Settings', exact: true });
await trigger.click();
const dialog = page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true });
await expect(dialog).toBeVisible();
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`settings-${theme}.png`) });
}
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).showLoras)).toBe(false);
await trigger.click();
await expect(dialog.getByRole('switch', { name: 'Show LoRAs', exact: true })).not.toBeChecked();
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).check();
await page.keyboard.press('Escape');
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.showLoras)).toBe(false);
});
test('embedding tag files import through real settings and complete canonical tags in both renderers', async ({ page }, testInfo) => {
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
await page.getByPlaceholder(/search/i).fill('Embedding Autocomplete');
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true });
const input = process.env.KIKO_TAG_CSV || { name: 'tags.csv', mimeType: 'text/csv', buffer: Buffer.from('highres,5,100,"high_res,high_resolution"\nblue_eyes,0,90,azure') };
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles(input);
await expect(dialog.getByRole('status')).toContainText('tags ready', { timeout: 15000 });
await testInfo.attach('import-status.txt', { body: await dialog.getByRole('status').innerText(), contentType: 'text/plain' });
await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check();
await dialog.getByText('Triggers, matching and insertion', { exact: true }).click();
await dialog.getByRole('switch', { name: 'Replace underscores with spaces', exact: true }).uncheck();
await page.screenshot({ path: testInfo.outputPath('tag-import.png') });
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
await page.keyboard.press('Escape');
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('TextInput');
app.graph.add(node);
node.setSize([550, 350]);
app.canvas.centerOnNode(node);
});
const prompt = page.locator('textarea:visible');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
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);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await prompt.fill('high_resolution');
await expect(list.getByRole('option').first()).toContainText('highres');
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue('highres, ');
}
});
test('embedding Unicode and emoticon completion preserves prompt boundaries in both renderers', async ({ page }) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const { parseTagList } = await import('/extensions/kikotools/autocomplete/tags.js');
const autocomplete = window.kikotools.autocomplete;
autocomplete.customWords = parseTagList('日本語,0,10,にほんご\nheart,0,20,<3');
Object.assign(autocomplete.settings, { showCustomWords: true, showEmbeddings: false, showLoras: false });
const node = LiteGraph.createNode('TextInput');
app.graph.add(node);
node.setSize([550, 350]);
app.canvas.centerOnNode(node);
});
const prompt = page.locator('textarea:visible');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
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);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await prompt.focus();
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
for (const [query, result] of [['sky, 日本', 'sky, 日本語, '], ['sky, <3', 'sky, heart, ']]) {
await prompt.fill(query);
await expect(list.getByRole('option')).toHaveCount(1);
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue(result);
}
}
});
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([]);
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readTagResponse } from '../../web/autocomplete/tag-download.js';
function response(chunks, headers = {}) {
let index = 0, cancelled = false;
const body = new ReadableStream({
pull(controller) { if (index < chunks.length) controller.enqueue(chunks[index++]); else controller.close(); },
cancel() { cancelled = true; },
}, { highWaterMark: 0 });
return { value: new Response(body, { headers }), cancelled: () => cancelled, read: () => index };
}
const encode = value => new TextEncoder().encode(value);
test('tag downloads stop reading at the byte limit with missing or dishonest headers', async () => {
for (const headers of [{}, { 'content-length': '1' }]) {
const stream = response([encode('1234'), encode('56789'), encode('unread')], headers);
await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i);
assert.equal(stream.cancelled(), true);
assert.equal(stream.read(), 2);
assert.equal(stream.value.body.locked, false);
}
});
test('oversized declared downloads are cancelled without reading their contents', async () => {
const stream = response([encode('unread')], { 'content-length': '100' });
await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i);
assert.equal(stream.read(), 0);
assert.equal(stream.cancelled(), true);
});
test('streamed UTF-8 survives split code points and an exact byte limit', async () => {
const bytes = encode('日本語');
const stream = response([bytes.slice(0, 2), bytes.slice(2, 5), bytes.slice(5)]);
assert.equal(await readTagResponse(stream.value, { maxBytes: bytes.length }), '日本語');
assert.equal(stream.value.body.locked, false);
assert.equal(await readTagResponse(new Response(null)), '');
});
test('aborted or failed downloads release their reader and preserve the error', { timeout: 2000 }, async () => {
const controller = new AbortController();
let cancelled = false;
const stream = new ReadableStream({ cancel() { cancelled = true; } });
const value = new Response(stream);
const pending = readTagResponse(value, { signal: controller.signal });
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(cancelled, true);
assert.equal(value.body.locked, false);
const stopped = response([encode('unread')]);
await assert.rejects(readTagResponse(stopped.value, { signal: AbortSignal.abort() }), { name: 'AbortError' });
assert.equal(stopped.cancelled(), true);
const error = new Error('Network interrupted');
const failed = new Response(new ReadableStream({ start(stream) { stream.error(error); } }));
await assert.rejects(readTagResponse(failed), /Network interrupted/);
assert.equal(failed.body.locked, false);
});
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { suggestionPosition } from '../../web/ui/autocomplete-suggestions.js';
test('completion popup stays within the viewport and flips above a low field', () => {
assert.deepEqual(suggestionPosition({ left: 20, top: 40, bottom: 80 }, { width: 220, height: 150 }, { width: 800, height: 600 }), { left: 20, top: 84 });
assert.deepEqual(suggestionPosition({ left: 300, top: 400, bottom: 440 }, { width: 220, height: 150 }, { width: 320, height: 480 }), { left: 92, top: 246 });
assert.deepEqual(suggestionPosition({ left: -30, top: 2, bottom: 10 }, { width: 300, height: 300 }, { width: 240, height: 200 }), { left: 8, top: 8 });
});
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { tagCache, parseTagFile } from '../../web/autocomplete/tag-storage.js';
test('a blocked cache operation cannot write after its caller has received an error', async () => {
const request = {};
let closed = false, wrote = false;
globalThis.indexedDB = { open: () => request };
try {
const operation = tagCache({ source: 'new-file' });
request.onblocked();
await assert.rejects(operation, /blocked/);
request.result = { close: () => { closed = true; }, transaction: () => { wrote = true; throw new Error('Abandoned operation wrote data'); } };
request.onsuccess();
assert.equal(closed, true);
assert.equal(wrote, false);
} finally { delete globalThis.indexedDB; }
});
test('cancelled imports never start workers, and running workers terminate on cancellation or failure', async () => {
await assert.rejects(parseTagFile('tag', AbortSignal.abort()), { name: 'AbortError' });
let current;
globalThis.Worker = class {
constructor() { current = this; this.terminated = false; }
postMessage() {}
terminate() { this.terminated = true; }
};
try {
const controller = new AbortController();
const aborted = parseTagFile('tag', controller.signal);
controller.abort();
await assert.rejects(aborted, { name: 'AbortError' });
assert.equal(current.terminated, true);
const failed = parseTagFile('tag', new AbortController().signal);
current.onerror();
await assert.rejects(failed, /Could not parse/);
assert.equal(current.terminated, true);
} finally { delete globalThis.Worker; }
});
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTagList, searchTags, tagQuery } from '../../web/autocomplete/tags.js';
test('four-column packs preserve categories, popularity and quoted aliases', () => {
const tags = parseTagList('\ufefftag,category,post_count,aliases\r\nblue_eyes,0,900,"blue_eye,azure_eyes"\r\nartist_name,1,700,"artist,artist_alias"\r\n');
assert.deepEqual(tags.map(({ name, category, frequency, aliases }) => ({ name, category, frequency, aliases })), [
{ name: 'blue_eyes', category: 0, frequency: 900, aliases: ['blue_eye', 'azure_eyes'] },
{ name: 'artist_name', category: 1, frequency: 700, aliases: ['artist', 'artist_alias'] },
]);
assert.equal(searchTags(tags, 'azure', 5)[0].value, 'blue_eyes');
});
test('legacy words, frequencies and headerless packs remain compatible', () => {
assert.deepEqual(parseTagList('low,2\nhigh,100\nplain\n').map(tag => [tag.name, tag.frequency]), [['high', 100], ['low', 2], ['plain', 0]]);
assert.equal(parseTagList('long_hair,0,6317335,')[0].frequency, 6317335);
assert.equal(parseTagList('"say""hello",0,12,"nickname"')[0].name, 'say"hello');
});
test('invalid data fails clearly instead of replacing a valid list with garbage', () => {
for (const text of ['', '<!doctype html><html>error</html>', '<script>alert(1)</script>', 'x,0,NaN,', 'x,2,12,', 'x,-1', '"unfinished', 'x,0,1,a,extra', 'x,0,1,"a"tail', 'name,category,post_count,aliases\n']) {
assert.throws(() => parseTagList(text), Error, text);
}
assert.throws(() => parseTagList('x'.repeat(32 * 1024 * 1024 + 1)), /large/i);
});
test('duplicates merge aliases while retaining the highest frequency', () => {
const tags = parseTagList('blue_eyes,0,100,azure\nblue_eyes,0,200,blue_eye');
assert.equal(tags.length, 1);
assert.equal(tags[0].frequency, 200);
assert.deepEqual(tags[0].aliases, ['azure', 'blue_eye']);
});
test('search ranks exact canonical and alias matches, prefixes, then substrings by popularity', () => {
const tags = parseTagList('blue_sky,0,100,azure\nblue_eyes,0,900,\nlight_blue,0,500,\nblue,0,1,\n');
assert.deepEqual(searchTags(tags, 'blue', 3).map(tag => tag.name), ['blue', 'blue_eyes', 'blue_sky']);
assert.equal(searchTags(tags, 'AZURE', 1)[0].name, 'blue_sky');
assert.deepEqual(searchTags(tags, 'missing', 3), []);
assert.deepEqual(searchTags(tags, '', 0), []);
assert.deepEqual(searchTags(tags, '', 2).map(tag => tag.name), ['blue_eyes', 'light_blue']);
});
test('search handles 300,000 tags with bounded results and canonical alias insertion', () => {
const text = Array.from({ length: 300000 }, (_, i) => `tag_${i},0,${i},alias_${i}`).join('\n');
const tags = parseTagList(text);
assert.equal(tags.length, 300000);
const matches = searchTags(tags, 'alias_299999', 20);
assert.equal(matches.length, 1);
assert.equal(matches[0].value, 'tag_299999');
});
test('tag data supports Unicode, emoticons and quoted commas as plain text', () => {
const tags = parseTagList('heart,0,50,<3\n>_<,0,20,\n日本語,3,10,"別名,にほんご"');
assert.equal(searchTags(tags, '<3', 1)[0].value, 'heart');
assert.equal(searchTags(tags, 'にほんご', 1)[0].value, '日本語');
assert.equal(searchTags(tags, '>_<', 1)[0].value, '>_<');
});
test('worker reports parsed tag records and errors through its message boundary', async () => {
const messages = [];
globalThis.self = { postMessage: message => messages.push(message) };
try {
await import('../../web/autocomplete/tag-worker.js');
self.onmessage({ data: 'blue_eyes,0,20,azure' });
self.onmessage({ data: 'bad,0,invalid,' });
assert.equal(messages[0].tags[0].frequency, 20);
assert.match(messages[1].error, /post count/);
} finally { delete globalThis.self; }
});
test('prompt query boundaries preserve Unicode, punctuation and surrounding weighting syntax', () => {
assert.deepEqual(tagQuery('sky, 日本'), { prefix: '日本', start: 5 });
assert.deepEqual(tagQuery('(o_o)'), { prefix: '(o_o)', start: 0 });
assert.deepEqual(tagQuery('<3'), { prefix: '<3', start: 0 });
assert.deepEqual(tagQuery('sky, (日本', true), { prefix: '日本', start: 6 });
assert.deepEqual(tagQuery('hatsune_miku_(vocaloid'), { prefix: 'hatsune_miku_(vocaloid', start: 0 });
assert.deepEqual(tagQuery('foo\nmiku/vocal'), { prefix: 'miku/vocal', start: 4 });
assert.deepEqual(tagQuery('🎨🎨'), { prefix: '🎨🎨', start: 0 });
assert.equal(tagQuery('sky, '), null);
assert.equal(tagQuery('(', true), null);
});
+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' });
});
+38
View File
@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { outputSections } from '../../web/views/text-output.js';
import { restoreDisplayOutput } from '../../web/controllers/display-node.js';
test('display text recognizes paired prompts and preserves ordinary text verbatim', () => {
assert.deepEqual(outputSections('text', 'Positive prompt: bright ** metadata\nNegative prompt: blur ** metadata').map(section => section.text), ['bright', 'blur']);
assert.deepEqual(outputSections('text', 'POSITIVE PROMPT: sun\nNEGATIVE PROMPT: rain').map(section => section.text), ['sun', 'rain']);
for (const text of ['', 'Positive prompt: alone', '<script>hello</script>\nnext']) {
assert.deepEqual(outputSections('text', text).map(section => section.text), [text]);
}
const long = Array.from({ length: 45 }, (_, index) => `Line ${index}`).join('\n');
assert.deepEqual(outputSections('any', long).map(section => section.text), [long]);
});
test('versioned display snapshots preserve complete output including empty text', () => {
for (const kind of ['text', 'any']) {
for (const text of ['', 'mode\nfull\noutput']) {
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 1, text }, widgets_values: ['stale'] }, kind, false), text);
}
assert.equal(restoreDisplayOutput({}, kind, false), null);
assert.equal(restoreDisplayOutput({ widgets_values: [] }, kind, false), null);
}
});
test('legacy display workflows restore prompt pairs, converted inputs and every chunk', () => {
assert.equal(restoreDisplayOutput({ widgets_values: ['✓ Positive Prompt', 'sun', null, '✗ Negative Prompt', 'rain', null] }, 'text', false), 'Positive prompt: sun\nNegative prompt: rain');
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'saved text', null] }, 'text', true), 'saved text');
assert.equal(restoreDisplayOutput({ widgets_values: ['raw value', 'first\nchunk', 'second\nchunk', null] }, 'any', false), 'first\nchunk\nsecond\nchunk');
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'tensor shape', 'shape', null] }, 'any', true), 'shape');
assert.equal(restoreDisplayOutput({ widgets_values: ['tensor shape'] }, 'any', false), null);
assert.equal(restoreDisplayOutput({ widgets_values: [''] }, 'text', false), '');
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 99, text: 'unknown' }, widgets_values: ['legacy'] }, 'text', false), 'legacy');
});
test('prompt detection retains leading markdown markers accepted by existing workflows', () => {
assert.equal(outputSections('text', 'Positive prompt: **sun\nNegative prompt: rain')[0].text, '**sun');
});
+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 } },
],
});
+67 -13
View File
@@ -1,15 +1,45 @@
{
"autocomplete/tag-download.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"autocomplete/tag-storage.js": {
"lines": 98,
"branches": 98,
"functions": 74,
"statements": 98
},
"autocomplete/tag-worker.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"autocomplete/tags.js": {
"lines": 98,
"branches": 95,
"functions": 98,
"statements": 98
},
"controllers/display-node.js": {
"lines": 98,
"branches": 95,
"functions": 86,
"statements": 98
},
"display_any.js": {
"lines": 84,
"branches": 66,
"functions": 83,
"statements": 84
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"display_text.js": {
"lines": 71,
"branches": 74,
"functions": 42,
"statements": 71
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"empty_latent_batch_swap.js": {
"lines": 98,
@@ -24,10 +54,10 @@
"statements": 52
},
"kikoEmbeddingAutocomplete.js": {
"lines": 44,
"branches": 43,
"functions": 59,
"statements": 44
"lines": 82,
"branches": 74,
"functions": 92,
"statements": 82
},
"kiko_custom_colors.js": {
"lines": 46,
@@ -71,6 +101,12 @@
"functions": 88,
"statements": 95
},
"ui/autocomplete-suggestions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"ui/color-picker.js": {
"lines": 98,
"branches": 94,
@@ -91,7 +127,7 @@
},
"ui/dialog.js": {
"lines": 98,
"branches": 85,
"branches": 88,
"functions": 98,
"statements": 98
},
@@ -113,12 +149,24 @@
"functions": 98,
"statements": 93
},
"ui/text-widget-adapter.js": {
"lines": 98,
"branches": 89,
"functions": 98,
"statements": 98
},
"ui/workflow-clock.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"views/autocomplete-settings.js": {
"lines": 98,
"branches": 83,
"functions": 98,
"statements": 98
},
"views/image-browser.js": {
"lines": 98,
"branches": 98,
@@ -137,6 +185,12 @@
"functions": 98,
"statements": 98
},
"views/text-output.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"width_height_swap.js": {
"lines": 98,
"branches": 98,
+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"
+39
View File
@@ -0,0 +1,39 @@
import { MAX_TAG_BYTES } from './tags.js';
export async function readTagResponse(response, { maxBytes = MAX_TAG_BYTES, signal } = {}) {
const tooLarge = () => new Error(`Tag file too large. Maximum size is ${maxBytes === MAX_TAG_BYTES ? '32 MB' : `${maxBytes} bytes`}.`);
if (signal?.aborted) {
await response.body?.cancel();
signal.throwIfAborted();
}
if (Number(response.headers.get('content-length')) > maxBytes) {
await response.body?.cancel();
throw tooLarge();
}
if (!response.body) return '';
const reader = response.body.getReader();
const abort = () => { reader.cancel().catch(() => {}); };
signal?.addEventListener('abort', abort, { once: true });
const decoder = new TextDecoder();
const parts = [];
let bytes = 0;
try {
while (true) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
signal?.throwIfAborted();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
await reader.cancel();
throw tooLarge();
}
parts.push(decoder.decode(value, { stream: true }));
}
parts.push(decoder.decode());
return parts.join('');
} finally {
signal?.removeEventListener('abort', abort);
reader.releaseLock();
}
}
+36
View File
@@ -0,0 +1,36 @@
export function tagCache(value) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('kikotools-autocomplete', 1);
request.onupgradeneeded = () => request.result.createObjectStore('tags');
request.onerror = () => reject(request.error);
let blocked = false;
request.onblocked = () => { blocked = true; reject(new Error('Tag storage is blocked by another browser tab.')); };
request.onsuccess = () => {
const db = request.result;
if (blocked) { db.close(); return; }
db.onversionchange = () => db.close();
const transaction = db.transaction('tags', value === undefined ? 'readonly' : 'readwrite');
const store = transaction.objectStore('tags');
const result = value === undefined ? store.get('active') : store.put(value, 'active');
transaction.oncomplete = () => { db.close(); resolve(result.result); };
transaction.onabort = transaction.onerror = () => { db.close(); reject(transaction.error || new Error('Could not access tag storage.')); };
};
});
}
export function parseTagFile(text, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(new DOMException('Import cancelled.', 'AbortError')); return; }
const worker = new Worker(new URL('./tag-worker.js', import.meta.url), { type: 'module' });
const finish = (callback, value) => {
worker.terminate();
signal.removeEventListener('abort', abort);
callback(value);
};
const abort = () => finish(reject, new DOMException('Import cancelled.', 'AbortError'));
signal.addEventListener('abort', abort, { once: true });
worker.onmessage = ({ data }) => data.error ? finish(reject, new Error(data.error)) : finish(resolve, data.tags);
worker.onerror = () => finish(reject, new Error('Could not parse tag file.'));
worker.postMessage(text);
});
}
+5
View File
@@ -0,0 +1,5 @@
import { parseTagList } from './tags.js';
self.onmessage = ({ data }) => {
try { self.postMessage({ tags: parseTagList(data) }); }
catch (error) { self.postMessage({ error: error.message }); }
};
+86
View File
@@ -0,0 +1,86 @@
export const MAX_TAG_BYTES = 32 * 1024 * 1024;
function* csvRows(text) {
let row = [], field = '', quoted = false, closed = false;
for (let i = 0; i <= text.length; i++) {
const char = text[i];
if (quoted) {
if (char === undefined) throw new Error('Unclosed CSV quote.');
if (char === '"') {
if (text[i + 1] === '"') { field += '"'; i++; }
else { quoted = false; closed = true; }
} else field += char;
} else if (char === ',' || char === '\n' || char === undefined) {
row.push(field.trim());
field = ''; closed = false;
if (char !== ',') {
if (row.some(value => value !== '')) yield row;
row = [];
}
} else if (char === '\r' && (text[i + 1] === '\n' || i === text.length - 1)) {
continue;
} else if (char === '"' && !field && !closed) quoted = true;
else {
if (closed && char.trim()) throw new Error('Unexpected text after CSV quote.');
field += char;
}
}
}
export function parseTagList(text) {
if (new TextEncoder().encode(text).byteLength > MAX_TAG_BYTES) throw new Error('Tag file too large. Maximum size is 32 MB.');
if (/^\s*<(?:!doctype|html|script|body|iframe)\b/i.test(text)) throw new Error('Expected a tag list, received HTML or script content.');
const unique = new Map();
let first = true;
for (const row of csvRows(text.replace(/^\ufeff/, ''))) {
if (first && ['tag', 'name'].includes(row[0].toLowerCase()) && ['category', 'frequency', 'post_count'].includes(row[1]?.toLowerCase())) {
first = false; continue;
}
first = false;
if (![1, 2, 4].includes(row.length)) throw new Error('Expected tag,frequency or tag,category,post_count,aliases.');
const name = row[0];
if (!name || name.length > 500 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error('Invalid tag name.');
const count = row.length === 4 ? row[2] : row[1] || '0';
if (!/^\d+$/.test(count) || !Number.isSafeInteger(Number(count))) throw new Error(`Invalid post count for ${name}.`);
const category = row.length === 4 ? Number(row[1]) : null;
if (row.length === 4 && (!/^[01345]$/.test(row[1]))) throw new Error(`Invalid category for ${name}.`);
const aliases = row.length === 4 ? [...new Set(row[3].split(',').map(value => value.trim()).filter(Boolean))] : [];
if (aliases.some(alias => alias.length > 500 || /[\u0000-\u001f\u007f]/.test(alias))) throw new Error(`Invalid alias for ${name}.`);
const existing = unique.get(name);
if (existing) {
existing.frequency = Math.max(existing.frequency, Number(count));
existing.aliases = [...new Set([...existing.aliases, ...aliases])];
} else unique.set(name, { name, category, frequency: Number(count), aliases });
}
if (!unique.size) throw new Error('No tags found in the file.');
for (const tag of unique.values()) tag.searchText = [tag.name, ...tag.aliases].join('\n').toLowerCase();
return [...unique.values()].sort((a, b) => b.frequency - a.frequency || a.name.localeCompare(b.name));
}
export function searchTags(tags, prefix, limit) {
if (limit <= 0) return [];
prefix = prefix.toLowerCase();
const buckets = [[], [], []];
for (const tag of tags) {
if (tag.searchText && !tag.searchText.includes(prefix)) continue;
const names = [tag.name, ...(tag.aliases || [])];
let tier = 3;
for (const name of names) {
const lower = name.toLowerCase();
if (lower === prefix) { tier = 0; break; }
if (lower.startsWith(prefix)) tier = Math.min(tier, 1);
else if (lower.includes(prefix)) tier = Math.min(tier, 2);
}
if (tier < 3 && buckets[tier].length < limit) buckets[tier].push(tag);
if (buckets[0].length >= limit) break;
}
return buckets.flat().slice(0, limit).map(tag => ({ ...tag, fullPath: tag.name, type: 'custom', display: tag.name, value: tag.name }));
}
export function tagQuery(text, weighted = false) {
const match = text.match(/[^\s,]+$/u);
if (!match) return null;
const leading = weighted ? match[0].match(/^[\[(]*/)[0].length : 0;
const prefix = match[0].slice(leading);
return prefix ? { prefix, start: match.index + leading } : null;
}
+63
View File
@@ -0,0 +1,63 @@
import { createScope } from '../ui/core.js';
import { createTextOutputView } from '../views/text-output.js';
export function restoreDisplayOutput(data, kind, hasInputWidget) {
if (data.kikoDisplay?.version === 1 && typeof data.kikoDisplay.text === 'string') return data.kikoDisplay.text;
if (!Array.isArray(data.widgets_values)) return null;
const values = data.widgets_values.slice(hasInputWidget && data.widgets_values.length > 1 ? 1 : 0);
if (kind === 'any' && ['raw value', 'tensor shape'].includes(values[0])) values.shift();
const strings = values.filter(value => typeof value === 'string');
if (!strings.length) return null;
if (kind === 'text' && strings[0] === '✓ Positive Prompt' && strings[2] === '✗ Negative Prompt') {
return `Positive prompt: ${strings[1]}\nNegative prompt: ${strings[3]}`;
}
return kind === 'any' ? strings.join('\n') : strings[0];
}
function updateOutput(node, text) {
if (node.displayOutput.scope.signal.aborted) return;
node.displayOutput.text = text;
node.displayOutput.view.update(text);
node.setDirtyCanvas(true, true);
}
export function registerDisplayNode(nodeType, app, kind) {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const scope = createScope();
const view = createTextOutputView(scope, kind, app.ui.settings);
this.displayOutput = { scope, view, text: '' };
this.addDOMWidget('kiko_display_output', 'div', view.element, {
serialize: false, hideOnZoom: false,
getMinHeight: () => kind === 'text' ? 240 : 180,
});
this.setSize([Math.max(this.size[0], kind === 'text' ? 400 : 450), Math.max(this.size[1], kind === 'text' ? 300 : 250)]);
updateOutput(this, kind === 'text' ? 'Text will appear here after execution...' : 'Value will appear here...');
return result;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
const result = onExecuted?.apply(this, arguments);
if (typeof message?.text?.[0] === 'string') updateOutput(this, message.text[0]);
return result;
};
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function (data) {
const text = restoreDisplayOutput(data, kind, Boolean(this.inputs?.[0]?.widget));
const result = configure?.apply(this, arguments);
if (text !== null) updateOutput(this, text);
return result;
};
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (data) {
const result = onSerialize?.apply(this, arguments);
data.kikoDisplay = { version: 1, text: this.displayOutput.text };
return result;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this.displayOutput.scope.dispose();
return onRemoved?.apply(this, arguments);
};
}
+5 -122
View File
@@ -1,126 +1,9 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { app } from '../../scripts/app.js';
import { registerDisplayNode } from './controllers/display-node.js';
app.registerExtension({
name: "ComfyAssets.DisplayAny",
name: 'ComfyAssets.DisplayAny',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayAny") {
function populate(text) {
// Remove existing display widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("display_") || this.widgets[i].name === "copy_value") {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
// Split text into multiple widgets if it's too long or has multiple lines
const lines = text ? text.split('\n') : [''];
const maxLinesPerWidget = 20;
const chunks = [];
for (let i = 0; i < lines.length; i += maxLinesPerWidget) {
chunks.push(lines.slice(i, i + maxLinesPerWidget).join('\n'));
}
// Create a widget for each chunk
chunks.forEach((chunk, index) => {
const w = ComfyWidgets["STRING"](this, `display_${index}`, ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.8;
w.inputEl.style.fontFamily = "monospace";
w.value = chunk;
});
// Add copy button widget
const copyWidget = {
type: "button",
name: "copy_value",
text: "📋 Copy Value",
callback: () => {
navigator.clipboard.writeText(text || '').then(() => {
copyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
copyWidget.text = "📋 Copy Value";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(copyWidget);
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the display text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
console.log("DisplayAny onExecuted message:", message);
if (message?.text && message.text.length > 0) {
const displayText = message.text[0];
console.log("DisplayAny displayText:", displayText);
populate.call(this, displayText);
} else {
console.log("DisplayAny no text in message");
}
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set minimum size - make it wider for better JSON display
this.size[0] = Math.max(this.size[0], 450);
this.size[1] = Math.max(this.size[1], 250);
// Add placeholder text
populate.call(this, "Value will appear here...");
};
}
}
if (nodeData.name === 'DisplayAny') registerDisplayNode(nodeType, app, 'any');
},
});
+5 -195
View File
@@ -1,199 +1,9 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { app } from '../../scripts/app.js';
import { registerDisplayNode } from './controllers/display-node.js';
app.registerExtension({
name: "ComfyAssets.DisplayText",
name: 'ComfyAssets.DisplayText',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayText") {
function populate(text) {
// Remove existing widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("text_") || this.widgets[i].name?.startsWith("copy_")) {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
// Parse the text to detect positive/negative prompt format
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (posMatch && negMatch) {
// Extract prompt content
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
// Remove any trailing ** markers
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
// Create header widget for positive prompt
const posHeader = ComfyWidgets["STRING"](this, "text_pos_header", ["STRING", { multiline: false }], app).widget;
posHeader.inputEl.readOnly = true;
posHeader.inputEl.style.opacity = 0.7;
posHeader.inputEl.style.backgroundColor = "#2a4a2a";
posHeader.inputEl.style.color = "#8f8";
posHeader.inputEl.style.fontWeight = "bold";
posHeader.value = "✓ Positive Prompt";
// Create positive prompt widget
const posWidget = ComfyWidgets["STRING"](this, "text_positive", ["STRING", { multiline: true }], app).widget;
posWidget.inputEl.readOnly = true;
posWidget.inputEl.style.opacity = 0.9;
posWidget.value = positiveText;
// Create copy button widget for positive prompt
const posCopyWidget = {
type: "button",
name: "copy_positive",
text: "📋 Copy Positive",
callback: () => {
navigator.clipboard.writeText(positiveText).then(() => {
posCopyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
posCopyWidget.text = "📋 Copy Positive";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(posCopyWidget);
// Create header widget for negative prompt
const negHeader = ComfyWidgets["STRING"](this, "text_neg_header", ["STRING", { multiline: false }], app).widget;
negHeader.inputEl.readOnly = true;
negHeader.inputEl.style.opacity = 0.7;
negHeader.inputEl.style.backgroundColor = "#4a2a2a";
negHeader.inputEl.style.color = "#f88";
negHeader.inputEl.style.fontWeight = "bold";
negHeader.value = "✗ Negative Prompt";
// Create negative prompt widget
const negWidget = ComfyWidgets["STRING"](this, "text_negative", ["STRING", { multiline: true }], app).widget;
negWidget.inputEl.readOnly = true;
negWidget.inputEl.style.opacity = 0.9;
negWidget.value = negativeText;
// Create copy button widget for negative prompt
const negCopyWidget = {
type: "button",
name: "copy_negative",
text: "📋 Copy Negative",
callback: () => {
navigator.clipboard.writeText(negativeText).then(() => {
negCopyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
negCopyWidget.text = "📋 Copy Negative";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(negCopyWidget);
} else {
// Single text display with ComfyUI's standard STRING widget
const w = ComfyWidgets["STRING"](this, "text_display", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.9;
w.value = text;
// Create copy button widget
const copyWidget = {
type: "button",
name: "copy_text",
text: "📋 Copy",
callback: () => {
navigator.clipboard.writeText(text).then(() => {
copyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
copyWidget.text = "📋 Copy";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(copyWidget);
}
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the input text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
populate.call(this, message.text[0]);
}
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set default size
this.size[0] = Math.max(this.size[0], 400);
this.size[1] = Math.max(this.size[1], 300);
// Add placeholder text
populate.call(this, "Text will appear here after execution...");
};
}
}
if (nodeData.name === 'DisplayText') registerDisplayNode(nodeType, app, 'text');
},
});
File diff suppressed because it is too large Load Diff
+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",
+77
View File
@@ -123,3 +123,80 @@ an immediate `close()` method. OK/Enter supplies the dialog and input/select
values; Cancel/Escape invokes cancellation once. Manual `close()` dismisses
without invoking cancellation. New views should build DOM through shared
factories rather than pass HTML to this compatibility helper.
## Display Text and Display Any
Both nodes use `../views/text-output.js` for labelled, selectable read-only text
areas and shared Copy buttons. Display Text detects paired positive/negative
prompts and offers a copy action for each. Display Any preserves the complete
formatted output in one scrollable monospace field instead of separate chunks.
The view fills the available node area; updating output preserves node size.
Copy success and failure appear in a polite status area. Replacement and removal
cancel delayed feedback and ignore pending clipboard results from the old view.
`../controllers/display-node.js` preserves native input/mode widgets and existing
lifecycle hook arguments, context and return values. Complete output, including
empty text, is saved in a `kikoDisplay: { version: 1, text }` workflow snapshot.
Legacy widget values remain readable: converted input prefixes, prompt header
pairs, native Display Any modes and all saved output chunks are recognized.
The new output DOM widget does not serialize presentation state into native
widget values. Node removal disposes the view, its listeners and theme observers.
## Embedding Autocomplete
`autocomplete-suggestions.js` supplies the shared themed listbox, type badges,
selected state and viewport placement. The controller retains resource fetching,
matching, sorting, triggers and insertion preferences. Focus stays in the native
textarea; arrow keys select suggestions, configured Enter/Tab keys insert, and
Escape dismisses. New input clears the previous selection. Completion names are
rendered as text, including directory names, without interpreting markup.
`text-widget-adapter.js` binds editable Nodes 2.0 replacement textareas through
their node ID and associated label. It leaves the original widget/input intact,
cleans up disconnected replacements, and preserves Classic behavior when switching
back. Read-only and disabled fields are excluded. Each widget owns its listeners,
blur work and temporary accessibility attributes; removing a different widget
does not dismiss the active list. Page cleanup cancels pending refresh work.
`../views/autocomplete-settings.js` uses the shared native dialog and switches.
Only Save updates the existing `kikotools.embedding_autocomplete.settings` key;
Cancel/Escape discards edits. All advanced saved preferences are retained.
Storage failures keep the dialog open and restore the previous live preferences.
The settings launcher owns dialog cleanup, tolerates synchronous reparenting and
returns keyboard focus when the modal closes. The shared modal shell contains
pointer events so nested dialogs do not dismiss ComfyUI's parent settings panel.
### Tag packs and autocomplete preferences
The settings view stages CSV/TXT file and URL imports until Save Settings.
`autocomplete/tags.js` parses quoted four-column Danbooru data and legacy
word/frequency lists, validates counts/categories, deduplicates tags and aliases,
and limits search results without sorting every matching tag on each keystroke.
`autocomplete/tag-worker.js` performs parsing outside the UI thread; its owner
terminates it on completion, error, or dialog removal. Records expand to the
existing suggestion shape only for returned results.
`autocomplete/tag-storage.js` persists the active source text in browser
IndexedDB. The controller restores matching cached sources before requesting a
URL, guards stale loads, and retains the active list when an import fails.
Advanced trigger, matching and insertion controls use the existing settings key.
For dataset provenance, format and installation, see
[Tag packs](../../docs/tag-packs/README.md).
Node removal uses one hook per node, shared by its active text widgets. Detaching
widgets in any order restores the original hook after the last widget leaves.
General queries accept Unicode and punctuation between whitespace/comma
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.
+73
View File
@@ -0,0 +1,73 @@
import { createScope, mountTheme } from './core.js';
let nextList = 0;
export function suggestionPosition(field, popup, viewport) {
const left = Math.max(8, Math.min(field.left, viewport.width - popup.width - 8));
const below = field.bottom + 4;
const top = below + popup.height <= viewport.height - 8 ? below : field.top - popup.height - 4;
return { left, top: Math.max(8, Math.min(top, viewport.height - popup.height - 8)) };
}
export function createSuggestionView(scope, settings) {
const element = document.createElement('div');
element.className = 'ca-tools ca-completions kikotools-autocomplete-suggestions';
element.id = `kiko-completions-${++nextList}`;
element.role = 'listbox';
element.setAttribute('aria-label', 'Prompt completions');
element.hidden = true;
document.body.append(element);
mountTheme(element, settings, scope);
let optionsScope;
scope.cleanup(() => { optionsScope?.dispose(); element.remove(); });
return {
element,
hide() {
optionsScope?.dispose();
element.hidden = true;
element.replaceChildren();
},
render(suggestions, onSelect, onInsert) {
optionsScope?.dispose();
optionsScope = createScope();
element.replaceChildren();
suggestions.forEach((item, index) => {
const option = document.createElement('div');
option.className = 'kikotools-autocomplete-item';
option.id = `${element.id}-${index}`;
option.role = 'option';
option.setAttribute('aria-selected', 'false');
const badge = document.createElement('span');
badge.className = 'ca-completion-type';
badge.textContent = item.type === 'embedding' ? '[EMB]' : item.type === 'lora' ? '[LORA]' : '[TAG]';
const name = document.createElement('span');
name.className = 'ca-completion-name';
const path = item.type === 'embedding' ? item.fullPath : null;
if (path?.includes('/')) {
const directory = document.createElement('span');
directory.className = 'ca-completion-directory';
directory.textContent = path.slice(0, path.lastIndexOf('/') + 1);
name.append(directory, document.createTextNode(path.slice(path.lastIndexOf('/') + 1)));
} else name.textContent = item.name;
option.append(badge, name);
optionsScope.listen(option, 'pointerdown', event => event.preventDefault());
optionsScope.listen(option, 'pointermove', () => onSelect(index));
optionsScope.listen(option, 'click', () => onInsert(item));
element.append(option);
});
element.hidden = false;
},
select(index) {
const options = [...element.children];
options.forEach((option, position) => option.setAttribute('aria-selected', String(position === index)));
options[index]?.scrollIntoView({ block: 'nearest' });
return options[index]?.id;
},
position(field) {
const popup = element.getBoundingClientRect();
const { left, top } = suggestionPosition(field, popup, { width: innerWidth, height: innerHeight });
element.style.left = `${left}px`;
element.style.top = `${top}px`;
},
};
}
+41
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; }
@@ -188,3 +189,43 @@
.ca-dialog-error:empty { display: none; }
.ca-dialog input[aria-invalid="true"] { border-color: var(--ca-error); }
@media (max-width: 360px) { .ca-dimension-fields { grid-template-columns: minmax(0, 1fr); } }
.ca-text-output { display: flex; flex-direction: column; gap: 10px; inline-size: 100%; block-size: 100%; min-block-size: 180px; padding: 12px; border-radius: 6px; contain: size; overflow: auto; box-sizing: border-box; }
.ca-text-output > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-end: 8px; border-block-end: 1px solid var(--ca-line); }
.ca-text-output h2 { font-size: 13px; font-weight: 550; margin: 0; }
.ca-text-output > header span { color: var(--ca-muted); font-size: 10px; }
.ca-output-content { display: flex; flex-direction: column; flex: 1; gap: 12px; min-block-size: 0; overflow: auto; }
.ca-output-section { display: flex; flex-direction: column; flex: 1 0 110px; gap: 6px; min-inline-size: 0; }
.ca-output-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 6px; }
.ca-output-toolbar label { font-size: 12px; font-weight: 550; }
.ca-text-output textarea { inline-size: 100%; flex: 1; min-block-size: 64px; resize: none; opacity: 1; }
.ca-text-output[data-output-kind="any"] textarea { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.ca-text-output > p { font-size: 11px; color: var(--ca-muted); min-block-size: 17px; margin: 0; }
.ca-text-output > p[data-error="true"] { color: var(--ca-error-text); }
.ca-autocomplete-launcher { display: inline-flex; background: transparent; }
.ca-autocomplete-settings form { display: flex; flex-direction: column; gap: 12px; }
.ca-autocomplete-settings .ca-switch { justify-content: flex-start; }
.ca-completions { position: fixed; z-index: 10000; min-inline-size: min(240px, calc(100dvw - 16px)); max-inline-size: min(420px, calc(100dvw - 16px)); max-block-size: min(240px, calc(100dvh - 16px)); overflow: auto; border: 1px solid var(--ca-border); border-radius: 6px; box-shadow: 0 6px 20px #0005; box-sizing: border-box; }
.ca-completions[hidden] { display: none; }
.ca-completions .kikotools-autocomplete-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 10px; min-block-size: 32px; cursor: pointer; }
.ca-completion-type { flex-shrink: 0; font-size: 11px; color: var(--ca-focus); min-inline-size: 44px; }
.ca-completion-name { min-inline-size: 0; overflow-wrap: anywhere; }
.ca-completion-directory { color: var(--ca-muted); }
.ca-completions [aria-selected="true"] { background: var(--ca-accent); color: #fff; outline: 2px solid var(--ca-focus); outline-offset: -2px; }
.ca-completions [aria-selected="true"] :where(.ca-completion-type, .ca-completion-directory) { color: inherit; }
@media (pointer: coarse) { .ca-completions .kikotools-autocomplete-item { min-block-size: 40px; } }
.ca-autocomplete-settings { inline-size: 520px; }
.ca-autocomplete-settings fieldset { min-inline-size: 0; margin: 0; padding: 12px; border: 1px solid var(--ca-line); border-radius: 6px; display: flex; flex-direction: column; gap: 10px; }
.ca-autocomplete-settings legend { padding-inline: 4px; font-size: 12px; font-weight: 550; }
.ca-autocomplete-settings .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 5px; }
.ca-autocomplete-settings input { min-inline-size: 0; max-inline-size: 100%; box-sizing: border-box; }
.ca-autocomplete-settings input[type="file"] { inline-size: 100%; }
.ca-autocomplete-settings .ca-dialog-hint { margin: 0; }
.ca-autocomplete-settings a { color: var(--ca-focus); font-size: 12px; }
.ca-autocomplete-settings summary { cursor: pointer; font-size: 12px; padding-block: 6px; }
.ca-autocomplete-settings details[open] { display: flex; flex-direction: column; gap: 12px; }
.ca-autocomplete-settings .ca-tag-status { margin: 0; min-block-size: 18px; font-size: 12px; overflow-wrap: anywhere; color: var(--ca-muted); }
.ca-autocomplete-settings .ca-tag-status[data-error="true"] { color: var(--ca-error-text); }
.ca-autocomplete-settings .ca-tag-source { overflow-wrap: anywhere; }
+1
View File
@@ -42,6 +42,7 @@ export function createDialog({ title, settings, fallbackFocus, onCancel }) {
});
scope.listen(element, 'keydown', event => event.stopPropagation());
scope.listen(element, 'keyup', event => event.stopPropagation());
for (const event of ['pointerdown', 'mousedown', 'click']) scope.listen(element, event, event => event.stopPropagation());
return {
element, scope, body, actions,
close: element.close,
+33
View File
@@ -0,0 +1,33 @@
export function watchRendererTextareas(scope, graph, autocomplete) {
const bridges = new Map();
const detachMissing = () => {
for (const [textarea, bridge] of bridges) {
if (textarea.isConnected) continue;
autocomplete.detachFromWidget(bridge);
bridges.delete(textarea);
}
};
scope.listen(document, 'focusin', event => {
const textarea = event.target;
if (!(textarea instanceof HTMLTextAreaElement) || textarea.readOnly || textarea.disabled) return;
const owner = textarea.closest('[node-id]');
const node = owner && graph.getNodeById(owner.getAttribute('node-id'));
if (!node) return;
const label = textarea.labels?.[0]?.textContent.trim();
const widget = node.widgets?.find(widget => (widget.label || widget.name) === label && widget.inputEl?.tagName === 'TEXTAREA');
if (!widget || widget.inputEl === textarea || widget.inputEl.readOnly) return;
let bridge = bridges.get(textarea);
if (!bridge) {
bridge = { inputEl: textarea };
bridges.set(textarea, bridge);
}
autocomplete.attachToWidget(bridge, node);
});
const observer = new MutationObserver(detachMissing);
observer.observe(document.body, { childList: true, subtree: true });
scope.cleanup(() => {
observer.disconnect();
for (const bridge of bridges.values()) autocomplete.detachFromWidget(bridge);
bridges.clear();
});
}
+185
View File
@@ -0,0 +1,185 @@
import { createButton, createField, createScope, createSwitch, mountTheme } from '../ui/core.js';
import { createDialog } from '../ui/dialog.js';
import { MAX_TAG_BYTES } from '../autocomplete/tags.js';
import { parseTagFile } from '../autocomplete/tag-storage.js';
export function openAutocompleteSettings(autocomplete, settings, fallbackFocus) {
const existing = autocomplete.settingsDialog;
if (existing && !existing.scope.signal.aborted) {
existing.element.querySelector('input').focus();
return existing;
}
autocomplete.hideSuggestions();
const dialog = createDialog({ title: 'Embedding Autocomplete Settings', settings, fallbackFocus });
autocomplete.settingsDialog = dialog;
dialog.element.classList.add('ca-autocomplete-settings');
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
const fields = new Map();
const group = (title, parent = form) => {
const fieldset = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = title;
fieldset.append(legend);
parent.append(fieldset);
return fieldset;
};
const addSwitch = (parent, key, label) => {
const field = createSwitch(label, '', `${form.id}-${key}`);
const input = field.querySelector('input');
input.checked = autocomplete.settings[key] !== false;
fields.set(key, input);
parent.append(field);
};
const addInput = (parent, key, label, type = 'text', limits = {}) => {
const input = document.createElement('input');
Object.assign(input, { type, value: autocomplete.settings[key] ?? '', ...limits });
fields.set(key, input);
parent.append(createField(label, input, `${form.id}-${key}`));
return input;
};
const sources = group('Sources');
for (const [key, label] of [['enabled', 'Enable Autocomplete'], ['showEmbeddings', 'Show Embeddings'], ['showLoras', 'Show LoRAs'], ['showCustomWords', 'Show Custom Words']]) addSwitch(sources, key, label);
const tags = group('Tags and custom words');
const count = document.createElement('p');
count.className = 'ca-dialog-hint';
count.textContent = `${autocomplete.customWords.length.toLocaleString()} tags loaded`;
const hint = document.createElement('p');
hint.className = 'ca-dialog-hint';
hint.textContent = 'Import CSV or TXT, or load a URL. Lists replace the current tags when you save. Supports tag,frequency and tag,category,post_count,aliases. Maximum 32 MB.';
const pack = document.createElement('a');
pack.href = 'https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16';
pack.target = '_blank';
pack.rel = 'noopener noreferrer';
pack.textContent = 'Download the Danbooru tag pack';
const source = document.createElement('p');
source.className = 'ca-dialog-hint ca-tag-source';
source.textContent = `Current source: ${(autocomplete.settings.tagSource || autocomplete.settings.customWordsUrl || 'None').replace(/^file:/, '')}`;
tags.append(count, source, hint, pack);
const url = addInput(tags, 'customWordsUrl', 'Tag list URL', 'url');
const load = createButton('Load URL');
const file = document.createElement('input');
file.type = 'file';
file.accept = '.csv,.txt,text/csv,text/plain';
tags.append(load, createField('Tag list file', file, `${form.id}-file`));
const advanced = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = 'Triggers, matching and insertion';
advanced.append(summary);
form.append(advanced);
const matching = group('Triggers and matching', advanced);
addInput(matching, 'embeddingTrigger', 'Embedding trigger');
addInput(matching, 'loraTrigger', 'LoRA trigger');
addInput(matching, 'quickTrigger', 'Quick embedding trigger');
addInput(matching, 'minChars', 'Minimum characters', 'number', { min: 1, max: 10, step: 1, required: true });
addInput(matching, 'maxSuggestions', 'Maximum suggestions', 'number', { min: 1, max: 100, step: 1, required: true });
addSwitch(matching, 'sortByDirectory', 'Group by directory');
const insertion = group('Insertion', advanced);
for (const [key, label] of [['autoInsertComma', 'Auto-insert comma'], ['replaceUnderscores', 'Replace underscores with spaces'], ['insertOnTab', 'Insert on Tab'], ['insertOnEnter', 'Insert on Enter']]) addSwitch(insertion, key, label);
const status = document.createElement('p');
status.role = 'status';
status.className = 'ca-tag-status';
status.textContent = autocomplete.customWordsError || '';
tags.append(status);
dialog.body.append(form);
const cancel = createButton('Cancel');
const save = createButton('Save Settings', 'ca-primary');
save.type = 'submit';
save.setAttribute('form', form.id);
dialog.actions.append(cancel, save);
let pending, busy = false;
const prepare = async callback => {
busy = true;
load.disabled = file.disabled = save.disabled = true;
status.dataset.error = 'false';
status.textContent = 'Loading tags…';
try {
const result = await callback();
if (dialog.scope.signal.aborted) return;
pending = result;
status.textContent = `${pending.tags.length.toLocaleString()} tags ready. Save Settings to apply.`;
} catch (error) {
if (dialog.scope.signal.aborted) return;
status.dataset.error = 'true';
status.textContent = `Could not load tags: ${error.message}`;
} finally {
busy = false;
load.disabled = file.disabled = save.disabled = false;
}
};
dialog.scope.listen(load, 'click', () => {
if (!url.value.trim() || !url.reportValidity()) {
status.dataset.error = 'true';
status.textContent = 'Enter a valid tag-list URL.';
return;
}
prepare(() => autocomplete.fetchTagList(url.value.trim(), dialog.scope.signal));
});
dialog.scope.listen(file, 'change', () => {
const selected = file.files[0];
if (!selected) return;
prepare(async () => {
if (selected.size > MAX_TAG_BYTES) throw new Error('Tag file too large. Maximum size is 32 MB.');
const text = await selected.text();
const tags = await parseTagFile(text, dialog.scope.signal);
return { text, tags, source: `file:${selected.name}` };
});
file.value = '';
});
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'submit', async event => {
event.preventDefault();
if (busy) return;
const next = {};
for (const [key, input] of fields) next[key] = input.type === 'checkbox' ? input.checked : input.type === 'number' ? Number(input.value) : input.value.trim();
next.embeddingTrigger ||= 'embedding:';
next.loraTrigger ||= '<lora:';
next.tagSource = pending?.source || (next.customWordsUrl !== autocomplete.settings.customWordsUrl ? next.customWordsUrl : autocomplete.settings.tagSource);
save.disabled = true;
try {
await autocomplete.saveTagSettings(next, pending);
dialog.close('save');
} catch (error) {
status.dataset.error = 'true';
status.textContent = error.message;
save.disabled = false;
}
});
dialog.scope.cleanup(() => { if (autocomplete.settingsDialog === dialog) autocomplete.settingsDialog = null; });
dialog.open(fields.get('enabled'));
return dialog;
}
export function createAutocompleteLauncher(autocomplete, settings, fallbackFocus) {
if (!customElements.get('kiko-autocomplete-launcher')) {
customElements.define('kiko-autocomplete-launcher', class extends HTMLElement {
connectedCallback() {
if (this.scope && !this.scope.signal.aborted) return;
this.scope = createScope();
this.className = 'ca-tools ca-autocomplete-launcher';
mountTheme(this, this.settings, this.scope);
const button = createButton('Open Settings', 'ca-primary');
this.replaceChildren(button);
this.scope.listen(button, 'click', () => {
this.dialog = openAutocompleteSettings(this.autocomplete, this.settings, this.fallbackFocus);
this.dialog.owner = this;
});
this.scope.cleanup(() => { if (this.dialog?.owner === this) this.dialog.close('removed'); });
}
disconnectedCallback() {
const scope = this.scope;
queueMicrotask(() => { if (!this.isConnected) scope.dispose(); });
}
});
}
const row = document.createElement('tr');
row.className = 'kikotools-settings-row';
const label = document.createElement('td');
label.textContent = 'Embedding Autocomplete Configuration';
const value = document.createElement('td');
const launcher = document.createElement('kiko-autocomplete-launcher');
Object.assign(launcher, { autocomplete, settings, fallbackFocus });
value.append(launcher);
row.append(label, value);
return row;
}
+86
View File
@@ -0,0 +1,86 @@
import { createButton, createScope, mountTheme } from '../ui/core.js';
let nextOutput = 0;
export function outputSections(kind, text) {
if (kind === 'text') {
const positive = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negative = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (positive && negative) {
let positiveText = positive[1].trim();
const marker = positiveText.indexOf('**');
if (marker > 0) positiveText = positiveText.slice(0, marker).trim();
return [
{ label: 'Positive prompt', copy: 'Copy positive', text: positiveText },
{ label: 'Negative prompt', copy: 'Copy negative', text: negative[1].trim() },
];
}
}
return [{ label: kind === 'text' ? 'Text output' : 'Value output', copy: kind === 'text' ? 'Copy text' : 'Copy value', text }];
}
export function createTextOutputView(scope, kind, settings) {
const element = document.createElement('div');
element.className = 'ca-tools ca-text-output';
element.dataset.outputKind = kind;
const header = document.createElement('header');
const title = document.createElement('h2');
title.textContent = kind === 'text' ? 'Display Text' : 'Display Any';
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(title, brand);
const content = document.createElement('div');
content.className = 'ca-output-content';
const status = document.createElement('p');
status.role = 'status';
element.append(header, content, status);
mountTheme(element, settings, scope);
let renderScope;
scope.cleanup(() => {
renderScope?.dispose();
element.remove();
});
return {
element,
update(text) {
renderScope?.dispose();
const current = createScope();
renderScope = current;
content.replaceChildren();
status.textContent = '';
delete status.dataset.error;
const clearStatus = current.debounce(() => { status.textContent = ''; }, 1500);
for (const section of outputSections(kind, text)) {
const row = document.createElement('div');
row.className = 'ca-output-section';
const toolbar = document.createElement('div');
toolbar.className = 'ca-output-toolbar';
const input = document.createElement('textarea');
input.id = `ca-output-${++nextOutput}`;
input.readOnly = true;
input.spellcheck = false;
input.value = section.text;
const label = document.createElement('label');
label.htmlFor = input.id;
label.textContent = section.label;
const copy = createButton(section.copy, 'ca-primary');
toolbar.append(label, copy);
row.append(toolbar, input);
content.append(row);
current.listen(copy, 'click', async () => {
try {
await navigator.clipboard.writeText(section.text);
if (current.signal.aborted) return;
delete status.dataset.error;
status.textContent = 'Copied.';
clearStatus();
} catch {
if (current.signal.aborted) return;
status.dataset.error = 'true';
status.textContent = 'Could not copy. Select the text and copy it manually.';
}
});
}
},
};
}