Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2db82a80 | ||
|
|
1820fb19ea | ||
|
|
7edf08f1e2 | ||
|
|
1721778cb5 | ||
|
|
6bac864c4d | ||
|
|
8dc81364a0 | ||
|
|
957ca3c142 | ||
|
|
ad73422472 | ||
|
|
1a7e0dfcd7 | ||
|
|
0b85600d38 | ||
|
|
ca65cc7961 | ||
|
|
6adba639bb | ||
|
|
1e86b73168 | ||
|
|
0fa595a9b7 | ||
|
|
f8ce4cb982 | ||
|
|
42b6009f1b | ||
|
|
963ac0a4af | ||
|
|
b4bd8b98b1 | ||
|
|
ed6675721d | ||
|
|
b0c0d53676 | ||
|
|
b9103ad9c4 | ||
|
|
b5694ecfe5 | ||
|
|
1255c4e9cc | ||
|
|
26557a99b6 | ||
|
|
848674d474 | ||
|
|
0c47dc6085 | ||
|
|
ee7d1e9011 | ||
|
|
b7a29a1731 | ||
|
|
3cabbffb40 | ||
|
|
47dc177517 | ||
|
|
ddb356fe17 | ||
|
|
9e2b271824 | ||
|
|
2e7fac4b70 | ||
|
|
740eccec27 | ||
|
|
55bce1004b | ||
|
|
63a8596b81 | ||
|
|
11494c747c | ||
|
|
88a9238ee2 | ||
|
|
3879f18d79 | ||
|
|
64f70b6970 | ||
|
|
ae0fb7f229 | ||
|
|
ddf73a667e | ||
|
|
46999c8d90 | ||
|
|
852864a270 | ||
|
|
c182c775f4 | ||
|
|
02108be2d1 | ||
|
|
2a9fbe78c7 | ||
|
|
5a8a8b62e4 | ||
|
|
335cc53961 | ||
|
|
c5e00c130b | ||
|
|
78f1498ea8 | ||
|
|
c65f3aad03 | ||
|
|
9a55c23282 | ||
|
|
e90bde5849 | ||
|
|
49523e10de | ||
|
|
8e6cf4233a | ||
|
|
6301cf90f8 | ||
|
|
f87eb959b3 | ||
|
|
7783ed8a16 | ||
|
|
726fcee761 | ||
|
|
98f3defe50 | ||
|
|
143a6c47f6 | ||
|
|
76a52765c7 | ||
|
|
f308b23631 | ||
|
|
d65c11c76c | ||
|
|
1879f42f83 |
@@ -0,0 +1,29 @@
|
||||
# Files excluded from the Comfy Registry archive (gitignore syntax).
|
||||
# Everything here is development-only; the published node needs
|
||||
# __init__.py, kikotools/, web/, examples/, README, LICENSE, pyproject.toml.
|
||||
|
||||
# Test suite and tooling
|
||||
tests/
|
||||
scripts/
|
||||
.github/
|
||||
Makefile
|
||||
pytest.ini
|
||||
mypy.ini
|
||||
.flake8
|
||||
.pre-commit-config.yaml
|
||||
.secrets.baseline
|
||||
requirements-dev.txt
|
||||
|
||||
# Reference / scratch code not imported by the node
|
||||
example.py
|
||||
|
||||
# Agent and contributor docs
|
||||
CLAUDE.md
|
||||
CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
|
||||
tools/
|
||||
.ci-comfyui/
|
||||
coverage/
|
||||
TESTING.md
|
||||
AGENTS.md
|
||||
@@ -2,6 +2,7 @@
|
||||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
exclude =
|
||||
node_modules,
|
||||
.git,
|
||||
__pycache__,
|
||||
.mypy_cache,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"required_status_checks": {
|
||||
"strict": true,
|
||||
"contexts": [
|
||||
"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", "real ComfyUI compatibility", "code-quality"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
"required_pull_request_reviews": null,
|
||||
"restrictions": null,
|
||||
"allow_force_pushes": false,
|
||||
"allow_deletions": false
|
||||
}
|
||||
@@ -8,3 +8,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tools/testing"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -13,15 +13,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Format check with Black
|
||||
@@ -141,10 +142,10 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -172,16 +173,17 @@ jobs:
|
||||
architecture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Check architecture compliance
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'ComfyAssets' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
|
||||
@@ -10,55 +10,28 @@ on:
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run full test suite
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
# Run comprehensive tests before release
|
||||
from kikotools.base import ComfyAssetsBaseNode
|
||||
from kikotools.tools.resolution_calculator.logic import extract_dimensions, calculate_scaled_dimensions
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
import torch
|
||||
|
||||
print('Running pre-release validation...')
|
||||
|
||||
# Test all major functionality
|
||||
node = ResolutionCalculatorNode()
|
||||
|
||||
# Test various scenarios
|
||||
test_cases = [
|
||||
(torch.randn(1, 512, 512, 3), 2.0),
|
||||
(torch.randn(1, 1024, 1024, 3), 1.5),
|
||||
(torch.randn(1, 1216, 832, 3), 1.53), # User scenario
|
||||
]
|
||||
|
||||
for i, (image, scale) in enumerate(test_cases):
|
||||
width, height = node.calculate_resolution(scale, image=image)
|
||||
print(f'✓ Test case {i+1}: {image.shape[2]}×{image.shape[1]} → {width}×{height} (scale: {scale})')
|
||||
assert width % 8 == 0 and height % 8 == 0
|
||||
|
||||
print('🎉 All pre-release tests passed!')
|
||||
"
|
||||
- name: Run full test suite and coverage gate
|
||||
run: python -m pytest tests --cov=kikotools --cov-report=term-missing
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
@@ -100,7 +73,7 @@ jobs:
|
||||
### 📊 Technical Details
|
||||
|
||||
- **Nodes**: 1 (Resolution Calculator)
|
||||
- **Test Coverage**: 100%
|
||||
- **Python Coverage Gate**: 85% combined statement/branch coverage
|
||||
- **Python Support**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
|
||||
@@ -114,7 +87,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
|
||||
@@ -147,4 +120,4 @@ jobs:
|
||||
echo "- Resolution Calculator tool"
|
||||
echo "- Complete documentation"
|
||||
echo "- Example workflows"
|
||||
echo "- 100% test coverage"
|
||||
echo "- Enforced Python coverage and frontend regression checks"
|
||||
|
||||
@@ -20,10 +20,10 @@ jobs:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
@@ -40,18 +40,83 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt aiohttp
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests
|
||||
run: python -m pytest tests --junitxml=test-results/pytest.xml
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: python-${{ matrix.os }}-${{ matrix.python-version }}
|
||||
path: test-results/
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
@@ -61,21 +126,76 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt aiohttp
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# fail_under = 85 lives in pyproject.toml [tool.coverage.report]
|
||||
- name: Measure coverage
|
||||
run: python -m pytest tests --cov=kikotools --cov-report=term-missing
|
||||
run: python -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html --cov-report=xml
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: python-coverage
|
||||
path: |
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
js-check:
|
||||
name: frontend syntax
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# No JS unit tests yet; at least make sure every module parses
|
||||
- name: Check web/*.js parse as ES modules
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
# Check every shipped module, including files without behavioral tests.
|
||||
- name: Check all shipped JavaScript modules parse
|
||||
run: |
|
||||
for f in web/*.js; do
|
||||
shopt -s globstar nullglob
|
||||
for f in web/**/*.js web/**/*.mjs; do
|
||||
node --input-type=module --check < "$f" || { echo "Syntax error in $f"; exit 1; }
|
||||
done
|
||||
|
||||
frontend:
|
||||
name: frontend workflows and coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
- name: Install Python test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: tools/testing/package-lock.json
|
||||
- run: npm ci --prefix tools/testing --ignore-scripts
|
||||
- run: npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
|
||||
- name: JavaScript regressions, Chromium workflows, and coverage gate
|
||||
run: npm run --prefix tools/testing test:coverage
|
||||
- name: Firefox and WebKit workflows
|
||||
run: npm run --prefix tools/testing test:browser -- --project firefox --project webkit
|
||||
- name: Upload frontend reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
path: |
|
||||
tools/testing/test-results/
|
||||
coverage/js/
|
||||
!coverage/js/raw/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
@@ -164,3 +164,17 @@ experiments/
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
referance/
|
||||
|
||||
# Runtime state written inside the package (repo is symlinked into custom_nodes)
|
||||
kikotools/tools/local_image_loader/config.json
|
||||
kikotools/tools/local_image_loader/selections.json
|
||||
gemini_config.json
|
||||
|
||||
# Frontend test tooling and reports
|
||||
node_modules/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Disposable pinned host checkout used by CI
|
||||
.ci-comfyui/
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
repos:
|
||||
# Python code formatting with Black
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
rev: 26.10.1
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.10
|
||||
args: ['--line-length=88'] # Match CI configuration
|
||||
|
||||
# Python linting with flake8
|
||||
@@ -61,9 +60,27 @@ repos:
|
||||
args: ['--baseline', '.secrets.baseline']
|
||||
exclude: '^(tests/|\.git/)'
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: python-regressions
|
||||
name: Python regression suite
|
||||
entry: python -m pytest tests
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-push]
|
||||
- id: frontend-regressions
|
||||
name: JavaScript regression suite
|
||||
entry: npm test --prefix tools/testing
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-push]
|
||||
|
||||
# Configuration for specific hooks
|
||||
default_stages: [pre-commit]
|
||||
default_language_version:
|
||||
python: python3.10
|
||||
python: python3
|
||||
|
||||
# Run hooks on all files by default
|
||||
fail_fast: false
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`kikotools/tools/<tool>/logic.py` contains reusable logic; `node.py` defines the
|
||||
ComfyUI node. Shared classes live in `kikotools/base/`. Register nodes in
|
||||
`kikotools/__init__.py`; the root `__init__.py` exposes the plugin and API routes.
|
||||
`web/` contains browser extensions. Tests live in `tests/unit/`, `tests/js/`, and
|
||||
`tests/browser/`. Examples are in `examples/`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Install Python dependencies with `python -m pip install -r requirements-dev.txt`.
|
||||
Run `python -m pytest tests` or `make test` for the complete Python suite.
|
||||
Use `python -m pytest tests --cov=kikotools` for the 85% statement/branch gate.
|
||||
Format with `black .`; use `flake8 .` for lint findings.
|
||||
|
||||
Frontend tooling needs Node.js 24 and `npm ci --prefix tools/testing
|
||||
--ignore-scripts`. Run `npm test --prefix tools/testing` for JavaScript tests,
|
||||
`npm run --prefix tools/testing test:coverage` for coverage and Chromium workflows,
|
||||
and `npm run --prefix tools/testing test:browser` for all browser projects.
|
||||
See `TESTING.md` for browser installation, environment overrides, and artifacts.
|
||||
|
||||
## Coding Style & Naming
|
||||
|
||||
Use four-space Python indentation and Black's 88-character formatting.
|
||||
Use `snake_case` for Python functions/modules, `PascalCase` for classes, and
|
||||
`UPPER_CASE` for constants. Keep node interfaces separate from logic. Preserve
|
||||
existing ComfyUI lifecycle hooks when extending widgets and clean up timers,
|
||||
listeners, and observers when nodes are removed.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Write regression tests first and verify failure before implementing fixes.
|
||||
Name Python tests `test_*.py` and JavaScript tests `*.test.mjs`; put browser
|
||||
workflows in `tests/browser/*.spec.mjs`. Test behavior and persistence, including
|
||||
failure recovery. Keep fixtures disposable and external services mocked.
|
||||
Never lower coverage floors to make a change pass.
|
||||
|
||||
## Commits & Pull Requests
|
||||
|
||||
Use Conventional Commits, such as `fix(local_image_loader): preserve error
|
||||
status`. Link the tracked issue, describe observable behavior, and report the
|
||||
commands and results used for validation. Include screenshots for visual changes.
|
||||
Required checks must pass before merging. Keep credentials and runtime files out
|
||||
of commits; restart ComfyUI after installing Python changes.
|
||||
@@ -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.
|
||||
@@ -357,9 +357,12 @@ Brief description of changes.
|
||||
|
||||
### Review Process
|
||||
|
||||
See [TESTING.md](TESTING.md) for automated suites, coverage gates, required merge
|
||||
checks, and the remaining ComfyUI/GPU/service boundaries.
|
||||
|
||||
1. **Automated Checks**: CI runs tests and quality checks
|
||||
2. **Code Review**: Maintainer reviews code and provides feedback
|
||||
3. **Testing**: Manual testing in ComfyUI environment
|
||||
3. **Testing**: Run relevant automated suites; use live ComfyUI checks for host, GPU, service, or visual changes
|
||||
4. **Merge**: After approval, changes are merged to main
|
||||
|
||||
## 🎨 Code Style Guidelines
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ComfyUI-KikoTools Development Makefile
|
||||
|
||||
.PHONY: help install test test-fast lint format type-check quality-check clean setup dev-test release-test
|
||||
.PHONY: coverage frontend-test browser-test frontend-coverage help install test test-fast lint format type-check quality-check clean setup dev-test release-test
|
||||
|
||||
# Python and virtual environment setup
|
||||
PYTHON := python3
|
||||
@@ -34,7 +34,11 @@ help:
|
||||
@echo " quality-check - Run all quality checks"
|
||||
@echo ""
|
||||
@echo "Testing:"
|
||||
@echo " test - Run comprehensive tests"
|
||||
@echo " test - Run the complete pytest suite"
|
||||
@echo " coverage - Python statement/branch coverage (85% gate)"
|
||||
@echo " frontend-test - JavaScript regression tests"
|
||||
@echo " browser-test - Extension workflows in four browser configurations"
|
||||
@echo " frontend-coverage - JavaScript tests, browser tests, and coverage gate"
|
||||
@echo " test-fast - Run core functionality tests"
|
||||
@echo " dev-test - Quick development test"
|
||||
@echo " release-test - Full release validation"
|
||||
@@ -120,13 +124,20 @@ test-fast: $(VENV_DIR)
|
||||
print('🎉 All core tests passed!'); \
|
||||
"
|
||||
|
||||
test: test-fast
|
||||
@echo "Running comprehensive test suite..."
|
||||
@echo "✅ Test case 1: 512×512 → 1024×1024 (scale: 2.0)"
|
||||
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)"
|
||||
@echo "✅ Test case 3: 832×1216 → 1272×1864 (scale: 1.53)"
|
||||
@echo "✅ Error handling test passed"
|
||||
@echo "🎉 All comprehensive tests passed!"
|
||||
test: $(VENV_DIR)
|
||||
@$(PYTHON_CMD) -m pytest tests
|
||||
|
||||
coverage: $(VENV_DIR)
|
||||
@$(PYTHON_CMD) -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html
|
||||
|
||||
frontend-test:
|
||||
npm test --prefix tools/testing
|
||||
|
||||
browser-test:
|
||||
npm run --prefix tools/testing test:browser
|
||||
|
||||
frontend-coverage:
|
||||
npm run --prefix tools/testing test:coverage
|
||||
|
||||
release-test: quality-check test
|
||||
@echo "Running release validation..."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ComfyUI-KikoTools
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -17,6 +17,20 @@ I tried forking, patching, and submitting merge requests, but eventually decided
|
||||
|
||||
I’m sharing them here with the community, and I hope you find them as useful as I do.
|
||||
|
||||
## 1.2.0 — unified ComfyAssets UI
|
||||
|
||||
A consistent interface across the KikoTools controls, with ComfyUI-following
|
||||
light/dark styling and Classic/Nodes 2.0 support. Existing workflow interfaces
|
||||
and settings are retained. See the [release notes](CHANGELOG.md),
|
||||
[migration overview](docs/ui-migration.md) and [screenshots](docs/screenshots/).
|
||||
|
||||
## ComfyUI Classic and Nodes 2.0
|
||||
|
||||
KikoTools supports both ComfyUI Classic and Nodes 2.0. Our shared ComfyAssets
|
||||
controls follow ComfyUI's dark/light theme, with automated tests covering renderer
|
||||
switching, workflow persistence and cleanup. See the retained
|
||||
[UI screenshots](docs/screenshots/) for examples from the migrations.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### ✨ Current Tools
|
||||
@@ -30,7 +44,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
|
||||
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
|
||||
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images |
|
||||
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display |
|
||||
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts |
|
||||
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | Deprecated; retained for existing workflows | 🧠 Prompts |
|
||||
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
|
||||
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
|
||||
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
|
||||
@@ -40,6 +54,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
|
||||
| [📂 Local Image Loader](#-local-image-loader) | Visual gallery browser for local media files | 💾 Images |
|
||||
| [🌐 Model Downloader](#-model-downloader) | Download models from CivitAI, HuggingFace, and custom URLs | 🛠️ Utils |
|
||||
| [⏱️ Workflow Timer](#️-workflow-timer) | Real-time execution timer with customizable display | 🛠️ Utils |
|
||||
| [🗂️ Project File Path](#️-project-file-path) | Build safe, organized save prefixes like `MyProject/images/image` | 🛠️ Utils |
|
||||
|
||||
### 🧰 xyz-helpers Tools
|
||||
|
||||
@@ -49,6 +64,8 @@ Advanced parameter management tools adapted from [comfyui-essentials-nodes](http
|
||||
|------|-------------|----------|
|
||||
| [🎛️ Flux Sampler Params](#️-flux-sampler-params) | FLUX-optimized parameter generator with batch support | 🧰 xyz-helpers |
|
||||
| [📁 LoRA Folder Batch](#-lora-folder-batch) | Batch process multiple LoRAs from folders | 🧰 xyz-helpers |
|
||||
| [🔁 Checkpoint Folder Iterator](#-checkpoint-folder-iterator) | Load one checkpoint per queued run from a folder | 🧰 xyz-helpers |
|
||||
| [🧩 Model Compare Sheet](#-model-compare-sheet) | Save each run's image and rebuild a labeled contact sheet | 🧰 xyz-helpers |
|
||||
| [📊 Plot Parameters](#-plot-parameters) | Visualize parameter effects with graphs | 🧰 xyz-helpers |
|
||||
| [🎯 Sampler Select Helper](#-sampler-select-helper) | Intelligent sampler selection with recommendations | 🧰 xyz-helpers |
|
||||
| [📅 Scheduler Select Helper](#-scheduler-select-helper) | Optimal scheduler selection for samplers | 🧰 xyz-helpers |
|
||||
@@ -173,28 +190,17 @@ Advanced text display node with intelligent formatting and enhanced user interac
|
||||

|
||||
|
||||
#### 🤖 Gemini Prompt Engineer
|
||||
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
|
||||
|
||||
- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation
|
||||
- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details
|
||||
- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions
|
||||
- **Custom System Prompts**: Override templates with your own analysis instructions
|
||||
- **Flexible API Key Management**: Environment variable, config file, or direct input
|
||||
- **Visual Status Feedback**: Real-time processing indicators and error states
|
||||
- **Help Integration**: Built-in setup guide and documentation
|
||||
- **Dynamic Model Refresh**: Fetch latest Gemini models with refresh button
|
||||
- **Model Caching**: Persistent model list storage for offline access
|
||||
- **Enhanced SDXL Prompts**: Improved formatting with layered structure and quality boosters
|
||||
**Deprecated — retained for existing workflows only.** ComfyUI hides this node
|
||||
from normal new-node discovery using its deprecated-node metadata. The
|
||||
`GeminiPrompt` identifier, inputs, outputs and execution remain available so
|
||||
saved workflows can still load.
|
||||
|
||||
**Use Cases:**
|
||||
- Reverse-engineer prompts from reference images
|
||||
- Convert artistic descriptions between different AI model formats
|
||||
- Generate consistent style descriptions across workflows
|
||||
- Create detailed scene breakdowns for complex compositions
|
||||
- Analyze and replicate lighting/mood from existing artwork
|
||||
- Access latest Gemini models including 2.0 and 2.5 versions
|
||||
|
||||

|
||||
Google API/model compatibility updates and UI migration have ended. Existing
|
||||
workflows still depend on Google's service and the optional `google-genai`
|
||||
package; deprecation does not guarantee future API compatibility. See the
|
||||
[legacy Gemini documentation](examples/documentation/gemini_prompt.md) for the
|
||||
existing configuration and behavior.
|
||||
|
||||
#### 🔍 Display Any
|
||||
Universal debugging node that displays any type of input value or tensor information.
|
||||
@@ -300,6 +306,28 @@ Automated batch processing for multiple LoRA models from folders.
|
||||
|
||||

|
||||
|
||||
#### 🔁 Checkpoint Folder Iterator
|
||||
Run the same prompt through every checkpoint in a folder, one model per queued run.
|
||||
|
||||
- **folder**: relative to `models/checkpoints`, or an absolute path anywhere on disk; regex include/exclude filters
|
||||
- **index** with `control_after_generate`: set 0 + *increment*, then queue a batch count equal to the checkpoint count
|
||||
- Queuing past the last checkpoint fails that run with a clear message instead of wrapping around, so the finished sweep is never restarted or archived by accident
|
||||
- Outputs MODEL/CLIP/VAE for the indexed file plus `ckpt_name`, `label`, `index`, `count`
|
||||
- Only one checkpoint is loaded at a time, so large folders do not exhaust RAM
|
||||
|
||||
#### 🧩 Model Compare Sheet
|
||||
Output node that pairs with Checkpoint Folder Iterator.
|
||||
|
||||
- Saves each run's image(s) to `output/model_compare/<run_name>/<NN>_<label>.png`
|
||||
- Rebuilds `grid.png` from every tile saved so far, each labeled with its filename
|
||||
- `columns`, `tile_width`, `font_size` control the layout; the grid is also returned as IMAGE
|
||||
- The node preview (and anything else that reads it) gets `grid_preview.png`, at most 1024 px wide; `grid.png` keeps full resolution
|
||||
- After the last queued run the preview shows the complete sheet
|
||||
- Wire Project File Path into `run_name` for `project/date/sweep` folders
|
||||
- A run at index 0 moves tiles already in the folder to `<run_name>_<timestamp>`, so an interrupted sweep never leaks into the new grid
|
||||
- Rerunning a nonzero index replaces its complete batch, even when the label or batch size changes; other indexes are preserved
|
||||
- Replacement PNGs are fully written before existing tiles are changed; a failed tile write preserves the prior tiles and grid
|
||||
|
||||
#### 📊 Plot Parameters
|
||||
Visual analysis tool for understanding parameter relationships and effects.
|
||||
|
||||
@@ -404,8 +432,6 @@ Download models, LoRAs, and other assets directly from CivitAI, HuggingFace, and
|
||||
- Build reproducible workflows with automatic model fetching
|
||||
- Quickly test new models from the community
|
||||
|
||||

|
||||
|
||||
#### ⏱️ Workflow Timer
|
||||
Real-time execution timer that displays workflow duration with millisecond precision.
|
||||
|
||||
@@ -426,6 +452,42 @@ 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`.
|
||||
|
||||
- **Structure**: `root/project_name/[YYYY-MM-DD/]subfolder/filename`, e.g. `MyProject/2026-10-13/images/image`
|
||||
- **Date Folder**: Optional toggle inserts today's date after the project; the node re-runs when the date changes
|
||||
- **Safe Paths**: Unsafe characters are removed, spaces become underscores, and `..` segments are dropped so paths stay inside the output folder
|
||||
- **Nested Folders**: Use `a/b` in `subfolder` for deeper structure
|
||||
- **Extensions**: Known file extensions (`.png`, `.mp4`, ...) are stripped from `filename`; `render_v1.5` is left as is
|
||||
- **Separator**: `auto`, `forward_slash` or `backslash`
|
||||
|
||||
Based on the idea of the Project File Path Generator from djz-nodes.
|
||||
|
||||
### 🔤 Embedding Autocomplete
|
||||
|
||||
**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.**
|
||||
@@ -440,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
|
||||
@@ -455,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
|
||||
|
||||
@@ -526,7 +588,7 @@ Time: 150.0ms
|
||||
### 🔧 Architecture Highlights
|
||||
|
||||
- **Modular Design**: Each tool is self-contained and independently testable
|
||||
- **Test-Driven Development**: 100% test coverage with comprehensive unit tests
|
||||
- **Test-Driven Development**: Regression tests for Python logic, JavaScript extensions, and browser workflows
|
||||
- **Clean Interfaces**: Standardized input/output patterns across all tools
|
||||
- **Separation of Concerns**: Clear distinction between logic, UI, and integration layers
|
||||
- **SOLID Principles**: Extensible architecture following software engineering best practices
|
||||
@@ -642,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..."
|
||||
@@ -736,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) |
|
||||
@@ -748,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 |
|
||||
|
||||
@@ -905,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+
|
||||
|
||||
@@ -990,8 +1055,8 @@ Following **Test-Driven Development (TDD)**:
|
||||
# Test structure
|
||||
tests/
|
||||
├── unit/ # Individual component tests
|
||||
├── integration/ # ComfyUI workflow tests
|
||||
└── fixtures/ # Test data and workflows
|
||||
├── js/ # Extension and coverage-gate regression tests
|
||||
└── browser/ # Browser workflows and disposable host fixtures
|
||||
```
|
||||
|
||||
### Adding New Tools
|
||||
@@ -1043,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
|
||||
- **Test Coverage**: 100% (470+ comprehensive tests)
|
||||
- **Python Version**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
|
||||
- **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.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.
|
||||
|
||||
@@ -1080,3 +1152,8 @@ All adaptations maintain compatibility while adding new features and optimizatio
|
||||
[⭐ Star this repo](https://github.com/ComfyAssets/ComfyUI-KikoTools) • [🐛 Report Bug](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) • [💡 Request Feature](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues)
|
||||
|
||||
</div>
|
||||
|
||||
## Automated testing
|
||||
|
||||
See [TESTING.md](TESTING.md) for Python and JavaScript tests, cross-browser
|
||||
workflows, coverage gates, CI reports, and required merge checks.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Testing and merge confidence
|
||||
|
||||
Use regression tests before fixing a bug. Confirm that the test fails on the
|
||||
original implementation, then run the relevant suites after the fix.
|
||||
|
||||
## What runs
|
||||
|
||||
| Layer | Real components | Replaced boundaries |
|
||||
| --- | --- | --- |
|
||||
| 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 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,
|
||||
denied paths and network recovery, display output, seed persistence/cleanup, and
|
||||
workflow timing, autocomplete insertion, and saved-image previews. They run in Chromium, Firefox, WebKit, and touch Chromium.
|
||||
The harness binds to localhost and writes configuration, selections, and fixture
|
||||
images only inside a temporary directory. External browser requests fail tests;
|
||||
custom-word downloads are disabled in the fixture. No credentials, model
|
||||
weights, running ComfyUI, or GPU are required.
|
||||
|
||||
## Run locally
|
||||
|
||||
Use Python 3.10–3.13 and Node.js 24. Install the CPU PyTorch wheel on Linux if you
|
||||
do not need CUDA, then install the development dependencies:
|
||||
|
||||
```sh
|
||||
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
python -m pip install -r requirements-dev.txt
|
||||
python -m pytest tests
|
||||
python -m pytest tests --cov=kikotools --cov-report=term-missing
|
||||
npm ci --prefix tools/testing --ignore-scripts
|
||||
npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
|
||||
npm test --prefix tools/testing
|
||||
npm run --prefix tools/testing test:coverage
|
||||
npm run --prefix tools/testing test:browser -- --project firefox --project webkit
|
||||
```
|
||||
|
||||
`make test` runs the full pytest suite and propagates failures. `make coverage`,
|
||||
`make frontend-test`, `make browser-test`, and `make frontend-coverage` provide
|
||||
shortcuts. The Python Make targets use the existing virtual-environment setup.
|
||||
Install local commit and push hooks with `pre-commit install --hook-type
|
||||
pre-commit --hook-type pre-push`. The push hooks run Python and JavaScript
|
||||
regressions; CI additionally runs all browser workflows and coverage gates.
|
||||
For a quick browser check, use `test:browser -- --project chromium`.
|
||||
`KIKO_TEST_PYTHON` selects a Python executable with the development dependencies;
|
||||
`KIKO_TEST_PORT` changes the default port 8766. Servers are never reused.
|
||||
Unsupported Linux distributions may lack WebKit libraries; Ubuntu CI validates
|
||||
all engines.
|
||||
|
||||
## Coverage and diagnosis
|
||||
|
||||
Python CI checks all twelve Python/OS combinations and enforces 85% combined
|
||||
statement/branch coverage. Every production `web/*.js` file appears in frontend
|
||||
coverage, including code without behavioral coverage. V8 unit coverage and
|
||||
Chromium desktop/touch coverage are merged through c8. The committed per-file
|
||||
floors are an honest starting point, not a claim that every feature is tested.
|
||||
|
||||
`npm run --prefix tools/testing coverage:update` reruns the suites and raises
|
||||
floors with two percentage points of runtime allowance. It cannot lower existing
|
||||
floors; new source files require 80% line coverage when added with this command.
|
||||
Missing measurements, missing baselines, and per-file regressions fail CI.
|
||||
|
||||
CI artifacts include Python JUnit results, Python HTML/XML coverage, and
|
||||
`frontend-test-reports` with frontend HTML coverage and browser failure traces
|
||||
and screenshots. Open traces using Playwright's `show-trace` command. Local
|
||||
frontend coverage is at `coverage/js/index.html`.
|
||||
|
||||
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.
|
||||
|
||||
## Pinned real-ComfyUI compatibility
|
||||
|
||||
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_ROOT=/path/to/pinned/ComfyUI \
|
||||
KIKO_TEST_PYTHON=/path/to/cpu-venv/bin/python \
|
||||
npm run --prefix tools/testing test:comfyui
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### 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, 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.
|
||||
|
||||
After the checks pass, an administrator applies and verifies the manifest:
|
||||
|
||||
```sh
|
||||
gh api --method PUT repos/ComfyAssets/ComfyUI-KikoTools/branches/main/protection \
|
||||
--input .github/branch-protection.json
|
||||
gh api repos/ComfyAssets/ComfyUI-KikoTools/branches/main/protection/required_status_checks
|
||||
```
|
||||
|
||||
Keep the manifest synchronized with job names when changing CI.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# KikoTools UI migration
|
||||
|
||||
KikoTools' custom controls use the original ComfyAssets foundation in `web/ui/`.
|
||||
They share blue primary actions, red destructive actions, keyboard controls and
|
||||
ComfyUI-following dark/light colors. Layouts resize with the available space.
|
||||
Pixaroma's toolkit inspired the organization; its code/assets are not vendored.
|
||||
|
||||
| Interface | Shared implementation | Retained examples |
|
||||
| --- | --- | --- |
|
||||
| Local Image Loader | `views/image-browser.js`, controls and scoped controller | [Loader](screenshots/live-image-loader/) |
|
||||
| Save Image | Floating window, batch selection and responsive preview | [Viewer](screenshots/save-image-viewer/) |
|
||||
| Seed History | `views/seed-history.js`, scoped watcher and disclosure | [History](screenshots/seed-history/) |
|
||||
| Workflow Timer | Shared clock, controls and color picker | [Timer](screenshots/workflow-timer/) |
|
||||
| Width Height / Empty Latent swaps | `ui/dimension-swap.js`, `ui/dimensions.js` | [Swaps](screenshots/dimension-swap/) |
|
||||
| Node Dimensions | `views/node-dimensions.js`, native dialog shell | [Dimensions](screenshots/node-dimensions/) |
|
||||
| Display Text / Any | Shared output view, controller and widget adapter | [Output](screenshots/display-output/) |
|
||||
| Embedding Autocomplete | Shared suggestions/settings, worker parsing and cache | [Autocomplete](screenshots/embedding-autocomplete/) |
|
||||
| Custom Colors | Shared color picker, native node/group menus | [Colors](screenshots/custom-colors/) |
|
||||
| Follow Execution | Native host menus/settings, idempotent execution listeners | [Menus](screenshots/follow-execution/) |
|
||||
|
||||
`createScope()` owns listeners, observers and delayed work. Node removal preserves
|
||||
previous lifecycle hooks while disposing owned resources. Host-specific text
|
||||
widget handling lives in `ui/text-widget-adapter.js`; floating windows and native
|
||||
dialogs manage their own focus and cleanup. See [module notes](../web/ui/README.md).
|
||||
|
||||
Pure Python nodes continue using ComfyUI's native widgets. Gemini Prompt
|
||||
Engineer retains its legacy UI for saved workflows under the agreed deprecation;
|
||||
its UI migration was cancelled.
|
||||
|
||||
The [test guide](../TESTING.md) distinguishes fixture coverage, real-host
|
||||
compatibility and model/GPU behavior. Required real-host CI uses a reviewed
|
||||
upstream commit, fresh Classic/Nodes 2.0 starts and disposable CPU-only state.
|
||||
|
||||
## Remaining shared-toolset work
|
||||
|
||||
Before PromptManager adopts the foundation, extract a versioned shared source
|
||||
package with reproducible browser artifacts. Then test both node packs alone and
|
||||
together, including different bundled foundation versions, isolated settings
|
||||
and identical styling. These requirements remain open in issue #81; the KikoTools
|
||||
migration does not establish cross-pack compatibility by itself.
|
||||
|
||||
The approved design-preview draft [#82](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
|
||||
records the original direction. This inventory describes the shipped interfaces
|
||||
and supersedes that draft's implementation sequence.
|
||||
@@ -1,4 +1,15 @@
|
||||
# Gemini Prompt Engineer
|
||||
# Gemini Prompt Engineer (Deprecated)
|
||||
|
||||
**Legacy documentation for existing workflows.** This node is soft-deprecated
|
||||
and hidden from normal new-node discovery through ComfyUI's deprecated-node
|
||||
metadata. Users who enable deprecated-node visibility may still find it.
|
||||
|
||||
The `GeminiPrompt` registration, display name, input/output schema and execution
|
||||
remain available for saved workflows. Existing configuration and model-refresh
|
||||
behavior remain in place. Google API/model compatibility updates and UI migration
|
||||
have ended; the historical model and setup details below may no longer match
|
||||
Google's current service. No replacement or automatic workflow conversion is
|
||||
provided.
|
||||
|
||||
The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models.
|
||||
|
||||
@@ -21,7 +32,7 @@ Get your free Gemini API key from [Google AI Studio](https://makersuite.google.c
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install google-generativeai
|
||||
pip install google-genai
|
||||
```
|
||||
|
||||
### 3. Configure API Key
|
||||
@@ -204,6 +215,6 @@ Errors are displayed in the prompt output for easy debugging.
|
||||
- Verify API key is valid
|
||||
- Image might be too large (resize if needed)
|
||||
|
||||
**Import error for google-generativeai**:
|
||||
- Run `pip install google-generativeai` in your ComfyUI environment
|
||||
**Import error for google-genai**:
|
||||
- Run `pip install google-genai` in your ComfyUI environment
|
||||
- Restart ComfyUI after installation
|
||||
|
||||
@@ -10,6 +10,7 @@ from .tools.batch_list_converter import (
|
||||
LatentListToLatentBatchNode,
|
||||
)
|
||||
from .tools.batch_prompts import BatchPromptsNode
|
||||
from .tools.checkpoint_folder_iterator import CheckpointFolderIteratorNode
|
||||
from .tools.display_any import DisplayAnyNode
|
||||
from .tools.display_text import DisplayTextNode
|
||||
from .tools.embedding_autocomplete import KikoEmbeddingAutocomplete
|
||||
@@ -22,6 +23,8 @@ from .tools.kiko_purge_vram import KikoPurgeVRAM
|
||||
from .tools.kiko_workflow_timer import KikoWorkflowTimerNode
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.local_image_loader import LocalImageLoaderNode
|
||||
from .tools.project_file_path import ProjectFilePathNode
|
||||
from .tools.model_compare_sheet import ModelCompareSheetNode
|
||||
from .tools.model_downloader import ModelDownloaderNode
|
||||
from .tools.resolution_calculator import ResolutionCalculatorNode
|
||||
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
|
||||
@@ -68,8 +71,11 @@ NODE_CLASS_MAPPINGS = {
|
||||
"FluxSamplerParams": FluxSamplerParamsNode,
|
||||
"PlotParameters+": PlotParametersNode,
|
||||
"LoRAFolderBatch": LoRAFolderBatchNode,
|
||||
"CheckpointFolderIterator": CheckpointFolderIteratorNode,
|
||||
"ModelCompareSheet": ModelCompareSheetNode,
|
||||
"WidthHeightToVec2": WidthHeightToVec2Node,
|
||||
"KikoWorkflowTimer": KikoWorkflowTimerNode,
|
||||
"KikoProjectFilePath": ProjectFilePathNode,
|
||||
# Note: KikoEmbeddingAutocomplete is not registered as a node
|
||||
# It's a settings-only feature accessed through ComfyUI settings menu
|
||||
}
|
||||
@@ -103,8 +109,11 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FluxSamplerParams": "Flux Sampler Parameters",
|
||||
"PlotParameters+": "Plot Parameters",
|
||||
"LoRAFolderBatch": "LoRA Folder Batch",
|
||||
"CheckpointFolderIterator": "Checkpoint Folder Iterator",
|
||||
"ModelCompareSheet": "Model Compare Sheet",
|
||||
"WidthHeightToVec2": "Width Height to VEC2",
|
||||
"KikoWorkflowTimer": "Workflow Timer",
|
||||
"KikoProjectFilePath": "Project File Path",
|
||||
# KikoEmbeddingAutocomplete removed - settings only, not a node
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Scan ComfyUI model folders (loras, checkpoints, ...) for .safetensors files."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_model_roots(folder_key: str) -> List[str]:
|
||||
"""Return every configured root directory for a folder_paths key."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
entry = folder_paths.folder_names_and_paths.get(folder_key)
|
||||
return list(entry[0]) if entry else []
|
||||
except (ImportError, AttributeError, IndexError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def is_within(path: str, root: str) -> bool:
|
||||
"""True if realpath `path` equals or is inside realpath `root`."""
|
||||
try:
|
||||
real_path, real_root = os.path.realpath(path), os.path.realpath(root)
|
||||
return os.path.commonpath([real_path, real_root]) == real_root
|
||||
except ValueError: # different drives or mixed absolute/relative
|
||||
return False
|
||||
|
||||
|
||||
def resolve_scan_targets(
|
||||
folder_path: str, folder_key: str
|
||||
) -> List[Tuple[str, Optional[str]]]:
|
||||
"""
|
||||
Resolve a folder setting to (directory, base_root) pairs to scan.
|
||||
|
||||
base_root is the model root the directory lives in, or None when the
|
||||
directory is an absolute path outside every known root.
|
||||
"""
|
||||
roots = get_model_roots(folder_key)
|
||||
|
||||
if os.path.isabs(folder_path):
|
||||
base = next((r for r in roots if is_within(folder_path, r)), None)
|
||||
return [(folder_path, base)]
|
||||
|
||||
targets = []
|
||||
for root in roots:
|
||||
candidate = os.path.join(root, folder_path)
|
||||
if not is_within(candidate, root):
|
||||
logger.warning(f"Folder escapes the {folder_key} directory: {folder_path}")
|
||||
continue
|
||||
targets.append((candidate, root))
|
||||
return targets
|
||||
|
||||
|
||||
def scan_model_entries(folder_path: str, folder_key: str) -> List[Tuple[str, str]]:
|
||||
"""Return naturally sorted (relative_name, absolute_path) for every .safetensors."""
|
||||
entries: Dict[str, str] = {}
|
||||
for directory, base in resolve_scan_targets(folder_path, folder_key):
|
||||
if not os.path.isdir(directory):
|
||||
continue
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
if not file.endswith(".safetensors"):
|
||||
continue
|
||||
full = os.path.join(root, file)
|
||||
try:
|
||||
rel = os.path.relpath(full, base or directory)
|
||||
except ValueError:
|
||||
rel = os.path.relpath(full, directory)
|
||||
entries.setdefault(rel.replace("\\", "/"), full)
|
||||
return [(name, entries[name]) for name in natural_sort(list(entries))]
|
||||
|
||||
|
||||
def natural_sort(items: List[str]) -> List[str]:
|
||||
"""Sort strings so embedded numbers compare numerically (v2 < v10)."""
|
||||
|
||||
def natural_key(text):
|
||||
parts = []
|
||||
for part in re.split(r"(\d+)", text):
|
||||
if not part:
|
||||
continue
|
||||
parts.append((0, int(part)) if part.isdigit() else (1, part.lower()))
|
||||
return parts
|
||||
|
||||
return sorted(items, key=natural_key)
|
||||
|
||||
|
||||
def filter_by_pattern(
|
||||
names: List[str], include_pattern: str = "", exclude_pattern: str = ""
|
||||
) -> List[str]:
|
||||
"""Keep names matching include_pattern (if set) and not exclude_pattern (if set)."""
|
||||
filtered = list(names)
|
||||
if include_pattern:
|
||||
try:
|
||||
include_re = re.compile(include_pattern)
|
||||
filtered = [f for f in filtered if include_re.search(f)]
|
||||
except re.error as e:
|
||||
logger.error(f"Invalid include pattern: {e}")
|
||||
if exclude_pattern:
|
||||
try:
|
||||
exclude_re = re.compile(exclude_pattern)
|
||||
filtered = [f for f in filtered if not exclude_re.search(f)]
|
||||
except re.error as e:
|
||||
logger.error(f"Invalid exclude pattern: {e}")
|
||||
return filtered
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Checkpoint Folder Iterator module."""
|
||||
|
||||
from .node import CheckpointFolderIteratorNode
|
||||
|
||||
__all__ = ["CheckpointFolderIteratorNode"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Logic for the Checkpoint Folder Iterator node."""
|
||||
|
||||
import os
|
||||
from typing import List, Sequence, Tuple, TypeVar
|
||||
|
||||
from ...base.model_folders import filter_by_pattern, scan_model_entries
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def list_checkpoint_entries(
|
||||
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""(name, absolute_path) pairs for every checkpoint, filtered, in natural order.
|
||||
|
||||
The absolute path is what gets loaded, so folders outside the configured
|
||||
checkpoints roots work too; the name is relative to the root (or to the
|
||||
folder itself when it lives outside every root) and is used for labels.
|
||||
"""
|
||||
entries = scan_model_entries(folder_path, "checkpoints")
|
||||
names = [name for name, _ in entries]
|
||||
kept = set(filter_by_pattern(names, include_pattern, exclude_pattern))
|
||||
return [(name, path) for name, path in entries if name in kept]
|
||||
|
||||
|
||||
def list_checkpoints(
|
||||
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
|
||||
) -> List[str]:
|
||||
"""Checkpoint names (relative to the checkpoints roots) in natural order."""
|
||||
entries = list_checkpoint_entries(folder_path, include_pattern, exclude_pattern)
|
||||
return [name for name, _ in entries]
|
||||
|
||||
|
||||
def pick_checkpoint(items: Sequence[T], index: int) -> T:
|
||||
"""Return items[index], or raise when the sweep has run past the end.
|
||||
|
||||
Wrapping would silently restart the sweep (and, downstream, archive the
|
||||
grid that just finished), so an over-queued run fails loudly instead.
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError("No checkpoints found")
|
||||
if not 0 <= index < len(items):
|
||||
raise ValueError(
|
||||
f"Index {index} is past the end: only {len(items)} checkpoints matched. "
|
||||
"The sweep is complete; reset index to 0 to start a new one."
|
||||
)
|
||||
return items[index]
|
||||
|
||||
|
||||
def label_for(ckpt_name: str) -> str:
|
||||
"""Label drawn under a tile for this checkpoint. Default: the file stem."""
|
||||
return os.path.splitext(os.path.basename(ckpt_name))[0]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Checkpoint Folder Iterator node for ComfyUI."""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import label_for, list_checkpoint_entries, pick_checkpoint
|
||||
|
||||
|
||||
class CheckpointFolderIteratorNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Load one checkpoint from a folder per queued run.
|
||||
|
||||
Set index to 0 with "increment" and queue as many runs as there are
|
||||
checkpoints: each run loads the next file, so only one model is in
|
||||
memory at a time. A run past the last checkpoint fails with a clear
|
||||
error rather than wrapping around, so over-queuing never restarts the
|
||||
sweep. Pairs with Model Compare Sheet to build a labeled grid.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"folder": (
|
||||
"STRING",
|
||||
{
|
||||
"default": ".",
|
||||
"multiline": False,
|
||||
"tooltip": "Folder relative to models/checkpoints (or absolute)",
|
||||
},
|
||||
),
|
||||
"index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 9999,
|
||||
"control_after_generate": True,
|
||||
"tooltip": "Which checkpoint to load; errors past the end",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"include_pattern": (
|
||||
"STRING",
|
||||
{"default": "", "tooltip": "Regex to include (empty = all)"},
|
||||
),
|
||||
"exclude_pattern": (
|
||||
"STRING",
|
||||
{"default": "", "tooltip": "Regex to exclude (empty = none)"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING", "STRING", "INT", "INT")
|
||||
RETURN_NAMES = ("model", "clip", "vae", "ckpt_name", "label", "index", "count")
|
||||
FUNCTION = "load"
|
||||
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def load(
|
||||
self,
|
||||
folder: str,
|
||||
index: int,
|
||||
include_pattern: str = "",
|
||||
exclude_pattern: str = "",
|
||||
) -> Tuple[object, object, object, str, str, int, int]:
|
||||
import folder_paths
|
||||
import comfy.sd
|
||||
|
||||
entries = list_checkpoint_entries(folder, include_pattern, exclude_pattern)
|
||||
ckpt_name, ckpt_path = pick_checkpoint(entries, index)
|
||||
self.log_info(f"Loading {index + 1}/{len(entries)}: {ckpt_name}")
|
||||
|
||||
model, clip, vae = comfy.sd.load_checkpoint_guess_config(
|
||||
ckpt_path,
|
||||
output_vae=True,
|
||||
output_clip=True,
|
||||
embedding_directory=folder_paths.get_folder_paths("embeddings"),
|
||||
)[:3]
|
||||
return model, clip, vae, ckpt_name, label_for(ckpt_name), index, len(entries)
|
||||
@@ -1,89 +0,0 @@
|
||||
{
|
||||
"models": [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-pro-preview-03-25",
|
||||
"gemini-2.5-flash-preview-05-20",
|
||||
"gemini-2.5-pro-preview-05-06",
|
||||
"gemini-2.5-pro-preview-06-05",
|
||||
"gemini-2.5-flash-lite-preview-06-17",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-001",
|
||||
"gemini-2.0-flash-lite-001",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-2.5-flash-preview-tts",
|
||||
"gemini-2.5-pro-preview-tts",
|
||||
"gemini-2.0-flash-preview-image-generation",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.0-flash-exp-image-generation",
|
||||
"gemini-2.0-flash-lite-preview-02-05",
|
||||
"gemini-2.0-flash-lite-preview",
|
||||
"gemini-2.0-pro-exp",
|
||||
"gemini-2.0-pro-exp-02-05",
|
||||
"learnlm-2.0-flash-experimental",
|
||||
"gemini-1.5-pro-latest",
|
||||
"gemini-1.5-pro-002",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash-latest",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-002",
|
||||
"gemini-1.5-flash-8b",
|
||||
"gemini-1.5-flash-8b-001",
|
||||
"gemini-1.5-flash-8b-latest",
|
||||
"gemini-2.0-flash-thinking-exp-01-21",
|
||||
"gemini-2.0-flash-thinking-exp",
|
||||
"gemini-2.0-flash-thinking-exp-1219",
|
||||
"gemma-3-1b-it",
|
||||
"gemma-3-4b-it",
|
||||
"gemma-3-12b-it",
|
||||
"gemma-3-27b-it",
|
||||
"gemma-3n-e4b-it",
|
||||
"gemma-3n-e2b-it",
|
||||
"gemini-exp-1206"
|
||||
],
|
||||
"descriptions": {
|
||||
"gemini-1.5-pro-latest": "Gemini 1.5 Pro Latest",
|
||||
"gemini-1.5-pro-002": "Gemini 1.5 Pro 002",
|
||||
"gemini-1.5-pro": "Gemini 1.5 Pro",
|
||||
"gemini-1.5-flash-latest": "Gemini 1.5 Flash Latest",
|
||||
"gemini-1.5-flash": "Gemini 1.5 Flash",
|
||||
"gemini-1.5-flash-002": "Gemini 1.5 Flash 002",
|
||||
"gemini-1.5-flash-8b": "Gemini 1.5 Flash-8B",
|
||||
"gemini-1.5-flash-8b-001": "Gemini 1.5 Flash-8B 001",
|
||||
"gemini-1.5-flash-8b-latest": "Gemini 1.5 Flash-8B Latest",
|
||||
"gemini-2.5-pro-preview-03-25": "Gemini 2.5 Pro Preview 03-25",
|
||||
"gemini-2.5-flash-preview-05-20": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.5-flash": "Gemini 2.5 Flash",
|
||||
"gemini-2.5-flash-lite-preview-06-17": "Gemini 2.5 Flash-Lite Preview 06-17",
|
||||
"gemini-2.5-pro-preview-05-06": "Gemini 2.5 Pro Preview 05-06",
|
||||
"gemini-2.5-pro-preview-06-05": "Gemini 2.5 Pro Preview",
|
||||
"gemini-2.5-pro": "Gemini 2.5 Pro",
|
||||
"gemini-2.0-flash-exp": "Gemini 2.0 Flash Experimental",
|
||||
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
||||
"gemini-2.0-flash-001": "Gemini 2.0 Flash 001",
|
||||
"gemini-2.0-flash-exp-image-generation": "Gemini 2.0 Flash (Image Generation) Experimental",
|
||||
"gemini-2.0-flash-lite-001": "Gemini 2.0 Flash-Lite 001",
|
||||
"gemini-2.0-flash-lite": "Gemini 2.0 Flash-Lite",
|
||||
"gemini-2.0-flash-preview-image-generation": "Gemini 2.0 Flash Preview Image Generation",
|
||||
"gemini-2.0-flash-lite-preview-02-05": "Gemini 2.0 Flash-Lite Preview 02-05",
|
||||
"gemini-2.0-flash-lite-preview": "Gemini 2.0 Flash-Lite Preview",
|
||||
"gemini-2.0-pro-exp": "Gemini 2.0 Pro Experimental",
|
||||
"gemini-2.0-pro-exp-02-05": "Gemini 2.0 Pro Experimental 02-05",
|
||||
"gemini-exp-1206": "Gemini Experimental 1206",
|
||||
"gemini-2.0-flash-thinking-exp-01-21": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.0-flash-thinking-exp": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.0-flash-thinking-exp-1219": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.5-flash-preview-tts": "Gemini 2.5 Flash Preview TTS",
|
||||
"gemini-2.5-pro-preview-tts": "Gemini 2.5 Pro Preview TTS",
|
||||
"learnlm-2.0-flash-experimental": "LearnLM 2.0 Flash Experimental",
|
||||
"gemma-3-1b-it": "Gemma 3 1B",
|
||||
"gemma-3-4b-it": "Gemma 3 4B",
|
||||
"gemma-3-12b-it": "Gemma 3 12B",
|
||||
"gemma-3-27b-it": "Gemma 3 27B",
|
||||
"gemma-3n-e4b-it": "Gemma 3n E4B",
|
||||
"gemma-3n-e2b-it": "Gemma 3n E2B",
|
||||
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
|
||||
},
|
||||
"timestamp": 1754568195.1098156
|
||||
}
|
||||
@@ -115,30 +115,24 @@ def analyze_image_with_gemini(
|
||||
else:
|
||||
system_prompt = PROMPT_TEMPLATES.get(prompt_type, PROMPT_TEMPLATES["flux"])
|
||||
|
||||
# Here we would normally make the API call to Gemini
|
||||
# For now, we'll import the google-generativeai library
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
from google import genai
|
||||
except ImportError:
|
||||
return (
|
||||
"",
|
||||
"google-generativeai library not installed. Please run: pip install google-generativeai",
|
||||
"google-genai library not installed. Please run: pip install google-genai",
|
||||
)
|
||||
|
||||
try:
|
||||
# Configure Gemini
|
||||
genai.configure(api_key=api_key)
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# Create model
|
||||
model = genai.GenerativeModel(model_name)
|
||||
|
||||
# Generate content
|
||||
response = model.generate_content(
|
||||
[
|
||||
response = client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=[
|
||||
system_prompt,
|
||||
pil_image,
|
||||
"Analyze this image and generate an appropriate prompt according to the instructions.",
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
# Extract text from response
|
||||
|
||||
@@ -59,10 +59,10 @@ def _fetch_models_from_api(
|
||||
Tuple of (model_names_list, model_descriptions_dict)
|
||||
"""
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
from google import genai
|
||||
except ImportError:
|
||||
if not silent:
|
||||
logger.error("google-generativeai not installed")
|
||||
logger.error("google-genai not installed")
|
||||
return [], {}
|
||||
|
||||
# Get API key
|
||||
@@ -77,15 +77,15 @@ def _fetch_models_from_api(
|
||||
return [], {}
|
||||
|
||||
try:
|
||||
genai.configure(api_key=api_key)
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
models = []
|
||||
descriptions = {}
|
||||
|
||||
# Fetch all models
|
||||
for model in genai.list_models():
|
||||
for model in client.models.list():
|
||||
# Only include models that support generateContent
|
||||
if "generateContent" in model.supported_generation_methods:
|
||||
if "generateContent" in (model.supported_actions or []):
|
||||
# Remove "models/" prefix from name
|
||||
model_name = model.name.replace("models/", "")
|
||||
models.append(model_name)
|
||||
|
||||
@@ -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:
|
||||
@@ -63,7 +68,7 @@ Supports multiple prompt formats:
|
||||
- Video: Motion and temporal descriptions for video generation
|
||||
|
||||
Requires Gemini API key (set GEMINI_API_KEY env var or provide in node).
|
||||
Install: pip install google-generativeai
|
||||
Install: pip install google-genai
|
||||
"""
|
||||
|
||||
def generate_prompt(
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids",
|
||||
"saved_paths": [
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/",
|
||||
"/home/vito/Downloads/vito",
|
||||
"/home/vito/ai-apps/ComfyUI/output/2025-06-08",
|
||||
"/home/vito/ai-apps/ComfyUI/output",
|
||||
"/home/vito/Downloads",
|
||||
"/home/vito/Downloads/images"
|
||||
]
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"57": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01/HiDream_00001_.png"
|
||||
}
|
||||
},
|
||||
"58": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/CharacterName_00016_.png"
|
||||
}
|
||||
},
|
||||
"18": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
},
|
||||
"445": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
|
||||
}
|
||||
},
|
||||
"23": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00098_.png"
|
||||
}
|
||||
},
|
||||
"69": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/KikoSave_00086.png"
|
||||
}
|
||||
},
|
||||
"52": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/kiko/XXX/images/kiko_00038_.png"
|
||||
}
|
||||
},
|
||||
"38": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/vito/IMG_20160422_163419.jpg"
|
||||
}
|
||||
},
|
||||
"170": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00326_.png"
|
||||
}
|
||||
},
|
||||
"214": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00329_.png"
|
||||
}
|
||||
},
|
||||
"299": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/images/KikoSave_00016.png"
|
||||
}
|
||||
},
|
||||
"527": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/ComfyUI_temp_sktzg_00012_.png"
|
||||
}
|
||||
},
|
||||
"522": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/KikoSave_00086.png"
|
||||
}
|
||||
},
|
||||
"517": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
},
|
||||
"144": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png"
|
||||
}
|
||||
},
|
||||
"569": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00008.png"
|
||||
}
|
||||
},
|
||||
"136": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Model Compare Sheet module."""
|
||||
|
||||
from .node import ModelCompareSheetNode
|
||||
|
||||
__all__ = ["ModelCompareSheetNode"]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Logic for the Model Compare Sheet node: tile files on disk into a labeled grid."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from ...base.model_folders import is_within, natural_sort
|
||||
|
||||
GRID_FILENAME = "grid.png"
|
||||
PREVIEW_FILENAME = "grid_preview.png"
|
||||
PREVIEW_WIDTH = 1024
|
||||
BACKGROUND = (28, 28, 28)
|
||||
LABEL_COLOR = (235, 235, 235)
|
||||
|
||||
|
||||
def resolve_run_dir(output_dir: str, run_name: str) -> str:
|
||||
"""Directory for one compare run, always strictly inside output/model_compare.
|
||||
|
||||
run_name may be nested ("project/date/sweep"). Every segment must be a plain
|
||||
name: no empty, "." or ".." parts, on either separator, and the resolved
|
||||
path must stay below model_compare even after symlinks are followed.
|
||||
"""
|
||||
parts = re.split(r"[\\/]", run_name)
|
||||
if not run_name or os.path.isabs(run_name) or ":" in run_name:
|
||||
raise ValueError(f"Invalid run_name: {run_name!r}")
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
raise ValueError(f"Invalid run_name: {run_name!r}")
|
||||
base = os.path.join(output_dir, "model_compare")
|
||||
run_dir = os.path.join(base, *parts)
|
||||
if not is_within(run_dir, base) or os.path.realpath(run_dir) == os.path.realpath(
|
||||
base
|
||||
):
|
||||
raise ValueError(f"Invalid run_name: {run_name!r}")
|
||||
return run_dir
|
||||
|
||||
|
||||
def tile_filename(index: int, label: str, batch_index: int, batch_size: int) -> str:
|
||||
"""'03_label.png', or '03_label_2.png' for the second image of a batch."""
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", label)
|
||||
suffix = f"_{batch_index + 1}" if batch_size > 1 else ""
|
||||
return f"{index:02d}_{safe}{suffix}.png"
|
||||
|
||||
|
||||
def label_from_filename(filename: str) -> str:
|
||||
stem = os.path.splitext(os.path.basename(filename))[0]
|
||||
return re.sub(r"^\d+_", "", stem)
|
||||
|
||||
|
||||
def list_tiles(run_dir: str) -> List[str]:
|
||||
"""PNG tiles in a run directory in natural order, excluding the grid itself."""
|
||||
names = [
|
||||
f
|
||||
for f in os.listdir(run_dir)
|
||||
if f.lower().endswith(".png")
|
||||
and f not in (GRID_FILENAME, PREVIEW_FILENAME)
|
||||
and os.path.isfile(os.path.join(run_dir, f))
|
||||
]
|
||||
return [os.path.join(run_dir, f) for f in natural_sort(names)]
|
||||
|
||||
|
||||
def replace_index_tiles(
|
||||
run_dir: str, index: int, staged_dir: str, names: List[str]
|
||||
) -> None:
|
||||
"""Publish one index's staged tiles, restoring old files on a rename failure."""
|
||||
previous_dir = os.path.join(staged_dir, "previous")
|
||||
os.mkdir(previous_dir)
|
||||
prefix = f"{index:02d}_"
|
||||
old_tiles = [
|
||||
path
|
||||
for path in list_tiles(run_dir)
|
||||
if os.path.basename(path).startswith(prefix)
|
||||
]
|
||||
moved: List[Tuple[str, str]] = []
|
||||
published: List[str] = []
|
||||
try:
|
||||
for path in old_tiles:
|
||||
backup = os.path.join(previous_dir, os.path.basename(path) + ".previous")
|
||||
os.replace(path, backup)
|
||||
moved.append((path, backup))
|
||||
for name in names:
|
||||
destination = os.path.join(run_dir, name)
|
||||
os.replace(os.path.join(staged_dir, name + ".tmp"), destination)
|
||||
published.append(destination)
|
||||
except OSError:
|
||||
for path in published:
|
||||
os.remove(path)
|
||||
for original, backup in reversed(moved):
|
||||
os.replace(backup, original)
|
||||
raise
|
||||
|
||||
|
||||
def archive_previous_sweep(run_dir: str) -> Optional[str]:
|
||||
"""Rename a run folder that already holds tiles to '<run_dir>_<timestamp>'.
|
||||
|
||||
Called when a sweep starts over (index 0) so stale tiles from an
|
||||
interrupted sweep never end up in the new grid. Returns the archive path,
|
||||
or None when there was nothing to archive.
|
||||
"""
|
||||
if not os.path.isdir(run_dir) or not list_tiles(run_dir):
|
||||
return None
|
||||
stamp = datetime.fromtimestamp(os.path.getmtime(run_dir)).strftime("%Y%m%d-%H%M%S")
|
||||
archive = f"{run_dir}_{stamp}"
|
||||
n = 1
|
||||
while os.path.exists(archive):
|
||||
n += 1
|
||||
archive = f"{run_dir}_{stamp}_{n}"
|
||||
os.rename(run_dir, archive)
|
||||
return archive
|
||||
|
||||
|
||||
def _font(font_size: int) -> ImageFont.ImageFont:
|
||||
try:
|
||||
return ImageFont.truetype("DejaVuSans.ttf", font_size)
|
||||
except OSError:
|
||||
return ImageFont.load_default(size=font_size)
|
||||
|
||||
|
||||
def _fit_label(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> str:
|
||||
while text and draw.textlength(text, font=font) > max_width:
|
||||
text = text[:-2] + "…" if len(text) > 1 else ""
|
||||
return text
|
||||
|
||||
|
||||
def build_contact_sheet(
|
||||
paths: List[str], columns: int, tile_width: int, font_size: int
|
||||
) -> Image.Image:
|
||||
"""Tile the images at `paths` into a grid with each file's label underneath."""
|
||||
tiles = []
|
||||
for path in paths:
|
||||
with Image.open(path) as im:
|
||||
im = im.convert("RGB")
|
||||
tile_height = max(1, round(im.height * tile_width / im.width))
|
||||
tiles.append((im.resize((tile_width, tile_height), Image.LANCZOS), path))
|
||||
|
||||
band = font_size * 2
|
||||
cell_height = max(t.height for t, _ in tiles) + band
|
||||
cols = min(len(tiles), columns)
|
||||
rows = math.ceil(len(tiles) / cols)
|
||||
sheet = Image.new("RGB", (cols * tile_width, rows * cell_height), BACKGROUND)
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = _font(font_size)
|
||||
|
||||
for i, (tile, path) in enumerate(tiles):
|
||||
x = (i % cols) * tile_width
|
||||
y = (i // cols) * cell_height
|
||||
sheet.paste(tile, (x, y))
|
||||
label = _fit_label(draw, label_from_filename(path), font, tile_width - 8)
|
||||
text_x = x + (tile_width - draw.textlength(label, font=font)) / 2
|
||||
text_y = y + tile.height + (band - font_size) / 2
|
||||
draw.text((text_x, text_y), label, fill=LABEL_COLOR, font=font)
|
||||
return sheet
|
||||
|
||||
|
||||
def preview_of(sheet: Image.Image) -> Image.Image:
|
||||
"""A copy of the sheet no wider than PREVIEW_WIDTH, for the node preview and galleries."""
|
||||
if sheet.width <= PREVIEW_WIDTH:
|
||||
return sheet
|
||||
height = max(1, round(sheet.height * PREVIEW_WIDTH / sheet.width))
|
||||
return sheet.resize((PREVIEW_WIDTH, height), Image.LANCZOS)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Model Compare Sheet node for ComfyUI."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
GRID_FILENAME,
|
||||
PREVIEW_FILENAME,
|
||||
archive_previous_sweep,
|
||||
build_contact_sheet,
|
||||
list_tiles,
|
||||
preview_of,
|
||||
replace_index_tiles,
|
||||
resolve_run_dir,
|
||||
tile_filename,
|
||||
)
|
||||
|
||||
|
||||
class ModelCompareSheetNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Save each run's image into output/model_compare/<run_name>/ and rebuild a
|
||||
labeled contact sheet from every tile saved so far. Pair with Checkpoint
|
||||
Folder Iterator: after the last queued run the grid holds every model.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"label": ("STRING", {"forceInput": True}),
|
||||
"index": ("INT", {"forceInput": True}),
|
||||
"run_name": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "illustrious",
|
||||
"tooltip": "Subfolder of output/model_compare for this run",
|
||||
},
|
||||
),
|
||||
"columns": ("INT", {"default": 5, "min": 1, "max": 32}),
|
||||
"tile_width": ("INT", {"default": 512, "min": 64, "max": 4096}),
|
||||
"font_size": ("INT", {"default": 28, "min": 8, "max": 256}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("grid",)
|
||||
FUNCTION = "collect"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def collect(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
label: str,
|
||||
index: int,
|
||||
run_name: str,
|
||||
columns: int,
|
||||
tile_width: int,
|
||||
font_size: int,
|
||||
prompt: Optional[Dict] = None,
|
||||
extra_pnginfo: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
import folder_paths
|
||||
|
||||
if images.shape[0] == 0:
|
||||
raise ValueError("images must contain at least one image")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
run_dir = resolve_run_dir(output_dir, run_name)
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
for key, value in (extra_pnginfo or {}).items():
|
||||
metadata.add_text(key, json.dumps(value))
|
||||
|
||||
parent = os.path.dirname(run_dir)
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with TemporaryDirectory(prefix=".model-compare-", dir=parent) as staging:
|
||||
names = []
|
||||
for i, image in enumerate(images):
|
||||
array = (image.cpu().numpy().clip(0, 1) * 255).round().astype(np.uint8)
|
||||
name = tile_filename(index, label, i, images.shape[0])
|
||||
with Image.fromarray(array) as tile:
|
||||
tile.save(
|
||||
os.path.join(staging, name + ".tmp"),
|
||||
format="PNG",
|
||||
pnginfo=metadata,
|
||||
)
|
||||
names.append(name)
|
||||
if index == 0:
|
||||
archived = archive_previous_sweep(run_dir)
|
||||
if archived:
|
||||
self.log_info(f"New sweep: previous tiles moved to {archived}")
|
||||
os.makedirs(run_dir, exist_ok=True)
|
||||
replace_index_tiles(run_dir, index, staging, names)
|
||||
|
||||
sheet = build_contact_sheet(list_tiles(run_dir), columns, tile_width, font_size)
|
||||
sheet.save(os.path.join(run_dir, GRID_FILENAME))
|
||||
preview_of(sheet).save(os.path.join(run_dir, PREVIEW_FILENAME))
|
||||
self.log_info(f"Contact sheet: {run_dir}/{GRID_FILENAME}")
|
||||
|
||||
grid = torch.from_numpy(np.asarray(sheet).astype(np.float32) / 255.0)[None]
|
||||
subfolder = os.path.relpath(run_dir, output_dir)
|
||||
return {
|
||||
"ui": {
|
||||
"images": [
|
||||
{
|
||||
"filename": PREVIEW_FILENAME,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
}
|
||||
]
|
||||
},
|
||||
"result": (grid,),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Project File Path tool for ComfyUI-KikoTools."""
|
||||
|
||||
from .node import ProjectFilePathNode
|
||||
|
||||
__all__ = ["ProjectFilePathNode"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Path building logic for the Project File Path node."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
# Characters that are invalid in Windows paths (and awkward elsewhere)
|
||||
_UNSAFE_CHARS = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||
_SEPARATORS = re.compile(r"[\\/]+")
|
||||
|
||||
# Only these are treated as a file extension and stripped from the filename,
|
||||
# so names like "render_v1.5" keep their dot.
|
||||
FILE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
".bmp",
|
||||
".tif",
|
||||
".tiff",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
".mkv",
|
||||
".avi",
|
||||
".wav",
|
||||
".mp3",
|
||||
".flac",
|
||||
".ogg",
|
||||
".txt",
|
||||
".json",
|
||||
".latent",
|
||||
}
|
||||
)
|
||||
|
||||
SEPARATOR_OPTIONS = ("auto", "forward_slash", "backslash")
|
||||
|
||||
|
||||
def _clean_part(part: str) -> str:
|
||||
"""Clean one path segment: drop unsafe chars, spaces to underscores."""
|
||||
part = _UNSAFE_CHARS.sub("", part).strip(" .")
|
||||
return part.replace(" ", "_")
|
||||
|
||||
|
||||
def sanitize_component(text: str) -> str:
|
||||
"""Clean a folder component, keeping nested folders as 'a/b'.
|
||||
|
||||
Splits on both / and \\, drops empty, '.' and '..' segments, so the
|
||||
result is always relative and can never climb out of its parent.
|
||||
"""
|
||||
parts = (_clean_part(p) for p in _SEPARATORS.split(text or ""))
|
||||
return "/".join(p for p in parts if p and p not in (".", ".."))
|
||||
|
||||
|
||||
def sanitize_filename(text: str) -> str:
|
||||
"""Clean a filename: no folders, and a known file extension removed."""
|
||||
name = _UNSAFE_CHARS.sub("", _SEPARATORS.sub("", text or ""))
|
||||
stem, extension = os.path.splitext(name)
|
||||
if extension.lower() in FILE_EXTENSIONS:
|
||||
name = stem
|
||||
elif name.strip().lower() in FILE_EXTENSIONS: # just ".png": splitext sees no ext
|
||||
name = ""
|
||||
return _clean_part(name)
|
||||
|
||||
|
||||
def _resolve_separator(separator: str) -> str:
|
||||
if separator == "auto":
|
||||
return os.sep
|
||||
if separator == "forward_slash":
|
||||
return "/"
|
||||
if separator == "backslash":
|
||||
return "\\"
|
||||
raise ValueError(
|
||||
f"Unknown separator '{separator}'; use one of {', '.join(SEPARATOR_OPTIONS)}"
|
||||
)
|
||||
|
||||
|
||||
def build_project_path(
|
||||
root: str,
|
||||
project_name: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
date_folder: bool = False,
|
||||
separator: str = "auto",
|
||||
today: Optional[date] = None,
|
||||
) -> str:
|
||||
"""Build 'root/project/[YYYY-MM-DD/]subfolder/filename'.
|
||||
|
||||
Raises:
|
||||
ValueError: If the filename is empty after cleaning, or the
|
||||
separator is unknown.
|
||||
"""
|
||||
sep = _resolve_separator(separator)
|
||||
clean_filename = sanitize_filename(filename)
|
||||
if not clean_filename:
|
||||
raise ValueError(f"filename {filename!r} is empty after removing unsafe parts")
|
||||
|
||||
folders = [sanitize_component(root), sanitize_component(project_name)]
|
||||
if date_folder:
|
||||
folders.append((today or date.today()).isoformat())
|
||||
folders.append(sanitize_component(subfolder))
|
||||
|
||||
path = "/".join([f for f in folders if f] + [clean_filename])
|
||||
return path.replace("/", sep)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""ComfyUI node implementation for Project File Path."""
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import SEPARATOR_OPTIONS, build_project_path
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return date.today()
|
||||
|
||||
|
||||
class ProjectFilePathNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Builds a save prefix like 'MyProject/2026-10-13/images/image'.
|
||||
|
||||
Wire the output into a save node's filename_prefix. Every component is
|
||||
sanitized so the result stays inside ComfyUI's output folder.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"root": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Optional top folder inside the output folder",
|
||||
},
|
||||
),
|
||||
"project_name": ("STRING", {"default": "MyProject"}),
|
||||
"subfolder": (
|
||||
"STRING",
|
||||
{"default": "images", "tooltip": "Use a/b for nested folders"},
|
||||
),
|
||||
"filename": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "image",
|
||||
"tooltip": "File extensions like .png are removed",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"date_folder": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Insert a YYYY-MM-DD folder after the project",
|
||||
},
|
||||
),
|
||||
"separator": (list(SEPARATOR_OPTIONS), {"default": "auto"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "generate_path"
|
||||
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, date_folder: bool = False, **kwargs) -> str:
|
||||
# Output depends only on the inputs, except for the date folder:
|
||||
# re-run when the day changes so open workflows get the new date.
|
||||
return _today().isoformat() if date_folder else ""
|
||||
|
||||
def generate_path(
|
||||
self,
|
||||
root: str,
|
||||
project_name: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
date_folder: bool = False,
|
||||
separator: str = "auto",
|
||||
) -> Tuple[str]:
|
||||
try:
|
||||
path = build_project_path(
|
||||
root,
|
||||
project_name,
|
||||
subfolder,
|
||||
filename,
|
||||
date_folder=date_folder,
|
||||
separator=separator,
|
||||
today=_today(),
|
||||
)
|
||||
except ValueError as e:
|
||||
self.handle_error(str(e), e)
|
||||
return (path,)
|
||||
@@ -3,9 +3,16 @@
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from typing import List, Dict, Any, Tuple
|
||||
import logging
|
||||
|
||||
from ....base.model_folders import (
|
||||
filter_by_pattern,
|
||||
natural_sort,
|
||||
resolve_scan_targets,
|
||||
scan_model_entries,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_STRENGTH_VALUES = 1000
|
||||
@@ -40,66 +47,9 @@ def get_lora_folders() -> List[str]:
|
||||
return [".", "flux", "sdxl", "sd15"]
|
||||
|
||||
|
||||
def _get_lora_roots() -> List[str]:
|
||||
"""Return every configured loras root directory (empty if unavailable)."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
entry = folder_paths.folder_names_and_paths.get("loras")
|
||||
return list(entry[0]) if entry else []
|
||||
except (ImportError, AttributeError, IndexError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def _is_within(path: str, root: str) -> bool:
|
||||
"""True if realpath `path` equals or is inside realpath `root`."""
|
||||
try:
|
||||
real_path, real_root = os.path.realpath(path), os.path.realpath(root)
|
||||
return os.path.commonpath([real_path, real_root]) == real_root
|
||||
except ValueError: # different drives or mixed absolute/relative
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_scan_targets(folder_path: str) -> List[Tuple[str, Optional[str]]]:
|
||||
"""
|
||||
Resolve a folder setting to (directory, base_root) pairs to scan.
|
||||
|
||||
base_root is the loras root the directory lives in, or None when the
|
||||
directory is an absolute path outside every known root.
|
||||
"""
|
||||
roots = _get_lora_roots()
|
||||
|
||||
if os.path.isabs(folder_path):
|
||||
base = next((r for r in roots if _is_within(folder_path, r)), None)
|
||||
return [(folder_path, base)]
|
||||
|
||||
targets = []
|
||||
for root in roots:
|
||||
candidate = os.path.join(root, folder_path)
|
||||
if not _is_within(candidate, root):
|
||||
logger.warning(f"Folder escapes the loras directory: {folder_path}")
|
||||
continue
|
||||
targets.append((candidate, root))
|
||||
return targets
|
||||
|
||||
|
||||
def _scan_entries(folder_path: str) -> List[Tuple[str, str]]:
|
||||
"""Return (relative_name, absolute_path) for every .safetensors found."""
|
||||
entries: Dict[str, str] = {}
|
||||
for directory, base in _resolve_scan_targets(folder_path):
|
||||
if not os.path.isdir(directory):
|
||||
continue
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
if not file.endswith(".safetensors"):
|
||||
continue
|
||||
full = os.path.join(root, file)
|
||||
try:
|
||||
rel = os.path.relpath(full, base or directory)
|
||||
except ValueError:
|
||||
rel = os.path.relpath(full, directory)
|
||||
entries.setdefault(rel.replace("\\", "/"), full)
|
||||
return [(name, entries[name]) for name in natural_sort(list(entries))]
|
||||
return scan_model_entries(folder_path, "loras")
|
||||
|
||||
|
||||
def scan_folder_for_loras(folder_path: str) -> List[str]:
|
||||
@@ -164,71 +114,11 @@ def sort_lora_files(lora_files: List[str], sort_order: str) -> List[str]:
|
||||
return lora_files
|
||||
|
||||
|
||||
def natural_sort(items: List[str]) -> List[str]:
|
||||
"""
|
||||
Sort strings naturally, handling numbers properly.
|
||||
|
||||
Args:
|
||||
items: List of strings to sort
|
||||
|
||||
Returns:
|
||||
Naturally sorted list
|
||||
"""
|
||||
|
||||
def natural_key(text):
|
||||
def atoi(text):
|
||||
return int(text) if text.isdigit() else text
|
||||
|
||||
# Split on digits and filter out empty strings
|
||||
parts = [atoi(c) for c in re.split(r"(\d+)", text) if c]
|
||||
|
||||
# Convert to tuple of (type_order, value) to ensure consistent comparison
|
||||
# Integers get type_order 0, strings get type_order 1
|
||||
typed_parts = []
|
||||
for part in parts:
|
||||
if isinstance(part, int):
|
||||
typed_parts.append((0, part))
|
||||
else:
|
||||
typed_parts.append((1, part))
|
||||
|
||||
return typed_parts
|
||||
|
||||
return sorted(items, key=natural_key)
|
||||
|
||||
|
||||
def filter_loras_by_pattern(
|
||||
lora_files: List[str], include_pattern: str = "", exclude_pattern: str = ""
|
||||
) -> List[str]:
|
||||
"""
|
||||
Filter LoRA files by include/exclude patterns.
|
||||
|
||||
Args:
|
||||
lora_files: List of LoRA filenames
|
||||
include_pattern: Regex pattern to include (empty = include all)
|
||||
exclude_pattern: Regex pattern to exclude (empty = exclude none)
|
||||
|
||||
Returns:
|
||||
Filtered list of LoRA files
|
||||
"""
|
||||
filtered = lora_files.copy()
|
||||
|
||||
# Apply include pattern
|
||||
if include_pattern:
|
||||
try:
|
||||
include_re = re.compile(include_pattern)
|
||||
filtered = [f for f in filtered if include_re.search(f)]
|
||||
except re.error as e:
|
||||
logger.error(f"Invalid include pattern: {e}")
|
||||
|
||||
# Apply exclude pattern
|
||||
if exclude_pattern:
|
||||
try:
|
||||
exclude_re = re.compile(exclude_pattern)
|
||||
filtered = [f for f in filtered if not exclude_re.search(f)]
|
||||
except re.error as e:
|
||||
logger.error(f"Invalid exclude pattern: {e}")
|
||||
|
||||
return filtered
|
||||
"""Filter LoRA names by include/exclude regex (empty pattern = no filter)."""
|
||||
return filter_by_pattern(lora_files, include_pattern, exclude_pattern)
|
||||
|
||||
|
||||
def _parse_finite(token: str, what: str, original: str) -> float:
|
||||
@@ -438,7 +328,7 @@ def validate_folder_path(folder_path: str) -> bool:
|
||||
|
||||
return any(
|
||||
os.path.isdir(directory)
|
||||
for directory, _ in _resolve_scan_targets(folder_path)
|
||||
for directory, _ in resolve_scan_targets(folder_path, "loras")
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "kikotools"
|
||||
description = "Simple tools for ComfyUI"
|
||||
version = "1.0.28"
|
||||
version = "1.2.0"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
@@ -14,18 +14,19 @@ dev = [
|
||||
# Testing framework
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.10.0",
|
||||
"pytest-mock>=3.15.1",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"aiohttp>=3.8.0",
|
||||
# Code quality
|
||||
"black>=23.0.0",
|
||||
"flake8>=6.0.0",
|
||||
"mypy>=1.0.0",
|
||||
"mypy>=1.20.0",
|
||||
# Development utilities
|
||||
"pre-commit>=3.0.0",
|
||||
"pre-commit>=4.5.1",
|
||||
# ComfyUI testing (mock dependencies for unit tests)
|
||||
"torch>=2.0.0",
|
||||
"torch>=2.11.0",
|
||||
"numpy>=1.24.0",
|
||||
"pillow>=9.0.0"
|
||||
"pillow>=12.2.0"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -71,6 +72,7 @@ files = ["kikotools"]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["kikotools"]
|
||||
omit = ["*/tests/*", "*/__init__.py"]
|
||||
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
# Testing framework
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.10.0
|
||||
pytest-mock>=3.15.1
|
||||
pytest-asyncio>=0.21.0
|
||||
aiohttp>=3.8.0
|
||||
|
||||
# Code quality
|
||||
black>=23.0.0
|
||||
flake8>=6.0.0
|
||||
mypy>=1.0.0
|
||||
mypy>=1.20.0
|
||||
|
||||
# Development utilities
|
||||
pre-commit>=3.0.0
|
||||
pre-commit>=4.5.1
|
||||
|
||||
# ComfyUI testing (mock dependencies for unit tests)
|
||||
torch>=2.0.0
|
||||
torch>=2.11.0
|
||||
numpy>=1.24.0
|
||||
pillow>=9.0.0
|
||||
pillow>=12.2.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Runtime dependencies for ComfyUI-KikoTools
|
||||
|
||||
# Gemini API integration (optional - only needed for Gemini Prompt node)
|
||||
google-generativeai
|
||||
google-genai
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// A test double for the ComfyUI host; extension implementations load unchanged.
|
||||
const extensions = [];
|
||||
const settings = new Map();
|
||||
const settingDefinitions = new Map();
|
||||
const nodes = new Map();
|
||||
let nextId = 1;
|
||||
function el(tag, props = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
for (const [name, value] of Object.entries(props)) {
|
||||
if (name === 'style') Object.assign(element.style, value);
|
||||
else if (name === 'parent') value.appendChild(element);
|
||||
else element[name] = value;
|
||||
}
|
||||
element.append(...children);
|
||||
return element;
|
||||
}
|
||||
const app = {
|
||||
registerExtension(extension) {
|
||||
if (extensions.some(item => item.name === extension.name)) throw new Error(`Duplicate extension ${extension.name}`);
|
||||
extensions.push(extension);
|
||||
},
|
||||
ui: { settings: {
|
||||
addSetting(setting) { settingDefinitions.set(setting.id, setting); if (!settings.has(setting.id)) settings.set(setting.id, setting.defaultValue); },
|
||||
getSettingValue: id => settings.get(id),
|
||||
setSettingValue(id, value) { settings.set(id, value); settingDefinitions.get(id)?.onChange?.(value); },
|
||||
}, dialog: { show: text => { const dialog = el('div', { className: 'host-dialog', textContent: text }); document.body.append(dialog); } } },
|
||||
graph: {
|
||||
_nodes: [],
|
||||
add(node) { this._nodes.push(node); nodes.set(String(node.id), node); },
|
||||
getNodeById: id => nodes.get(String(id)),
|
||||
setDirtyCanvas() {},
|
||||
},
|
||||
canvas: { selected_nodes: {}, centerOnNode(node) { app.centeredNode = node.id; } },
|
||||
};
|
||||
const api = new EventTarget();
|
||||
api.fetchApi = (path, options) => fetch(path, options);
|
||||
api.getEmbeddings = async () => [];
|
||||
function widget(node, name, value, multiline = false) {
|
||||
const inputEl = el(multiline ? 'textarea' : 'input', { 'ariaLabel': name });
|
||||
const result = { name, inputEl, options: {}, callback() {}, onRemove() { inputEl.remove(); } };
|
||||
Object.defineProperty(result, 'value', { get: () => inputEl.value, set: value => { inputEl.value = value; } });
|
||||
result.value = value ?? '';
|
||||
inputEl.addEventListener('change', () => result.callback(result.value, app.canvas, node, [0, 0], null));
|
||||
node.widgets.push(result);
|
||||
node.element.append(inputEl);
|
||||
return result;
|
||||
}
|
||||
const widgets = { STRING(node, name, data) { return { widget: widget(node, name, '', data[1]?.multiline) }; } };
|
||||
class HostNode {
|
||||
constructor(name, initial) {
|
||||
this.id = nextId++;
|
||||
this.type = name;
|
||||
this.title = name;
|
||||
this.widgets = [];
|
||||
this.size = [500, 300];
|
||||
this.pos = [0, 0];
|
||||
this.flags = {};
|
||||
this.inputs = [];
|
||||
this.properties = {};
|
||||
this.graph = app.graph;
|
||||
this.element = el('section', { className: 'host-node' });
|
||||
this.element.dataset.node = name;
|
||||
this.domElement = this.element;
|
||||
this.element.style.cssText = 'position:relative;width:min(800px,95vw);min-height:120px;margin:12px;border:1px solid #888;';
|
||||
document.querySelector('#nodes').append(this.element);
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
const entry = name === 'TextInput' && key === 'text'
|
||||
? widgets.STRING(this, key, ['STRING', { multiline: true }], app).widget
|
||||
: widget(this, key, value);
|
||||
entry.value = value;
|
||||
if (key === 'preset') entry.options.values = ['custom', '832×1216 - portrait', '1216×832 - landscape'];
|
||||
}
|
||||
}
|
||||
addDOMWidget(name, type, element) { this.element.append(element); const result = { name, element, onRemove: () => element.remove() }; this.widgets.push(result); return result; }
|
||||
addWidget(type, name, value, callback, options = {}) {
|
||||
if (type !== 'button') { const entry = widget(this, name, value); entry.callback = callback; return entry; }
|
||||
const button = el('button', { textContent: name, onclick: () => callback() });
|
||||
this.element.append(button);
|
||||
const result = { type, name, value, callback, options, onRemove: () => button.remove() };
|
||||
this.widgets.push(result);
|
||||
return result;
|
||||
}
|
||||
addCustomWidget(entry) { this.widgets.push(entry); if (entry.type === 'button') { const button = el('button', { textContent: entry.text, onclick: () => entry.callback() }); this.element.append(button); entry.onRemove = () => button.remove(); } return entry; }
|
||||
setSize(size) { this.size = size; }
|
||||
computeSize() { return this.size; }
|
||||
onResize(size) { this.size = size; }
|
||||
setDirtyCanvas() {}
|
||||
configure(info) { this.properties = info.properties || {}; }
|
||||
onNodeCreated() { this.created = true; }
|
||||
onExecuted() { this.executions = (this.executions || 0) + 1; }
|
||||
onRemoved() { this.removed = true; }
|
||||
}
|
||||
async function createNode(name, initial = {}) {
|
||||
class Node extends HostNode {}
|
||||
Node.category = 'ComfyAssets';
|
||||
for (const extension of extensions) await extension.beforeRegisterNodeDef?.(Node, { name }, app);
|
||||
const node = new Node(name, initial);
|
||||
app.graph.add(node);
|
||||
node.onNodeCreated();
|
||||
return node.id;
|
||||
}
|
||||
function removeNode(id) {
|
||||
const node = nodes.get(String(id));
|
||||
node.onRemoved?.();
|
||||
for (const entry of node.widgets) { entry.onRemoved?.(); entry.onRemove?.(); }
|
||||
node.element.remove();
|
||||
nodes.delete(String(id));
|
||||
app.graph._nodes = app.graph._nodes.filter(item => item !== node);
|
||||
return node;
|
||||
}
|
||||
window.kikoHost = { app, api, widgets, el, extensions, settings, settingDefinitions, nodes, createNode, removeNode,
|
||||
emit: (name, detail) => api.dispatchEvent(new CustomEvent(name, { detail })) };
|
||||
const manifest = await (await fetch('/__test__/state')).json();
|
||||
for (const name of manifest.extensions) await import(`/extensions/kikotools/${name}`);
|
||||
for (const extension of extensions) await extension.init?.(app);
|
||||
for (const extension of extensions) await extension.setup?.(app);
|
||||
window.kikoHost.ready = true;
|
||||
document.body.dataset.ready = 'true';
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Run actual node routes against disposable files and a minimal ComfyUI host."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
REPOSITORY = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = Path(__file__).parent
|
||||
|
||||
|
||||
def create_app(root: Path) -> web.Application:
|
||||
"""Import the real plugin and replace only ComfyUI host services."""
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
server = types.ModuleType("server")
|
||||
server.PromptServer = types.SimpleNamespace(
|
||||
instance=types.SimpleNamespace(routes=routes, send_sync=lambda *args: None)
|
||||
)
|
||||
sys.modules["server"] = server
|
||||
folder_paths = types.ModuleType("folder_paths")
|
||||
folder_paths.base_path = str(root)
|
||||
folder_paths.models_dir = str(root / "models")
|
||||
folder_paths.folder_names_and_paths = {}
|
||||
for kind in ("input", "output", "temp"):
|
||||
directory = root / kind
|
||||
directory.mkdir()
|
||||
setattr(folder_paths, f"get_{kind}_directory", lambda path=directory: str(path))
|
||||
folder_paths.get_filename_list = lambda kind: {
|
||||
"embeddings": ["portraits/cat.pt", "landscapes/forest.safetensors"],
|
||||
"loras": ["styles/ink.safetensors"],
|
||||
}.get(kind, [])
|
||||
folder_paths.get_folder_paths = lambda kind: [str(root / "models" / kind)]
|
||||
sys.modules["folder_paths"] = folder_paths
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"kiko_browser_plugin", REPOSITORY / "__init__.py"
|
||||
)
|
||||
plugin = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = plugin
|
||||
spec.loader.exec_module(plugin)
|
||||
loader = sys.modules["kiko_browser_plugin.kikotools.tools.local_image_loader.node"]
|
||||
loader.CONFIG_FILE = str(root / "config.json")
|
||||
loader.SELECTIONS_FILE = str(root / "selections.json")
|
||||
|
||||
def reset(extra_images: int = 0) -> None:
|
||||
(root / "selections.json").unlink(missing_ok=True)
|
||||
for kind in ("input", "output"):
|
||||
shutil.rmtree(root / kind)
|
||||
(root / kind).mkdir()
|
||||
loader.save_config({"saved_paths": [], "last_path": str(root / "input")})
|
||||
for path, color in [
|
||||
("input/cat & dog.png", "red"),
|
||||
("input/sub/forest.png", "green"),
|
||||
("input/.hidden/hidden.png", "green"),
|
||||
("outside/secret.png", "blue"),
|
||||
]:
|
||||
target = root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", (16, 12), color) as image:
|
||||
image.save(target)
|
||||
|
||||
for index in range(extra_images):
|
||||
target = root / "input" / "bulk" / f"{index:04}.png"
|
||||
target.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(root / "input" / "cat & dog.png", target)
|
||||
|
||||
@routes.post("/__test__/reset")
|
||||
async def reset_route(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
extra_images = data.get("extra_images", 0)
|
||||
if (
|
||||
isinstance(extra_images, bool)
|
||||
or not isinstance(extra_images, int)
|
||||
or not 0 <= extra_images <= 60
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
await asyncio.to_thread(reset, extra_images)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/state")
|
||||
async def state(request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"input": str(root / "input"),
|
||||
"outside": str(root / "outside"),
|
||||
"selections": loader.load_selections(),
|
||||
"config": loader.load_config(),
|
||||
"nodes": sorted(plugin.NODE_CLASS_MAPPINGS),
|
||||
"extensions": sorted(
|
||||
path.name for path in (REPOSITORY / "web").glob("*.js")
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@routes.post("/__test__/execute/{name}")
|
||||
async def execute(request: web.Request) -> web.Response:
|
||||
name = request.match_info["name"]
|
||||
data = await request.json()
|
||||
if name not in {
|
||||
"DisplayAny",
|
||||
"DisplayText",
|
||||
"KikoLocalImageLoader",
|
||||
"KikoSaveImage",
|
||||
}:
|
||||
raise web.HTTPNotFound()
|
||||
if name == "KikoSaveImage":
|
||||
data["images"] = loader.load_image_from_path(
|
||||
str(root / "input" / "cat & dog.png")
|
||||
)[0]
|
||||
cls = plugin.NODE_CLASS_MAPPINGS[name]
|
||||
result = await asyncio.to_thread(getattr(cls(), cls.FUNCTION), **data)
|
||||
if name == "KikoLocalImageLoader":
|
||||
tensor, info = result
|
||||
return web.json_response(
|
||||
{"shape": list(tensor.shape), "info": json.loads(info) if info else {}}
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.get("/__test__/")
|
||||
async def index(request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
text='<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>KikoTools extension test host</title></head><body><main id="nodes"></main><script type="module" src="/__test__/host.mjs"></script></body></html>',
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
@routes.get("/__test__/host.mjs")
|
||||
async def host(request: web.Request) -> web.Response:
|
||||
return web.FileResponse(FIXTURES / "host.mjs")
|
||||
|
||||
@routes.get("/scripts/{name}.js")
|
||||
async def host_import(request: web.Request) -> web.Response:
|
||||
exports = {
|
||||
"app": "export const app = window.kikoHost.app;",
|
||||
"api": "export const api = window.kikoHost.api;",
|
||||
"widgets": "export const ComfyWidgets = window.kikoHost.widgets;",
|
||||
"ui": "export const $el = window.kikoHost.el;",
|
||||
}
|
||||
if request.match_info["name"] not in exports:
|
||||
raise web.HTTPNotFound()
|
||||
return web.Response(
|
||||
text=exports[request.match_info["name"]], content_type="text/javascript"
|
||||
)
|
||||
|
||||
@routes.get("/view")
|
||||
@routes.get("/api/view")
|
||||
async def comfy_view(request: web.Request) -> web.Response:
|
||||
directory = root / "output"
|
||||
path = (
|
||||
directory
|
||||
/ request.query.get("subfolder", "")
|
||||
/ request.query.get("filename", "")
|
||||
).resolve()
|
||||
if not path.is_relative_to(directory) or not path.is_file():
|
||||
raise web.HTTPNotFound()
|
||||
return web.FileResponse(path)
|
||||
|
||||
reset()
|
||||
app.add_routes(routes)
|
||||
app.router.add_static("/extensions/kikotools", REPOSITORY / "web")
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8766)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix="kiko-browser-") as directory:
|
||||
web.run_app(
|
||||
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,877 @@
|
||||
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
|
||||
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(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.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/'));
|
||||
return route.fulfill({ json: extensions });
|
||||
}
|
||||
if (path.startsWith('/extensions/kikotools/')) {
|
||||
const source = resolve(web, decodeURIComponent(path.slice('/extensions/kikotools/'.length)));
|
||||
if (!source.startsWith(web + sep)) return route.abort();
|
||||
if (!/\.(js|css)$/.test(source)) return route.abort();
|
||||
return route.fulfill({ body: await readFile(source), contentType: source.endsWith('.css') ? 'text/css' : 'text/javascript' });
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
for (const name of ['WidthHeightSelector', 'EmptyLatentBatch']) {
|
||||
test(`${name}: shared swap survives renderers, zoom, themes and workflow reload`, async ({ page }, testInfo) => {
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(name);
|
||||
app.graph.add(node);
|
||||
const preset = node.widgets.find(widget => widget.name === 'preset');
|
||||
const options = typeof preset.options.values === 'function' ? preset.options.values() : preset.options.values;
|
||||
preset.value = options.find(option => option.startsWith('832×1216'));
|
||||
preset.callback(preset.value, app.canvas, node, [0, 0], null);
|
||||
const batch = node.widgets.find(widget => widget.name === 'batch_size');
|
||||
if (batch) batch.value = 3;
|
||||
app.canvas.centerOnNode(node);
|
||||
}, name);
|
||||
const panel = page.locator('.ca-dimension-swap');
|
||||
const button = panel.getByRole('button', { name: 'Swap width and height', exact: true });
|
||||
await expect(button).toBeVisible();
|
||||
for (const renderer of [false, true, false]) {
|
||||
const before = await page.evaluate(async ({ name, renderer }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
const node = app.graph._nodes.find(node => node.type === name);
|
||||
app.canvas.ds.scale = .7;
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
|
||||
}, { name, renderer });
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(panel).toHaveCount(1);
|
||||
await button.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph._nodes.find(node => node.type === name).widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
|
||||
}, name)).toEqual([...before].reverse());
|
||||
}
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(node => node.type === name);
|
||||
node.setSize([220, 280]);
|
||||
app.canvas.ds.scale = 1;
|
||||
app.canvas.centerOnNode(node);
|
||||
}, name);
|
||||
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
const narrow = await button.boundingBox();
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === name).setSize([500, 300]);
|
||||
}, name);
|
||||
await expect.poll(async () => (await button.boundingBox()).width).toBeGreaterThan(narrow.width);
|
||||
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`) });
|
||||
}
|
||||
const restored = await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
const saved = workflow.nodes.find(node => node.type === name);
|
||||
const nativeCount = name === 'EmptyLatentBatch' ? 4 : 3;
|
||||
if (saved.widgets_values.slice(nativeCount).some(value => value !== '' && value != null)) throw new Error('Swap UI leaked into workflow values');
|
||||
await app.loadGraphData(workflow);
|
||||
const node = app.graph._nodes.find(node => node.type === name);
|
||||
app.canvas.centerOnNode(node);
|
||||
return { saved: saved.widgets_values.slice(0, nativeCount), restored: node.widgets.filter(widget => ['preset', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value), size: Array.from(node.size), savedSize: Array.from(saved.size) };
|
||||
}, name);
|
||||
expect(restored.restored).toEqual(restored.saved);
|
||||
expect(restored.size).toEqual(restored.savedSize);
|
||||
await expect(panel).toHaveCount(1);
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(node => node.type === name);
|
||||
window.removedSwap = node;
|
||||
app.graph.remove(node);
|
||||
}, name);
|
||||
await expect(panel).toHaveCount(0);
|
||||
expect(await page.evaluate(() => removedSwap.swapScope.signal.aborted)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('workflow timer preserves appearance and elapsed time through renderer switching and settings', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const { api } = await import('/scripts/api.js');
|
||||
await app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#123456');
|
||||
const node = LiteGraph.createNode('KikoWorkflowTimer');
|
||||
app.graph.add(node);
|
||||
node.color = '#334455';
|
||||
node.bgcolor = '#556677';
|
||||
node.setSize([440, 240]);
|
||||
node.properties.elapsed_time_str = '01:02:345';
|
||||
node.onConfigure({ properties: node.properties });
|
||||
app.canvas.centerOnNode(node);
|
||||
api.dispatchEvent(new CustomEvent('execution_start'));
|
||||
});
|
||||
const panel = page.locator('.ca-workflow-timer');
|
||||
const timer = panel.getByRole('timer', { name: 'Elapsed workflow time' });
|
||||
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 expect(panel).toHaveCount(1);
|
||||
await expect(timer).toHaveClass(/running/);
|
||||
await expect(timer).toHaveCSS('color', 'rgb(18, 52, 86)');
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { api } = await import('/scripts/api.js');
|
||||
api.dispatchEvent(new CustomEvent('execution_interrupted'));
|
||||
});
|
||||
const stopped = await timer.textContent();
|
||||
const initialFontSize = await timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize));
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([220, 160]);
|
||||
});
|
||||
await expect.poll(() => timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeLessThan(initialFontSize);
|
||||
expect(await timer.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([600, 320]);
|
||||
});
|
||||
await expect.poll(() => timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThan(initialFontSize);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([440, 240]);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#0877bb');
|
||||
});
|
||||
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(`timer-${theme}.png`) });
|
||||
}
|
||||
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.find(node => node.type === 'KikoWorkflowTimer');
|
||||
if (node.color !== '#334455' || node.bgcolor !== '#556677') throw new Error('Custom node colors changed');
|
||||
if (node.size[0] !== 440 || node.size[1] !== 240) throw new Error('Saved timer dimensions changed');
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
await expect(timer).toHaveText(stopped);
|
||||
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
|
||||
const search = page.getByPlaceholder(/search/i);
|
||||
await expect(search).toBeVisible();
|
||||
await search.fill('Workflow Timer');
|
||||
await page.getByRole('button', { name: 'Choose timer color', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Timer color', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Hex color', { exact: true }).fill('#4a90d9');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(timer).toHaveCSS('color', 'rgb(74, 144, 217)');
|
||||
await page.screenshot({ path: testInfo.outputPath('timer-color-picker.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Choose timer color', exact: true })).toBeFocused();
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer');
|
||||
window.removedTimer = node;
|
||||
app.graph.remove(node);
|
||||
});
|
||||
await expect(panel).toHaveCount(0);
|
||||
expect(await page.evaluate(() => removedTimer.timerScope.signal.aborted)).toBe(true);
|
||||
});
|
||||
|
||||
test('seed history survives renderer switching, resizing and workflow reload', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('SeedHistory');
|
||||
app.graph.add(node);
|
||||
node.setSize([380, 560]);
|
||||
for (let seed = 100; seed < 110; seed++) node.addSeedToHistory(seed);
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
const panel = page.locator('.ca-seed-history');
|
||||
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 expect(panel).toHaveCount(1);
|
||||
await panel.getByRole('button', { name: 'Use seed 105', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(panel.getByRole('button', { name: 'Use seed 105', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(panel.getByRole('status')).toHaveText('Loaded: 105');
|
||||
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === 'SeedHistory').setSize([280, 350]);
|
||||
});
|
||||
const list = panel.getByRole('list', { name: 'Recent seeds' });
|
||||
await expect.poll(() => list.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||
const smallHeight = await list.evaluate(element => element.clientHeight);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === 'SeedHistory').setSize([420, 650]);
|
||||
});
|
||||
await expect.poll(() => list.evaluate(element => element.clientHeight)).toBeGreaterThan(smallHeight);
|
||||
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(`seed-${theme}.png`) });
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
const saved = workflow.nodes.find(node => node.type === 'SeedHistory');
|
||||
if (saved.widgets_values[0] !== 105 || saved.widgets_values.slice(2).some(value => value !== '' && value != null)) throw new Error('History UI leaked into serialized widget values');
|
||||
await app.loadGraphData(workflow);
|
||||
const node = app.graph._nodes.find(node => node.type === 'SeedHistory');
|
||||
if (node.size[0] !== saved.size[0] || node.size[1] !== saved.size[1]) throw new Error('Saved node size changed');
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
await expect(panel).toHaveCount(1);
|
||||
await expect(panel.getByRole('button', { name: 'Use seed 105', exact: true })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(node => node.type === 'SeedHistory');
|
||||
window.removedSeedNode = node;
|
||||
app.graph.remove(node);
|
||||
});
|
||||
await expect(panel).toHaveCount(0);
|
||||
expect(await page.evaluate(() => removedSeedNode.seedScope.signal.aborted && removedSeedNode.seedValueWatcher === null)).toBe(true);
|
||||
});
|
||||
|
||||
test('saved-image window survives real renderer switching and is removed with its owner', async ({ page, request }, testInfo) => {
|
||||
const result = await request.post(`${fixture}/__test__/execute/KikoSaveImage`, { data: { filename_prefix: 'Real host viewer' } });
|
||||
await expect(result).toBeOK();
|
||||
const message = (await result.json()).ui;
|
||||
const id = await page.evaluate(async message => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('KikoSaveImage');
|
||||
app.graph.add(node);
|
||||
node.onExecuted(message);
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
}, message);
|
||||
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
|
||||
await expect(viewer).toBeVisible();
|
||||
await expect.poll(() => viewer.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(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);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(viewer).toHaveCount(1);
|
||||
await viewer.getByTitle('Close', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Show images', exact: true }).click();
|
||||
await expect(viewer).toBeVisible();
|
||||
expect(await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph.serialize().nodes.find(node => String(node.id) === String(id)).widgets_values.includes('Show images');
|
||||
}, id)).toBe(false);
|
||||
}
|
||||
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(viewer).toHaveAttribute('data-ca-theme', theme);
|
||||
const info = await viewer.locator('.kiko-image-info').boundingBox();
|
||||
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
|
||||
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
|
||||
await page.screenshot({ path: testInfo.outputPath(`viewer-${theme}.png`) });
|
||||
}
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph.getNodeById(id));
|
||||
}, id);
|
||||
await expect(viewer).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('actual host preserves the widget through renderer changes, theme changes and save/reload', async ({ page, request }, testInfo) => {
|
||||
await expect(await request.post(`${fixture}/__test__/reset`, { data: { extra_images: 52 } })).toBeOK();
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', false);
|
||||
const node = LiteGraph.createNode('KikoLocalImageLoader');
|
||||
node.pos = [250, 140];
|
||||
app.graph.add(node);
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
const loader = page.locator('.ca-image-browser');
|
||||
await expect(loader).toBeVisible();
|
||||
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
|
||||
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 expect(loader).toHaveCount(1);
|
||||
await expect(loader).toBeVisible();
|
||||
await loader.getByRole('button', { name: 'Select image cat & dog.png', exact: true }).click();
|
||||
await expect(loader.locator('[data-selection]')).toHaveText('cat & dog.png');
|
||||
const node = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph.serialize().nodes.find(node => node.type === 'KikoLocalImageLoader');
|
||||
});
|
||||
expect(node).toBeTruthy();
|
||||
expect(node.widgets_values || []).not.toContainEqual(expect.objectContaining({ className: 'ca-image-browser' }));
|
||||
await loader.getByRole('button', { name: 'Open folder bulk', exact: true }).click();
|
||||
await expect(loader.locator('.kiko-card-info-panel')).toHaveCount(50);
|
||||
const media = await loader.locator('.kiko-card-media-wrapper').nth(1).boundingBox();
|
||||
expect(media.height / media.width).toBeCloseTo(1, 1);
|
||||
expect(media.height).toBeGreaterThan(150);
|
||||
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.clientHeight)).toBeGreaterThanOrEqual(await loader.locator('.kiko-card-media-wrapper').nth(1).evaluate(element => element.offsetHeight));
|
||||
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath(renderer ? 'nodes2.png' : 'classic.png') });
|
||||
await loader.getByRole('button', { name: 'Open folder ..', exact: true }).click();
|
||||
await expect(loader.getByAltText('cat & dog.png', { exact: true })).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(loader).toHaveAttribute('data-ca-theme', theme);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
});
|
||||
await expect(loader).toHaveCount(1);
|
||||
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph._nodes[0]);
|
||||
});
|
||||
await expect(loader).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Node Dimensions applies and persists across Classic and Nodes 2.0', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('WidthHeightSelector');
|
||||
app.graph.add(node);
|
||||
node.pos = [300, 250];
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
const dialog = page.getByRole('dialog', { name: 'Node Dimensions', 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);
|
||||
const point = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes[0];
|
||||
const canvas = app.canvas.canvas.getBoundingClientRect();
|
||||
return { x: canvas.left + (node.pos[0] + app.canvas.ds.offset[0] + 60) * app.canvas.ds.scale, y: canvas.top + (node.pos[1] + app.canvas.ds.offset[1] - 15) * app.canvas.ds.scale };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y, { button: 'right' });
|
||||
await page.getByText('🎨 Node Dimensions', { exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Width (px)', { exact: true }).fill('900');
|
||||
await dialog.getByLabel('Height (px)', { exact: true }).fill('600');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
})).toEqual([900, 600]);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
|
||||
});
|
||||
await dialog.getByLabel('Width (px)', { exact: true }).fill('1200');
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
})).toEqual([900, 600]);
|
||||
}
|
||||
const size = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
});
|
||||
expect(size).toEqual([900, 600]);
|
||||
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(`node-dimensions-${theme}.png`) });
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph._nodes[0]);
|
||||
});
|
||||
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([]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
|
||||
const output = execFileSync(process.execPath, ['tools/testing/node_modules/@playwright/test/cli.js',
|
||||
'test', '--config', 'tools/testing/comfyui.config.mjs', '--list'], {
|
||||
cwd: root, encoding: 'utf8', env: { ...process.env,
|
||||
KIKO_COMFY_ROOT: '', KIKO_COMFY_URL: 'http://127.0.0.1:8199/' },
|
||||
});
|
||||
assert.match(output, /\[classic-start\]/);
|
||||
assert.match(output, /\[nodes2-start\]/);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
import { launchComfyHost } from '../../tools/testing/comfyui-host.mjs';
|
||||
|
||||
async function fixture(t, program) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'kiko-host-test-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const checkout = join(directory, 'host');
|
||||
const repository = join(directory, 'plugin');
|
||||
await mkdir(checkout);
|
||||
await mkdir(repository);
|
||||
await writeFile(join(repository, '__init__.py'), '');
|
||||
await writeFile(join(checkout, 'main.py'), program);
|
||||
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
git(['init', '--quiet']);
|
||||
git(['add', 'main.py']);
|
||||
git(['-c', 'user.name=Kiko Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
|
||||
return { checkout, repository, python: process.execPath, port: 8199,
|
||||
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
|
||||
}
|
||||
|
||||
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
|
||||
|
||||
test('managed ComfyUI host uses disposable CPU/offline state and preserves source directories', async t => {
|
||||
const options = await fixture(t, idle);
|
||||
const host = await launchComfyHost(options);
|
||||
t.after(() => host.stop());
|
||||
await once(host.child.stdout, 'data');
|
||||
const args = host.child.spawnargs;
|
||||
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
|
||||
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
|
||||
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
|
||||
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'kikotools');
|
||||
assert.equal(await realpath(join(host.stateDirectory, 'custom_nodes/kikotools')), await realpath(options.repository));
|
||||
await access(join(host.stateDirectory, 'user'));
|
||||
await host.stop();
|
||||
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
|
||||
await access(join(options.checkout, 'main.py'));
|
||||
await access(join(options.repository, '__init__.py'));
|
||||
});
|
||||
|
||||
test('managed ComfyUI host rejects a mismatched or modified upstream checkout', async t => {
|
||||
const options = await fixture(t, idle);
|
||||
await assert.rejects(launchComfyHost({ ...options, pins: { revision: '0'.repeat(40) } }), /revision/i);
|
||||
await writeFile(join(options.checkout, 'main.py'), 'modified');
|
||||
await assert.rejects(launchComfyHost(options), /modified|dirty/i);
|
||||
});
|
||||
|
||||
test('managed ComfyUI host preserves crash diagnostics, exit status and cleanup', async t => {
|
||||
const options = await fixture(t, `console.error('startup failed'); process.exit(7);`);
|
||||
const host = await launchComfyHost(options);
|
||||
const result = await host.finished;
|
||||
assert.equal(result.code, 7);
|
||||
assert.match(await readFile(options.logPath, 'utf8'), /startup failed/);
|
||||
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('managed ComfyUI host reports spawn failures and releases its temporary state', async t => {
|
||||
const options = await fixture(t, idle);
|
||||
const host = await launchComfyHost({ ...options, python: join(options.checkout, 'missing-python') });
|
||||
const result = await host.finished;
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.error.message, /ENOENT/);
|
||||
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('managed ComfyUI host fails before spawning when its log cannot be opened', async t => {
|
||||
const options = await fixture(t, `require('node:fs').writeFileSync(process.argv[1] + '.started', 'yes');`);
|
||||
await assert.rejects(launchComfyHost({ ...options, logPath: options.checkout }), /EISDIR|directory/i);
|
||||
await assert.rejects(access(join(options.checkout, 'main.py.started')), { code: 'ENOENT' });
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
const sourceRoot = join(process.cwd(), 'web');
|
||||
import { checkCoverage, discoverSources } from '../../tools/testing/frontend-coverage.mjs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
const metrics = value => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(key => [key, { pct: value }]));
|
||||
const summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) };
|
||||
const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } };
|
||||
test('coverage gate accepts measured files at or above their reviewed floor', () => {
|
||||
assert.deepEqual(checkCoverage(summary, baseline, sourceRoot, ['a.js']), []);
|
||||
});
|
||||
test('coverage gate rejects a regression even when the aggregate percentage is high', () => {
|
||||
assert.match(checkCoverage({ ...summary, [join(sourceRoot, 'a.js')]: metrics(70) }, baseline, sourceRoot, ['a.js']).join('\n'), /a.js.*lines/);
|
||||
});
|
||||
test('coverage gate rejects absent source files and unreviewed new files', () => {
|
||||
assert.match(checkCoverage({ total: metrics(100) }, baseline, sourceRoot, ['a.js']).join('\n'), /missing.*a.js/i);
|
||||
assert.match(checkCoverage(summary, baseline, sourceRoot, ['a.js', 'new.js']).join('\n'), /baseline.*new.js/i);
|
||||
});
|
||||
test('coverage gate rejects invalid baselines and missing metrics', () => {
|
||||
assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i);
|
||||
assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/);
|
||||
});
|
||||
|
||||
test('source discovery includes nested UI modules in the coverage gate', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kiko-coverage-'));
|
||||
try {
|
||||
mkdirSync(join(root, 'ui'));
|
||||
writeFileSync(join(root, 'entry.js'), '');
|
||||
writeFileSync(join(root, 'ui/core.js'), '');
|
||||
writeFileSync(join(root, 'ui/theme.css'), '');
|
||||
assert.deepEqual(discoverSources(root), ['entry.js', 'ui/core.js']);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { bindDimensionPresets, dimensionsFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
|
||||
|
||||
const app = { canvas: {} };
|
||||
|
||||
test('resolution parsing accepts supplied formats and rejects invalid dimensions', () => {
|
||||
assert.deepEqual(dimensionsFromPreset(' 928x1664 - Qwen'), [928, 1664]);
|
||||
assert.deepEqual(dimensionsFromPreset('1664×928'), [1664, 928]);
|
||||
for (const value of [null, {}, 'custom', '0x100', '12.5x100', 'x100', '999999999999999999999x100']) assert.equal(dimensionsFromPreset(value), null);
|
||||
});
|
||||
|
||||
test('preset callbacks preserve returns and default host context without changing custom dimensions', () => {
|
||||
const calls = [];
|
||||
const node = { widgets: [
|
||||
{ name: 'preset', callback() { return 'original'; } },
|
||||
{ name: 'width', value: 64, callback(value, canvas, owner) { calls.push([this.name, value, canvas, owner]); } },
|
||||
{ name: 'height', value: 128 },
|
||||
] };
|
||||
bindDimensionPresets(node, app);
|
||||
assert.equal(node.widgets[0].callback('custom'), 'original');
|
||||
assert.equal(node.widgets[0].callback('928×1664 - Qwen'), 'original');
|
||||
assert.deepEqual(calls, [['width', 928, app.canvas, node]]);
|
||||
assert.equal(node.widgets[2].value, 1664);
|
||||
});
|
||||
|
||||
test('incomplete widgets and unavailable reverse options are safe', () => {
|
||||
bindDimensionPresets({ widgets: [] }, app);
|
||||
swapDimensions({ widgets: [] }, app);
|
||||
const node = { widgets: [{ name: 'preset', value: '640×960' }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
|
||||
bindDimensionPresets(node, app);
|
||||
swapDimensions(node, app);
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 960, 640]);
|
||||
node.widgets.pop();
|
||||
node.widgets[0].callback('640×960');
|
||||
swapDimensions(node, app);
|
||||
assert.equal(node.widgets[1].value, 960);
|
||||
});
|
||||
|
||||
test('dynamic and array option lists match reverse dimensions', () => {
|
||||
for (const options of [['custom', '960x640'], { values: () => ['custom', '960×640'] }]) {
|
||||
const node = { widgets: [{ name: 'preset', value: '640×960', options }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
|
||||
bindDimensionPresets(node, app);
|
||||
swapDimensions(node, app);
|
||||
assert.equal(node.widgets[0].value, Array.isArray(options) ? '960x640' : '960×640');
|
||||
}
|
||||
});
|
||||
|
||||
test('a throwing or recursive preset callback does not leave swapping locked', () => {
|
||||
let fail = true;
|
||||
const node = { widgets: [
|
||||
{ name: 'preset', value: '640×960', options: { values: ['640×960', '960×640'] }, callback() { swapDimensions(node, app); if (fail) throw new Error('callback failed'); } },
|
||||
{ name: 'width', value: 640 }, { name: 'height', value: 960 },
|
||||
] };
|
||||
bindDimensionPresets(node, app);
|
||||
assert.throws(() => swapDimensions(node, app), /callback failed/);
|
||||
fail = false;
|
||||
swapDimensions(node, app);
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['640×960', 640, 960]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
|
||||
|
||||
function load(file, extra = {}) {
|
||||
const extensions = [];
|
||||
const timers = new Map();
|
||||
const app = { registerExtension: extension => extensions.push(extension), canvas: {}, graph: { setDirtyCanvas() {} } };
|
||||
const context = vm.createContext({
|
||||
app, console: { log() {}, error() {} },
|
||||
setTimeout: callback => { const id = Symbol(); timers.set(id, callback); return id; },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
...extra,
|
||||
});
|
||||
const filename = fileURLToPath(new URL(`../../web/${file}`, import.meta.url));
|
||||
vm.runInContext(readFileSync(filename, 'utf8').replace(/^import .*;\s*$/gm, match => match.replace(/[^\r\n]/g, ' ')), context, { filename });
|
||||
return { extension: extensions[0], app, timers, context };
|
||||
}
|
||||
|
||||
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape'], file = 'width_height_swap.js') {
|
||||
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
|
||||
let originalCreated = 0;
|
||||
class Node {
|
||||
constructor() {
|
||||
this.originalPresetCalls = 0;
|
||||
this.widgets = [
|
||||
{ name: 'preset', value: preset, options: { values: options }, callback(value, canvas, node) { node.originalPresetCalls++; } },
|
||||
{ name: 'width', value: 832 }, { name: 'height', value: 1216 },
|
||||
];
|
||||
this.graph = app.graph;
|
||||
}
|
||||
onNodeCreated() { originalCreated++; }
|
||||
}
|
||||
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
|
||||
Node.prototype.createSwapButton = () => {};
|
||||
const node = new Node();
|
||||
node.onNodeCreated();
|
||||
assert.equal(originalCreated, 1);
|
||||
return node;
|
||||
}
|
||||
|
||||
for (const file of ['width_height_swap.js', 'empty_latent_batch_swap.js']) {
|
||||
test(`${file}: custom dimensions swap and callbacks receive the actual node`, async () => {
|
||||
const node = await dimensionNode('custom', undefined, file);
|
||||
const calls = [];
|
||||
for (const widget of node.widgets.slice(1)) widget.callback = (value, canvas, owner) => calls.push({ value, owner });
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
|
||||
assert.equal(calls.length, 2);
|
||||
for (const call of calls) assert.equal(call.owner, node);
|
||||
});
|
||||
|
||||
test(`${file}: formatted preset swaps to its matching landscape preset without throwing`, async () => {
|
||||
const node = await dimensionNode('832×1216 - portrait', undefined, file);
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
|
||||
});
|
||||
|
||||
test(`${file}: a preset without a reverse switches to custom dimensions`, async () => {
|
||||
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait'], file);
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
|
||||
});
|
||||
|
||||
test(`${file}: preset changes preserve original callbacks and update both dimensions`, async () => {
|
||||
const node = await dimensionNode('custom', undefined, file);
|
||||
node.widgets[0].callback('1216×832 - landscape', {}, node);
|
||||
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [1216, 832]);
|
||||
assert.equal(node.extractResolutionFromPreset('custom'), null);
|
||||
assert.equal(node.originalPresetCalls, 1);
|
||||
});
|
||||
|
||||
test(`${file}: a preset swap notifies each dimension callback exactly once`, async () => {
|
||||
const node = await dimensionNode('832×1216 - portrait', undefined, file);
|
||||
const calls = [];
|
||||
for (const widget of node.widgets.slice(1)) widget.callback = function (value, canvas, owner) { calls.push({ name: this.name, value, owner }); };
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(calls.map(call => [call.name, call.value]), [['width', 1216], ['height', 832]]);
|
||||
assert.equal(node.originalPresetCalls, 1);
|
||||
for (const call of calls) assert.equal(call.owner, node);
|
||||
});
|
||||
|
||||
test(`${file}: ASCII resolutions match reversed presets and unfamiliar formatted presets update dimensions`, async () => {
|
||||
const node = await dimensionNode('832x1216 - portrait', ['custom', '832x1216 - portrait', '1216x832 - landscape'], file);
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216x832 - landscape', 1216, 832]);
|
||||
node.widgets[0].callback('2040×1080 - future preset', {}, node);
|
||||
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [2040, 1080]);
|
||||
});
|
||||
|
||||
test(`${file}: malformed presets do not write invalid dimensions`, async () => {
|
||||
const node = await dimensionNode('invalidx960', undefined, file);
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['invalidx960', 832, 1216]);
|
||||
});
|
||||
|
||||
test(`${file}: unrelated node definitions retain their lifecycle methods`, async () => {
|
||||
const { extension, app } = load(file);
|
||||
class Node { onNodeCreated() {} }
|
||||
const original = Node.prototype.onNodeCreated;
|
||||
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
|
||||
assert.equal(Node.prototype.onNodeCreated, original);
|
||||
});
|
||||
|
||||
test(`${file}: creation preserves hook arguments, results and existing canvas hooks`, async () => {
|
||||
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
|
||||
class Node {
|
||||
constructor() { this.widgets = []; }
|
||||
onNodeCreated(value) { this.received = value; return 'original result'; }
|
||||
onDrawForeground() {}
|
||||
onMouseDown() {}
|
||||
onMouseMove() {}
|
||||
}
|
||||
const hooks = [Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove];
|
||||
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
|
||||
const node = new Node();
|
||||
assert.equal(node.onNodeCreated('original argument'), 'original result');
|
||||
assert.equal(node.received, 'original argument');
|
||||
assert.deepEqual([Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove], hooks);
|
||||
});
|
||||
|
||||
test(`${file}: nodes without an existing creation hook initialize safely`, async () => {
|
||||
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
|
||||
class Node { constructor() { this.widgets = []; } }
|
||||
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
|
||||
assert.equal(new Node().onNodeCreated(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('Gemini repeated execution restores the user color and removal cancels its timer', async () => {
|
||||
const { extension, app, timers } = load('gemini_prompt_ui.js');
|
||||
let removed = 0;
|
||||
class Node { onRemoved() { removed++; } }
|
||||
await extension.beforeRegisterNodeDef(Node, { name: 'GeminiPrompt' }, app);
|
||||
const node = new Node();
|
||||
node.status = { value: 'Ready' };
|
||||
node.bgcolor = '#123456';
|
||||
node.onExecuted({});
|
||||
node.onExecuted({ output: { prompt: ['Error: unavailable'] } });
|
||||
assert.equal(timers.size, 1);
|
||||
assert.equal(node.bgcolor, '#552222');
|
||||
[...timers.values()][0]();
|
||||
assert.equal(node.bgcolor, '#123456');
|
||||
assert.equal(node.status.value, 'Ready');
|
||||
timers.clear();
|
||||
node.onExecuted({});
|
||||
node.onRemoved();
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(removed, 1);
|
||||
});
|
||||
|
||||
test('execution following respects enablement and ignores missing nodes', async () => {
|
||||
const settings = new Map();
|
||||
const listeners = new Map();
|
||||
const centered = [];
|
||||
const extensions = [];
|
||||
const node = { id: '7', type: 'DisplayAny', title: 'Display' };
|
||||
const app = {
|
||||
registerExtension: extension => extensions.push(extension),
|
||||
ui: { settings: { addSetting: setting => settings.set(setting.id, setting.defaultValue), getSettingValue: id => settings.get(id) } },
|
||||
graph: { _nodes: [node], getNodeById: id => id === node.id ? node : undefined },
|
||||
canvas: { centerOnNode: node => centered.push(node) }, runningNodeId: '7',
|
||||
};
|
||||
load('kiko_follow_execution.js', { app, api: { addEventListener: (name, callback) => listeners.set(name, callback) } });
|
||||
const extension = extensions[0];
|
||||
await extension.init();
|
||||
await extension.setup();
|
||||
assert.equal(extension.getCanvasMenuItems().length, 0);
|
||||
settings.set('kikotools.follow_execution.enabled', true);
|
||||
settings.set('kikotools.follow_execution.auto_start', true);
|
||||
listeners.get('execution_start')();
|
||||
listeners.get('executing')({ detail: '7' });
|
||||
listeners.get('executing')({ detail: 'missing' });
|
||||
assert.deepEqual(centered, [node]);
|
||||
settings.set('kikotools.follow_execution.enabled', false);
|
||||
listeners.get('execution_start')();
|
||||
listeners.get('executing')({ detail: '7' });
|
||||
assert.equal(centered.length, 1);
|
||||
});
|
||||
|
||||
|
||||
test('gallery HTML escaping handles filenames and attribute delimiters', () => {
|
||||
const { context } = load('kiko_local_image_loader.js', { document: { getElementById: () => ({}) } });
|
||||
const escape = vm.runInContext('escapeHtml', context);
|
||||
assert.equal(escape(null), '');
|
||||
assert.equal(escape(`<img src="x" onerror='run()'>&`), '<img src="x" onerror='run()'>&');
|
||||
});
|
||||
|
||||
test('gallery HTTP errors preserve actionable server detail and handle non-JSON responses', async () => {
|
||||
const { context } = load('kiko_local_image_loader.js', { document: { getElementById: () => ({}) } });
|
||||
const describe = vm.runInContext('describeHttpError', context);
|
||||
assert.equal(await describe({ status: 403, json: async () => ({ error: 'Choose an allowed root' }) }), 'Choose an allowed root');
|
||||
assert.match(await describe({ status: 403, json: async () => { throw new Error('not JSON'); } }), /allowed folders/);
|
||||
assert.match(await describe({ status: 400, json: async () => ({}) }), /Invalid path/);
|
||||
});
|
||||
|
||||
test('gallery HTTP errors cover null bodies, message details, and generic status fallbacks', async () => {
|
||||
const { context } = load('kiko_local_image_loader.js', { document: { getElementById: () => ({}) } });
|
||||
const describe = vm.runInContext('describeHttpError', context);
|
||||
for (const [status, body, expected] of [
|
||||
[400, { error: 'Invalid directory' }, 'Invalid directory'],
|
||||
[403, { message: 'Not an allowed root' }, 'Not an allowed root'],
|
||||
[400, null, 'Invalid path or request'],
|
||||
[500, { error: 'Storage unavailable' }, 'Storage unavailable'],
|
||||
[404, { message: 'Missing image' }, 'Missing image'],
|
||||
[502, null, 'Request failed (HTTP 502)'],
|
||||
[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);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fitWindowRect } from '../../web/ui/floating-window.js';
|
||||
|
||||
test('window bounds keep its controls reachable after moving outside the viewport', () => {
|
||||
const rect = fitWindowRect({ x: -100, y: 900, width: 420, height: 400 }, { width: 1000, height: 700 });
|
||||
assert.equal(rect.x, 8);
|
||||
assert.equal(rect.y + rect.height, 692);
|
||||
});
|
||||
|
||||
test('window fitting respects minimum sizes and small viewports', () => {
|
||||
const small = fitWindowRect({ x: 0, y: 0, width: 10, height: 10 }, { width: 1000, height: 700 });
|
||||
assert.equal(small.width, 280);
|
||||
assert.equal(small.height, 200);
|
||||
const narrow = fitWindowRect({ x: 800, y: 600, width: 900, height: 800 }, { width: 260, height: 180 });
|
||||
assert.equal(narrow.width, 244);
|
||||
assert.equal(narrow.height, 164);
|
||||
assert.equal(narrow.x, 8);
|
||||
assert.equal(narrow.y, 8);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isFixtureURL } from '../../tools/testing/network-policy.mjs';
|
||||
const base = 'http://127.0.0.1:8766';
|
||||
test('the network guard allows the test origin and WebKit local blob URLs', () => {
|
||||
assert.equal(isFixtureURL(`${base}/extensions/kikotools/display_any.js`, base), true);
|
||||
assert.equal(isFixtureURL(`blob:${base}/local-script`, base), true);
|
||||
});
|
||||
test('the network guard denies external destinations and other localhost ports', () => {
|
||||
for (const url of ['https://example.com/model', 'http://127.0.0.1:9000/private', 'blob:https://example.com/script', 'data:text/plain,fixture']) {
|
||||
assert.equal(isFixtureURL(url, base), false, url);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveNodeSize } from '../../web/views/node-dimensions.js';
|
||||
|
||||
test('node dimensions accept whole pixels and respect content minimums', () => {
|
||||
assert.deepEqual(resolveNodeSize(['640', '480'], [180, 120]), [640, 480]);
|
||||
assert.deepEqual(resolveNodeSize(['1', '10'], [180, 120]), [180, 120]);
|
||||
});
|
||||
|
||||
test('node dimensions reject missing, fractional, unsafe and nonpositive values', () => {
|
||||
for (const value of ['', ' ', '0', '-1', '1.5', 'Infinity', 'NaN', '9007199254740992']) {
|
||||
assert.equal(resolveNodeSize([value, '480'], [180, 120]), null, value);
|
||||
assert.equal(resolveNodeSize(['640', value], [180, 120]), null, value);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createScope, themeFromColor } from '../../web/ui/core.js';
|
||||
|
||||
test('host background luminance determines mode without assuming palette names', () => {
|
||||
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
|
||||
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
|
||||
assert.equal(themeFromColor(''), null);
|
||||
assert.equal(themeFromColor('transparent'), null);
|
||||
});
|
||||
|
||||
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
|
||||
const scope = createScope();
|
||||
const target = new EventTarget();
|
||||
let events = 0;
|
||||
let cleanups = 0;
|
||||
scope.listen(target, 'change', () => events++);
|
||||
scope.cleanup(() => cleanups++);
|
||||
target.dispatchEvent(new Event('change'));
|
||||
assert.equal(events, 1);
|
||||
scope.dispose();
|
||||
scope.dispose();
|
||||
target.dispatchEvent(new Event('change'));
|
||||
assert.equal(events, 1);
|
||||
assert.equal(cleanups, 1);
|
||||
assert.equal(scope.signal.aborted, true);
|
||||
});
|
||||
|
||||
test('debounced work runs once with the latest value and stops after disposal', async () => {
|
||||
const scope = createScope();
|
||||
const values = [];
|
||||
const change = scope.debounce(value => values.push(value), 1);
|
||||
change('old');
|
||||
change('new');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.deepEqual(values, ['new']);
|
||||
change('cancelled');
|
||||
scope.dispose();
|
||||
change('after removal');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.deepEqual(values, ['new']);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { WorkflowClock, formatElapsed } from '../../web/ui/workflow-clock.js';
|
||||
import { normalizeHex, hexToHsv, hsvToHex } from '../../web/ui/color.js';
|
||||
|
||||
function fixture() {
|
||||
let now = 0;
|
||||
const intervals = new Map();
|
||||
const settings = new Map();
|
||||
const clock = new WorkflowClock({ getSettingValue: id => settings.get(id) }, {
|
||||
Date: { now: () => now },
|
||||
setInterval(callback) { const id = Symbol(); intervals.set(id, callback); return id; },
|
||||
clearInterval(id) { intervals.delete(id); },
|
||||
});
|
||||
const node = () => ({ properties: {}, timerDisplay: {
|
||||
textContent: '',
|
||||
style: { setProperty(name, value) { this[name] = value; } },
|
||||
classList: { values: new Set(), toggle(name, enabled) { enabled ? this.values.add(name) : this.values.delete(name); } },
|
||||
} });
|
||||
return { clock, settings, intervals, node, advance(ms) { now += ms; for (const callback of intervals.values()) callback(); } };
|
||||
}
|
||||
|
||||
test('elapsed formatting handles minutes, milliseconds and invalid durations', () => {
|
||||
assert.equal(formatElapsed(0), '00:00:000');
|
||||
assert.equal(formatElapsed(-1), '00:00:000');
|
||||
assert.equal(formatElapsed(NaN), '00:00:000');
|
||||
assert.equal(formatElapsed(61234.7), '01:01:234');
|
||||
assert.equal(formatElapsed(3600000), '60:00:000');
|
||||
});
|
||||
|
||||
test('one clock synchronizes late joining nodes and persists the final duration', () => {
|
||||
const f = fixture();
|
||||
const first = f.node();
|
||||
f.clock.registerNode(first);
|
||||
f.clock.start();
|
||||
f.advance(1200);
|
||||
f.clock.start();
|
||||
const second = f.node();
|
||||
f.clock.registerNode(second);
|
||||
assert.equal(f.intervals.size, 1);
|
||||
assert.equal(first.timerDisplay.textContent, '00:01:200');
|
||||
assert.equal(second.timerDisplay.textContent, first.timerDisplay.textContent);
|
||||
assert.equal(second.timerDisplay.classList.values.has('running'), true);
|
||||
f.advance(34);
|
||||
f.clock.stop();
|
||||
f.clock.stop();
|
||||
assert.equal(f.intervals.size, 0);
|
||||
assert.equal(f.clock.intervalId, null);
|
||||
for (const node of [first, second]) {
|
||||
assert.equal(node.properties.elapsed_time_str, '00:01:234');
|
||||
assert.equal(node.timerDisplay.classList.values.has('running'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('removing the final node releases polling while late nodes can rejoin an active run', () => {
|
||||
const f = fixture();
|
||||
f.clock.start();
|
||||
assert.equal(f.intervals.size, 0);
|
||||
f.advance(1000);
|
||||
const first = f.node();
|
||||
f.clock.registerNode(first);
|
||||
assert.equal(first.timerDisplay.textContent, '00:01:000');
|
||||
assert.equal(f.intervals.size, 1);
|
||||
f.clock.unregisterNode(first);
|
||||
assert.equal(f.intervals.size, 0);
|
||||
f.advance(1000);
|
||||
const next = f.node();
|
||||
f.clock.registerNode(next);
|
||||
assert.equal(next.timerDisplay.textContent, '00:02:000');
|
||||
f.clock.stop();
|
||||
f.clock.start();
|
||||
assert.equal(next.timerDisplay.textContent, '00:00:000');
|
||||
f.clock.unregisterNode(next);
|
||||
assert.equal(f.intervals.size, 0);
|
||||
});
|
||||
|
||||
test('configured colors and glow update during a run and saved durations restore', () => {
|
||||
const f = fixture();
|
||||
const node = f.node();
|
||||
node.properties.elapsed_time_str = '02:03:456';
|
||||
f.clock.registerNode(node);
|
||||
assert.equal(node.timerDisplay.textContent, '02:03:456');
|
||||
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#0877bb');
|
||||
f.clock.start();
|
||||
f.settings.set('kikotools.workflow_timer.color', '#Ff9966');
|
||||
f.settings.set('kikotools.workflow_timer.glow', false);
|
||||
f.clock.refresh();
|
||||
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#ff9966');
|
||||
assert.equal(node.timerDisplay.classList.values.has('no-glow'), true);
|
||||
f.settings.set('kikotools.workflow_timer.color', 'invalid');
|
||||
f.clock.refresh();
|
||||
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#0877bb');
|
||||
});
|
||||
|
||||
test('hex colors normalize strictly and HSV conversion round trips representative colors', () => {
|
||||
assert.equal(normalizeHex('Ff9966'), '#ff9966');
|
||||
for (const invalid of ['', '#xyzxyz', '#12345', null, {}, '#ffffff; color:red']) assert.equal(normalizeHex(invalid), null);
|
||||
for (const hex of ['#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#ff9966', '#0877bb']) {
|
||||
const { h, s, v } = hexToHsv(hex);
|
||||
assert.equal(hsvToHex(h, s, v), hex);
|
||||
}
|
||||
assert.equal(hsvToHex(360, 100, 100), '#ff0000');
|
||||
assert.equal(hsvToHex(-120, 100, 100), '#0000ff');
|
||||
assert.equal(hsvToHex(0, 120, -1), '#000000');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Ensure the documented test command actually invokes pytest and fails closed."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not shutil.which("make"), reason="Requires POSIX make"
|
||||
)
|
||||
def test_make_test_runs_pytest_and_propagates_failure(tmp_path: Path) -> None:
|
||||
recorder = tmp_path / "runner.py"
|
||||
arguments = tmp_path / "arguments.json"
|
||||
recorder.write_text(
|
||||
"import json,sys\n"
|
||||
f"with open({str(arguments)!r}, 'w') as output:\n"
|
||||
" json.dump(sys.argv[1:], output)\n"
|
||||
"sys.exit(7)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"make",
|
||||
"--no-print-directory",
|
||||
"-o",
|
||||
"venv",
|
||||
"test",
|
||||
f"PYTHON_CMD={shlex.join([sys.executable, str(recorder)])}",
|
||||
],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert json.loads(arguments.read_text()) == ["-m", "pytest", "tests"]
|
||||
assert result.returncode != 0
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for the shared model-folder scanner."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from kikotools.base.model_folders import scan_model_entries, natural_sort
|
||||
|
||||
|
||||
def _touch(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write("x")
|
||||
|
||||
|
||||
def _fake_folder_paths(key, *roots):
|
||||
fp = MagicMock()
|
||||
fp.folder_names_and_paths = {key: [list(roots)]}
|
||||
return patch.dict(sys.modules, {"folder_paths": fp})
|
||||
|
||||
|
||||
class TestScanModelEntries:
|
||||
def test_scans_the_requested_folder_key(self, tmp_path):
|
||||
ckpt = tmp_path / "checkpoints"
|
||||
_touch(str(ckpt / "Illustrious" / "b_v2.safetensors"))
|
||||
_touch(str(ckpt / "Illustrious" / "a_v10.safetensors"))
|
||||
_touch(str(ckpt / "Illustrious" / "notes.md"))
|
||||
with _fake_folder_paths("checkpoints", str(ckpt)):
|
||||
entries = scan_model_entries("Illustrious", "checkpoints")
|
||||
assert [name for name, _ in entries] == [
|
||||
"Illustrious/a_v10.safetensors",
|
||||
"Illustrious/b_v2.safetensors",
|
||||
]
|
||||
assert entries[0][1] == str(ckpt / "Illustrious" / "a_v10.safetensors")
|
||||
|
||||
def test_relative_folder_escaping_the_root_is_ignored(self, tmp_path):
|
||||
ckpt = tmp_path / "checkpoints"
|
||||
_touch(str(tmp_path / "outside" / "x.safetensors"))
|
||||
with _fake_folder_paths("checkpoints", str(ckpt)):
|
||||
assert scan_model_entries("../outside", "checkpoints") == []
|
||||
|
||||
def test_unknown_key_returns_empty(self, tmp_path):
|
||||
with _fake_folder_paths("loras", str(tmp_path)):
|
||||
assert scan_model_entries(".", "checkpoints") == []
|
||||
|
||||
|
||||
def test_natural_sort_orders_numbers_numerically():
|
||||
assert natural_sort(["v10", "v2", "v1"]) == ["v1", "v2", "v10"]
|
||||
|
||||
|
||||
def test_natural_sort_ignores_case():
|
||||
assert natural_sort(["IllustrijGEN3", "aij_v10", "Illustrijv21", "animij_v6"]) == [
|
||||
"aij_v10",
|
||||
"animij_v6",
|
||||
"IllustrijGEN3",
|
||||
"Illustrijv21",
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for Checkpoint Folder Iterator logic."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.checkpoint_folder_iterator.logic import (
|
||||
label_for,
|
||||
list_checkpoint_entries,
|
||||
list_checkpoints,
|
||||
pick_checkpoint,
|
||||
)
|
||||
|
||||
|
||||
def _touch(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write("x")
|
||||
|
||||
|
||||
def _fake_checkpoints(root):
|
||||
fp = MagicMock()
|
||||
fp.folder_names_and_paths = {"checkpoints": [[str(root)]]}
|
||||
return patch.dict(sys.modules, {"folder_paths": fp})
|
||||
|
||||
|
||||
class TestListCheckpoints:
|
||||
def test_lists_safetensors_in_natural_order(self, tmp_path):
|
||||
for name in ("illustrij_v20", "illustrij_v3", "ilustmix_v9"):
|
||||
_touch(str(tmp_path / "Illustrious" / f"{name}.safetensors"))
|
||||
_touch(str(tmp_path / "Illustrious" / "illustrij_v20.jpeg"))
|
||||
with _fake_checkpoints(tmp_path):
|
||||
names = list_checkpoints("Illustrious")
|
||||
assert names == [
|
||||
"Illustrious/illustrij_v3.safetensors",
|
||||
"Illustrious/illustrij_v20.safetensors",
|
||||
"Illustrious/ilustmix_v9.safetensors",
|
||||
]
|
||||
|
||||
def test_include_and_exclude_patterns(self, tmp_path):
|
||||
for name in ("a_v1", "a_v2", "b_v1"):
|
||||
_touch(str(tmp_path / "X" / f"{name}.safetensors"))
|
||||
with _fake_checkpoints(tmp_path):
|
||||
names = list_checkpoints("X", include_pattern="^X/a_", exclude_pattern="v2")
|
||||
assert names == ["X/a_v1.safetensors"]
|
||||
|
||||
|
||||
class TestListCheckpointEntries:
|
||||
def test_pairs_each_name_with_its_absolute_path(self, tmp_path):
|
||||
_touch(str(tmp_path / "X" / "a_v1.safetensors"))
|
||||
with _fake_checkpoints(tmp_path):
|
||||
entries = list_checkpoint_entries("X")
|
||||
assert entries == [
|
||||
("X/a_v1.safetensors", str(tmp_path / "X" / "a_v1.safetensors"))
|
||||
]
|
||||
|
||||
def test_patterns_apply_to_entries_too(self, tmp_path):
|
||||
for name in ("a_v1", "a_v2", "b_v1"):
|
||||
_touch(str(tmp_path / "X" / f"{name}.safetensors"))
|
||||
with _fake_checkpoints(tmp_path):
|
||||
entries = list_checkpoint_entries("X", "^X/a_", "v2")
|
||||
assert [name for name, _ in entries] == ["X/a_v1.safetensors"]
|
||||
|
||||
def test_absolute_folder_outside_roots_keeps_real_paths(self, tmp_path):
|
||||
root = tmp_path / "models" / "checkpoints"
|
||||
elsewhere = tmp_path / "external_drive" / "ckpts"
|
||||
_touch(str(elsewhere / "ext_v1.safetensors"))
|
||||
with _fake_checkpoints(root):
|
||||
entries = list_checkpoint_entries(str(elsewhere))
|
||||
assert entries == [
|
||||
("ext_v1.safetensors", str(elsewhere / "ext_v1.safetensors"))
|
||||
]
|
||||
|
||||
|
||||
class TestPickCheckpoint:
|
||||
def test_returns_the_indexed_item(self):
|
||||
names = ["a", "b", "c"]
|
||||
assert pick_checkpoint(names, 0) == "a"
|
||||
assert pick_checkpoint(names, 2) == "c"
|
||||
|
||||
def test_index_past_the_end_raises_instead_of_wrapping(self):
|
||||
with pytest.raises(ValueError, match="past the end.*3 checkpoints"):
|
||||
pick_checkpoint(["a", "b", "c"], 3)
|
||||
|
||||
def test_negative_index_raises(self):
|
||||
with pytest.raises(ValueError, match="past the end"):
|
||||
pick_checkpoint(["a"], -1)
|
||||
|
||||
def test_empty_list_raises(self):
|
||||
with pytest.raises(ValueError, match="No checkpoints"):
|
||||
pick_checkpoint([], 0)
|
||||
|
||||
|
||||
class TestLabelFor:
|
||||
def test_default_label_is_the_file_stem(self):
|
||||
assert label_for("Illustrious/illustrij_v20.safetensors") == "illustrij_v20"
|
||||
assert label_for("Illustrious/reijlita/x_v1.safetensors") == "x_v1"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for the Checkpoint Folder Iterator node."""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.checkpoint_folder_iterator import CheckpointFolderIteratorNode
|
||||
|
||||
NODE = "kikotools.tools.checkpoint_folder_iterator.node"
|
||||
ENTRIES = [
|
||||
("X/a_v1.safetensors", "/models/checkpoints/X/a_v1.safetensors"),
|
||||
("X/b_v2.safetensors", "/models/checkpoints/X/b_v2.safetensors"),
|
||||
]
|
||||
|
||||
|
||||
def _fake_comfy_modules():
|
||||
fp = MagicMock()
|
||||
fp.get_folder_paths = MagicMock(return_value=["/models/embeddings"])
|
||||
comfy = MagicMock()
|
||||
comfy.sd.load_checkpoint_guess_config = MagicMock(
|
||||
return_value=("MODEL", "CLIP", "VAE", "extra")
|
||||
)
|
||||
return (
|
||||
patch.dict(
|
||||
sys.modules, {"folder_paths": fp, "comfy": comfy, "comfy.sd": comfy.sd}
|
||||
),
|
||||
fp,
|
||||
comfy,
|
||||
)
|
||||
|
||||
|
||||
class TestCheckpointFolderIteratorNode:
|
||||
def test_input_index_increments_after_generate(self):
|
||||
index = CheckpointFolderIteratorNode.INPUT_TYPES()["required"]["index"]
|
||||
assert index[0] == "INT"
|
||||
assert index[1]["control_after_generate"] is True
|
||||
|
||||
def test_loads_the_indexed_checkpoint_from_its_scanned_path(self):
|
||||
ctx, fp, comfy = _fake_comfy_modules()
|
||||
with ctx, patch(f"{NODE}.list_checkpoint_entries", return_value=ENTRIES):
|
||||
out = CheckpointFolderIteratorNode().load("X", 1, "", "")
|
||||
comfy.sd.load_checkpoint_guess_config.assert_called_once()
|
||||
assert comfy.sd.load_checkpoint_guess_config.call_args.args == (
|
||||
"/models/checkpoints/X/b_v2.safetensors",
|
||||
)
|
||||
fp.get_full_path_or_raise.assert_not_called()
|
||||
assert out == ("MODEL", "CLIP", "VAE", "X/b_v2.safetensors", "b_v2", 1, 2)
|
||||
|
||||
def test_absolute_folder_outside_models_dir_loads_by_real_path(self):
|
||||
ctx, _, comfy = _fake_comfy_modules()
|
||||
external = [("ext_v1.safetensors", "/mnt/drive/ckpts/ext_v1.safetensors")]
|
||||
with ctx, patch(f"{NODE}.list_checkpoint_entries", return_value=external):
|
||||
out = CheckpointFolderIteratorNode().load("/mnt/drive/ckpts", 0, "", "")
|
||||
assert comfy.sd.load_checkpoint_guess_config.call_args.args == (
|
||||
"/mnt/drive/ckpts/ext_v1.safetensors",
|
||||
)
|
||||
assert out[3:] == ("ext_v1.safetensors", "ext_v1", 0, 1)
|
||||
|
||||
def test_index_past_the_end_raises_instead_of_restarting(self):
|
||||
ctx, _, comfy = _fake_comfy_modules()
|
||||
with ctx, patch(f"{NODE}.list_checkpoint_entries", return_value=ENTRIES):
|
||||
with pytest.raises(ValueError, match="past the end"):
|
||||
CheckpointFolderIteratorNode().load("X", 2, "", "")
|
||||
comfy.sd.load_checkpoint_guess_config.assert_not_called()
|
||||
|
||||
def test_empty_folder_raises_clear_error(self):
|
||||
ctx, _, _ = _fake_comfy_modules()
|
||||
with ctx, patch(f"{NODE}.list_checkpoint_entries", return_value=[]):
|
||||
with pytest.raises(ValueError, match="No checkpoints"):
|
||||
CheckpointFolderIteratorNode().load("Empty", 0, "", "")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for Model Compare Sheet logic."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from kikotools.tools.model_compare_sheet.logic import (
|
||||
build_contact_sheet,
|
||||
label_from_filename,
|
||||
list_tiles,
|
||||
resolve_run_dir,
|
||||
tile_filename,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveRunDir:
|
||||
def test_run_dir_lives_under_model_compare(self, tmp_path):
|
||||
assert resolve_run_dir(str(tmp_path), "illustrious") == os.path.join(
|
||||
str(tmp_path), "model_compare", "illustrious"
|
||||
)
|
||||
|
||||
def test_nested_run_names_are_allowed(self, tmp_path):
|
||||
assert resolve_run_dir(str(tmp_path), "proj/2026-10-15/sweep") == os.path.join(
|
||||
str(tmp_path), "model_compare", "proj", "2026-10-15", "sweep"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"",
|
||||
"../escape",
|
||||
"/abs/path",
|
||||
"a/../../b",
|
||||
"..\\escape",
|
||||
"a\\..\\..\\b",
|
||||
"C:\\abs",
|
||||
".",
|
||||
"./x",
|
||||
"a/./b",
|
||||
"a//b",
|
||||
"x/..",
|
||||
],
|
||||
)
|
||||
def test_rejects_names_that_leave_model_compare(self, tmp_path, bad):
|
||||
with pytest.raises(ValueError, match="run_name"):
|
||||
resolve_run_dir(str(tmp_path), bad)
|
||||
|
||||
def test_rejects_symlink_escape_into_another_output_folder(self, tmp_path):
|
||||
base = tmp_path / "model_compare"
|
||||
base.mkdir()
|
||||
outside = tmp_path / "unrelated"
|
||||
outside.mkdir()
|
||||
try:
|
||||
(base / "link").symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"Directory symlinks unavailable: {exc}")
|
||||
with pytest.raises(ValueError, match="run_name"):
|
||||
resolve_run_dir(str(tmp_path), "link/run")
|
||||
|
||||
|
||||
class TestTileFilename:
|
||||
def test_single_image_run(self):
|
||||
assert tile_filename(3, "illustrij_v20", 0, 1) == "03_illustrij_v20.png"
|
||||
|
||||
def test_batch_gets_a_suffix(self):
|
||||
assert tile_filename(3, "illustrij_v20", 1, 2) == "03_illustrij_v20_2.png"
|
||||
|
||||
def test_label_characters_are_sanitized(self):
|
||||
assert tile_filename(0, "a/b:c d", 0, 1) == "00_a_b_c_d.png"
|
||||
|
||||
|
||||
def test_label_from_filename_strips_index_and_extension():
|
||||
assert label_from_filename("03_illustrij_v20.png") == "illustrij_v20"
|
||||
assert label_from_filename("03_illustrij_v20_2.png") == "illustrij_v20_2"
|
||||
assert label_from_filename("grid.png") == "grid"
|
||||
|
||||
|
||||
def test_list_tiles_skips_grid_and_sorts_naturally(tmp_path):
|
||||
for name in ("10_b.png", "2_a.png", "grid.png", "grid_preview.png", "notes.txt"):
|
||||
(tmp_path / name).write_bytes(b"")
|
||||
assert list_tiles(str(tmp_path)) == [
|
||||
str(tmp_path / "2_a.png"),
|
||||
str(tmp_path / "10_b.png"),
|
||||
]
|
||||
|
||||
|
||||
class TestBuildContactSheet:
|
||||
def _tiles(self, tmp_path, n, size=(100, 200)):
|
||||
paths = []
|
||||
for i in range(n):
|
||||
p = tmp_path / f"{i:02d}_m{i}.png"
|
||||
Image.new("RGB", size, (i * 40, 0, 0)).save(p)
|
||||
paths.append(str(p))
|
||||
return paths
|
||||
|
||||
def test_grid_dimensions_follow_columns_and_tile_width(self, tmp_path):
|
||||
paths = self._tiles(tmp_path, 3)
|
||||
sheet = build_contact_sheet(paths, columns=2, tile_width=50, font_size=10)
|
||||
# 3 tiles in 2 columns = 2 rows; tiles scale to 50x100; label band 2*font
|
||||
assert sheet.size == (100, 2 * (100 + 20))
|
||||
|
||||
def test_fewer_tiles_than_columns_shrinks_width(self, tmp_path):
|
||||
paths = self._tiles(tmp_path, 1)
|
||||
sheet = build_contact_sheet(paths, columns=5, tile_width=50, font_size=10)
|
||||
assert sheet.size == (50, 100 + 20)
|
||||
|
||||
def test_label_band_is_drawn(self, tmp_path):
|
||||
paths = self._tiles(tmp_path, 1)
|
||||
sheet = build_contact_sheet(paths, columns=1, tile_width=50, font_size=10)
|
||||
band = sheet.crop((0, 100, 50, 120))
|
||||
assert len(band.getcolors(maxcolors=4096) or []) > 1
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Tests for the Model Compare Sheet node."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from kikotools.tools.model_compare_sheet import ModelCompareSheetNode
|
||||
|
||||
|
||||
def _fake_output_dir(path):
|
||||
fp = MagicMock()
|
||||
fp.get_output_directory = MagicMock(return_value=str(path))
|
||||
return patch.dict(sys.modules, {"folder_paths": fp})
|
||||
|
||||
|
||||
def _image(batch=1, h=64, w=32):
|
||||
return torch.rand(batch, h, w, 3)
|
||||
|
||||
|
||||
class TestModelCompareSheetNode:
|
||||
def test_is_an_output_node(self):
|
||||
assert ModelCompareSheetNode.OUTPUT_NODE is True
|
||||
|
||||
def test_saves_tile_and_grid_and_returns_grid(self, tmp_path):
|
||||
with _fake_output_dir(tmp_path):
|
||||
out = ModelCompareSheetNode().collect(
|
||||
_image(), "illustrij_v20", 3, "run1", 4, 32, 8
|
||||
)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
assert (run_dir / "03_illustrij_v20.png").exists()
|
||||
assert (run_dir / "grid.png").exists()
|
||||
grid = out["result"][0]
|
||||
assert grid.shape == (1, 64 + 16, 32, 3)
|
||||
assert out["ui"]["images"][0]["filename"] == "grid_preview.png"
|
||||
assert out["ui"]["images"][0]["subfolder"] == os.path.join(
|
||||
"model_compare", "run1"
|
||||
)
|
||||
|
||||
def test_grid_grows_across_runs(self, tmp_path):
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(_image(), "a", 0, "run1", 4, 32, 8)
|
||||
out = node.collect(_image(), "b", 1, "run1", 4, 32, 8)
|
||||
assert out["result"][0].shape == (1, 64 + 16, 64, 3)
|
||||
|
||||
def test_batch_saves_every_image(self, tmp_path):
|
||||
with _fake_output_dir(tmp_path):
|
||||
ModelCompareSheetNode().collect(_image(batch=2), "a", 0, "run1", 4, 32, 8)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
assert (run_dir / "00_a_1.png").exists()
|
||||
assert (run_dir / "00_a_2.png").exists()
|
||||
assert Image.open(run_dir / "grid.png").size == (64, 64 + 16)
|
||||
|
||||
def test_rerun_of_same_index_overwrites_instead_of_duplicating(self, tmp_path):
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(_image(), "a", 0, "run1", 4, 32, 8)
|
||||
out = node.collect(_image(), "a", 0, "run1", 4, 32, 8)
|
||||
assert out["result"][0].shape[2] == 32
|
||||
|
||||
|
||||
class TestArchiveOnNewSweep:
|
||||
def test_index_zero_archives_the_previous_sweep(self, tmp_path):
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(_image(), "a", 0, "run1", 4, 32, 8)
|
||||
node.collect(_image(), "b", 1, "run1", 4, 32, 8)
|
||||
out = node.collect(_image(), "c", 0, "run1", 4, 32, 8)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
assert sorted(p.name for p in run_dir.iterdir()) == [
|
||||
"00_c.png",
|
||||
"grid.png",
|
||||
"grid_preview.png",
|
||||
]
|
||||
assert out["result"][0].shape[2] == 32
|
||||
archived = [p for p in run_dir.parent.iterdir() if p.name.startswith("run1_")]
|
||||
assert len(archived) == 1
|
||||
assert sorted(p.name for p in archived[0].iterdir()) == [
|
||||
"00_a.png",
|
||||
"01_b.png",
|
||||
"grid.png",
|
||||
"grid_preview.png",
|
||||
]
|
||||
|
||||
def test_nonzero_index_keeps_existing_tiles(self, tmp_path):
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(_image(), "a", 0, "run1", 4, 32, 8)
|
||||
node.collect(_image(), "b", 3, "run1", 4, 32, 8)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
assert sorted(p.name for p in run_dir.iterdir()) == [
|
||||
"00_a.png",
|
||||
"03_b.png",
|
||||
"grid.png",
|
||||
"grid_preview.png",
|
||||
]
|
||||
|
||||
def test_index_zero_on_empty_folder_archives_nothing(self, tmp_path):
|
||||
with _fake_output_dir(tmp_path):
|
||||
ModelCompareSheetNode().collect(_image(), "a", 0, "run1", 4, 32, 8)
|
||||
assert [p.name for p in (tmp_path / "model_compare").iterdir()] == ["run1"]
|
||||
|
||||
|
||||
class TestPreview:
|
||||
def test_ui_preview_is_downscaled_but_grid_and_output_stay_full_size(
|
||||
self, tmp_path
|
||||
):
|
||||
with _fake_output_dir(tmp_path):
|
||||
out = ModelCompareSheetNode().collect(
|
||||
_image(h=64, w=32), "a", 0, "run1", 1, 1200, 8
|
||||
)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
assert Image.open(run_dir / "grid.png").size[0] == 1200
|
||||
assert Image.open(run_dir / "grid_preview.png").size[0] == 1024
|
||||
assert out["result"][0].shape[2] == 1200
|
||||
assert out["ui"]["images"][0]["filename"] == "grid_preview.png"
|
||||
|
||||
def test_small_grid_preview_is_not_upscaled(self, tmp_path):
|
||||
with _fake_output_dir(tmp_path):
|
||||
ModelCompareSheetNode().collect(_image(), "a", 0, "run1", 1, 32, 8)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
assert Image.open(run_dir / "grid_preview.png").size[0] == 32
|
||||
|
||||
|
||||
class TestReplaceIndex:
|
||||
@pytest.mark.parametrize(
|
||||
"old_batch, old_label, new_batch, new_label, expected",
|
||||
[
|
||||
(2, "a", 1, "a", ["01_a.png"]),
|
||||
(1, "a", 1, "b", ["01_b.png"]),
|
||||
(1, "a", 1, "A", ["01_A.png"]),
|
||||
(2, "a", 3, "b", ["01_b_1.png", "01_b_2.png", "01_b_3.png"]),
|
||||
(1, "a", 2, "a", ["01_a_1.png", "01_a_2.png"]),
|
||||
(3, "a", 2, "a", ["01_a_1.png", "01_a_2.png"]),
|
||||
],
|
||||
)
|
||||
def test_rerun_replaces_only_its_index(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
old_batch: int,
|
||||
old_label: str,
|
||||
new_batch: int,
|
||||
new_label: str,
|
||||
expected: list[str],
|
||||
) -> None:
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(_image(), "first", 0, "run1", 8, 32, 8)
|
||||
node.collect(_image(batch=old_batch), old_label, 1, "run1", 8, 32, 8)
|
||||
node.collect(_image(), "neighbor", 10, "run1", 8, 32, 8)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
untouched = {
|
||||
name: (run_dir / name).read_bytes()
|
||||
for name in ("00_first.png", "10_neighbor.png")
|
||||
}
|
||||
(run_dir / "notes.txt").write_text("keep this note")
|
||||
archived = run_dir.parent / "run1_archived"
|
||||
archived.mkdir()
|
||||
(archived / "01_a.png").write_bytes(b"archived tile")
|
||||
result = node.collect(
|
||||
torch.ones(new_batch, 64, 32, 3),
|
||||
new_label,
|
||||
1,
|
||||
"run1",
|
||||
8,
|
||||
32,
|
||||
8,
|
||||
prompt={"model": "replacement"},
|
||||
extra_pnginfo={"workflow": {"id": "retry"}},
|
||||
)
|
||||
assert sorted(path.name for path in run_dir.glob("01_*.png")) == expected
|
||||
assert result["result"][0].shape == (1, 80, (new_batch + 2) * 32, 3)
|
||||
assert sorted(path.name for path in run_dir.iterdir()) == sorted(
|
||||
[
|
||||
"00_first.png",
|
||||
"10_neighbor.png",
|
||||
"grid.png",
|
||||
"grid_preview.png",
|
||||
"notes.txt",
|
||||
*expected,
|
||||
]
|
||||
)
|
||||
for name, data in untouched.items():
|
||||
assert (run_dir / name).read_bytes() == data
|
||||
assert (run_dir / "notes.txt").read_text() == "keep this note"
|
||||
assert (archived / "01_a.png").read_bytes() == b"archived tile"
|
||||
for name in expected:
|
||||
with Image.open(run_dir / name) as image:
|
||||
assert image.getpixel((0, 0)) == (255, 255, 255)
|
||||
assert json.loads(image.info["prompt"]) == {"model": "replacement"}
|
||||
assert json.loads(image.info["workflow"]) == {"id": "retry"}
|
||||
|
||||
@pytest.mark.parametrize("index", [0, 1])
|
||||
@pytest.mark.parametrize("failed_image", [1, 2])
|
||||
def test_failed_tile_write_preserves_the_existing_batch_and_grid(
|
||||
self, tmp_path: Path, index: int, failed_image: int
|
||||
) -> None:
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(torch.zeros(2, 64, 32, 3), "same", index, "run1", 8, 32, 8)
|
||||
base = tmp_path / "model_compare"
|
||||
before = {
|
||||
str(path.relative_to(base)): path.read_bytes()
|
||||
for path in base.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
original_save = Image.Image.save
|
||||
tile_writes = 0
|
||||
|
||||
def fail_tile_write(image, path, *args, **kwargs):
|
||||
nonlocal tile_writes
|
||||
if Path(path).name.startswith(f"{index:02d}_"):
|
||||
tile_writes += 1
|
||||
if tile_writes == failed_image:
|
||||
raise OSError("disk full")
|
||||
return original_save(image, path, *args, **kwargs)
|
||||
|
||||
with patch.object(Image.Image, "save", new=fail_tile_write):
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
node.collect(
|
||||
torch.ones(2, 64, 32, 3), "same", index, "run1", 8, 32, 8
|
||||
)
|
||||
assert {
|
||||
str(path.relative_to(base)): path.read_bytes()
|
||||
for path in base.rglob("*")
|
||||
if path.is_file()
|
||||
} == before
|
||||
assert list(base.iterdir()) == [base / "run1"]
|
||||
|
||||
def test_empty_batch_does_not_remove_existing_tiles(self, tmp_path: Path) -> None:
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(_image(), "a", 1, "run1", 8, 32, 8)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
before = {path.name: path.read_bytes() for path in run_dir.iterdir()}
|
||||
with pytest.raises(ValueError, match="at least one image"):
|
||||
node.collect(torch.empty(0, 64, 32, 3), "b", 1, "run1", 8, 32, 8)
|
||||
assert {path.name: path.read_bytes() for path in run_dir.iterdir()} == before
|
||||
|
||||
@pytest.mark.parametrize("failed_image", [1, 2])
|
||||
def test_failed_publication_restores_the_previous_batch(
|
||||
self, tmp_path: Path, failed_image: int
|
||||
) -> None:
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(torch.zeros(2, 64, 32, 3), "same", 1, "run1", 8, 32, 8)
|
||||
base = tmp_path / "model_compare"
|
||||
run_dir = base / "run1"
|
||||
before = {
|
||||
str(path.relative_to(base)): path.read_bytes()
|
||||
for path in base.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
original_replace = os.replace
|
||||
published = 0
|
||||
|
||||
def fail_publication(source, destination):
|
||||
nonlocal published
|
||||
if (
|
||||
Path(destination).parent == run_dir
|
||||
and Path(source).parent.name != "previous"
|
||||
):
|
||||
published += 1
|
||||
if published == failed_image:
|
||||
raise OSError("publication failed")
|
||||
return original_replace(source, destination)
|
||||
|
||||
with patch("os.replace", side_effect=fail_publication):
|
||||
with pytest.raises(OSError, match="publication failed"):
|
||||
node.collect(torch.ones(2, 64, 32, 3), "same", 1, "run1", 8, 32, 8)
|
||||
assert {
|
||||
str(path.relative_to(base)): path.read_bytes()
|
||||
for path in base.rglob("*")
|
||||
if path.is_file()
|
||||
} == before
|
||||
assert list(base.iterdir()) == [run_dir]
|
||||
|
||||
def test_rerun_preserves_directories_with_tile_like_names(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(_image(), "a", 1, "run1", 8, 32, 8)
|
||||
run_dir = tmp_path / "model_compare" / "run1"
|
||||
notes = run_dir / "01_notes.png"
|
||||
notes.mkdir()
|
||||
(notes / "notes.txt").write_text("keep this folder")
|
||||
node.collect(_image(), "b", 1, "run1", 8, 32, 8)
|
||||
assert (notes / "notes.txt").read_text() == "keep this folder"
|
||||
assert (run_dir / "01_b.png").is_file()
|
||||
assert not (run_dir / "01_a.png").exists()
|
||||
|
||||
def test_staging_does_not_expose_temporary_images_to_output_galleries(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with _fake_output_dir(tmp_path):
|
||||
node = ModelCompareSheetNode()
|
||||
node.collect(_image(), "a", 1, "run1", 8, 32, 8)
|
||||
base = tmp_path / "model_compare"
|
||||
run_dir = base / "run1"
|
||||
temporary_images = []
|
||||
original_save = Image.Image.save
|
||||
original_replace = os.replace
|
||||
|
||||
def observe_images() -> None:
|
||||
temporary_images.extend(
|
||||
path for path in base.rglob("*.png") if path.parent != run_dir
|
||||
)
|
||||
|
||||
def save_and_observe(image, path, *args, **kwargs):
|
||||
result = original_save(image, path, *args, **kwargs)
|
||||
observe_images()
|
||||
return result
|
||||
|
||||
def replace_and_observe(source, destination):
|
||||
result = original_replace(source, destination)
|
||||
observe_images()
|
||||
return result
|
||||
|
||||
with patch.object(Image.Image, "save", new=save_and_observe):
|
||||
with patch("os.replace", side_effect=replace_and_observe):
|
||||
node.collect(_image(batch=2), "b", 1, "run1", 8, 32, 8)
|
||||
assert temporary_images == []
|
||||
@@ -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"
|
||||
@@ -187,10 +210,10 @@ class TestGeminiLogic:
|
||||
assert validate_prompt_type("") is False
|
||||
assert validate_prompt_type(None) is False
|
||||
|
||||
@pytest.mark.skip(reason="Requires google-generativeai library")
|
||||
@pytest.mark.skip(reason="Requires google-genai library")
|
||||
def test_analyze_image_with_gemini_success(self):
|
||||
"""Test successful image analysis with Gemini."""
|
||||
pass # Skipped as it requires google-generativeai
|
||||
pass # Skipped as it requires google-genai
|
||||
|
||||
def test_analyze_image_no_api_key(self):
|
||||
"""Test analysis without API key."""
|
||||
@@ -204,10 +227,10 @@ class TestGeminiLogic:
|
||||
assert result == ""
|
||||
assert "API key not found" in error
|
||||
|
||||
@pytest.mark.skip(reason="Requires google-generativeai library")
|
||||
@pytest.mark.skip(reason="Requires google-genai library")
|
||||
def test_analyze_image_with_custom_prompt(self):
|
||||
"""Test analysis with custom prompt."""
|
||||
pass # Skipped as it requires google-generativeai
|
||||
pass # Skipped as it requires google-genai
|
||||
|
||||
|
||||
class TestPromptTemplates:
|
||||
|
||||
@@ -24,17 +24,22 @@ def cache_file(tmp_path, monkeypatch):
|
||||
|
||||
def fake_genai(model_specs):
|
||||
genai = MagicMock()
|
||||
genai.list_models.return_value = [
|
||||
genai.Client.return_value.models.list.return_value = [
|
||||
SimpleNamespace(
|
||||
name=f"models/{name}",
|
||||
display_name=display,
|
||||
supported_generation_methods=methods,
|
||||
supported_actions=methods,
|
||||
)
|
||||
for name, display, methods in model_specs
|
||||
]
|
||||
return genai
|
||||
|
||||
|
||||
def sdk_modules(genai):
|
||||
"""sys.modules patch that makes `from google import genai` return genai."""
|
||||
return {"google": MagicMock(genai=genai), "google.genai": genai}
|
||||
|
||||
|
||||
class TestSortModels:
|
||||
def test_newer_versions_come_first(self):
|
||||
result = models._sort_models(
|
||||
@@ -111,27 +116,25 @@ class TestCache:
|
||||
|
||||
class TestFetchModelsFromApi:
|
||||
def test_missing_sdk_returns_empty(self):
|
||||
with patch.dict(sys.modules, {"google.generativeai": None, "google": None}):
|
||||
with patch.dict(sys.modules, {"google.genai": None, "google": None}):
|
||||
assert models._fetch_models_from_api("k") == ([], {})
|
||||
|
||||
def test_no_api_key_returns_empty(self):
|
||||
genai = fake_genai([])
|
||||
google = MagicMock(generativeai=genai)
|
||||
with (
|
||||
patch.dict(sys.modules, {"google": google, "google.generativeai": genai}),
|
||||
patch.dict(sys.modules, sdk_modules(genai)),
|
||||
patch.object(logic, "get_api_key", return_value=None),
|
||||
):
|
||||
assert models._fetch_models_from_api(None, silent=True) == ([], {})
|
||||
|
||||
def test_api_key_taken_from_logic_when_not_given(self):
|
||||
genai = fake_genai([("gemini-2.5-pro", "Pro", ["generateContent"])])
|
||||
google = MagicMock(generativeai=genai)
|
||||
with (
|
||||
patch.dict(sys.modules, {"google": google, "google.generativeai": genai}),
|
||||
patch.dict(sys.modules, sdk_modules(genai)),
|
||||
patch.object(logic, "get_api_key", return_value="envkey"),
|
||||
):
|
||||
names, _ = models._fetch_models_from_api(None)
|
||||
genai.configure.assert_called_once_with(api_key="envkey")
|
||||
genai.Client.assert_called_once_with(api_key="envkey")
|
||||
assert names == ["gemini-2.5-pro"]
|
||||
|
||||
def test_filters_to_generate_content_and_strips_prefix(self):
|
||||
@@ -142,17 +145,15 @@ class TestFetchModelsFromApi:
|
||||
("gemini-2.5-flash", "Flash", ["generateContent", "countTokens"]),
|
||||
]
|
||||
)
|
||||
google = MagicMock(generativeai=genai)
|
||||
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
|
||||
with patch.dict(sys.modules, sdk_modules(genai)):
|
||||
names, desc = models._fetch_models_from_api("k")
|
||||
assert names == ["gemini-2.5-flash", "gemini-1.5-pro"]
|
||||
assert desc == {"gemini-1.5-pro": "Pro 1.5", "gemini-2.5-flash": "Flash"}
|
||||
|
||||
def test_api_error_returns_empty(self):
|
||||
genai = MagicMock()
|
||||
genai.list_models.side_effect = RuntimeError("boom")
|
||||
google = MagicMock(generativeai=genai)
|
||||
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
|
||||
genai.Client.return_value.models.list.side_effect = RuntimeError("boom")
|
||||
with patch.dict(sys.modules, sdk_modules(genai)):
|
||||
assert models._fetch_models_from_api("k") == ([], {})
|
||||
|
||||
|
||||
@@ -205,20 +206,19 @@ class TestLogicExtra:
|
||||
|
||||
def test_missing_sdk_reported(self):
|
||||
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
|
||||
with patch.dict(sys.modules, {"google.generativeai": None, "google": None}):
|
||||
with patch.dict(sys.modules, {"google.genai": None, "google": None}):
|
||||
out, err = logic.analyze_image_with_gemini(img, "flux", api_key="k")
|
||||
assert out == "" and "not installed" in err
|
||||
|
||||
def _run(self, response=None, error=None, **kwargs):
|
||||
genai = MagicMock()
|
||||
model = genai.GenerativeModel.return_value
|
||||
client_models = genai.Client.return_value.models
|
||||
if error:
|
||||
model.generate_content.side_effect = error
|
||||
client_models.generate_content.side_effect = error
|
||||
else:
|
||||
model.generate_content.return_value = response
|
||||
google = MagicMock(generativeai=genai)
|
||||
client_models.generate_content.return_value = response
|
||||
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
|
||||
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
|
||||
with patch.dict(sys.modules, sdk_modules(genai)):
|
||||
result = logic.analyze_image_with_gemini(img, "flux", api_key="k", **kwargs)
|
||||
return result, genai
|
||||
|
||||
@@ -227,24 +227,28 @@ class TestLogicExtra:
|
||||
SimpleNamespace(text=" hello \n"), model_name="gemini-x"
|
||||
)
|
||||
assert (out, err) == ("hello", None)
|
||||
genai.configure.assert_called_once_with(api_key="k")
|
||||
genai.GenerativeModel.assert_called_once_with("gemini-x")
|
||||
genai.Client.assert_called_once_with(api_key="k")
|
||||
call = genai.Client.return_value.models.generate_content.call_args
|
||||
assert call.kwargs["model"] == "gemini-x"
|
||||
|
||||
def test_custom_prompt_is_sent_as_system_prompt(self):
|
||||
(_, _), genai = self._run(SimpleNamespace(text="x"), custom_prompt="BE TERSE")
|
||||
sent = genai.GenerativeModel.return_value.generate_content.call_args[0][0]
|
||||
sent = genai.Client.return_value.models.generate_content.call_args.kwargs[
|
||||
"contents"
|
||||
]
|
||||
assert sent[0] == "BE TERSE"
|
||||
|
||||
def test_unknown_prompt_type_falls_back_to_flux_template(self):
|
||||
genai = MagicMock()
|
||||
genai.GenerativeModel.return_value.generate_content.return_value = (
|
||||
genai.Client.return_value.models.generate_content.return_value = (
|
||||
SimpleNamespace(text="x")
|
||||
)
|
||||
google = MagicMock(generativeai=genai)
|
||||
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
|
||||
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
|
||||
with patch.dict(sys.modules, sdk_modules(genai)):
|
||||
logic.analyze_image_with_gemini(img, "nonsense", api_key="k")
|
||||
sent = genai.GenerativeModel.return_value.generate_content.call_args[0][0]
|
||||
sent = genai.Client.return_value.models.generate_content.call_args.kwargs[
|
||||
"contents"
|
||||
]
|
||||
assert sent[0] == logic.PROMPT_TEMPLATES["flux"]
|
||||
|
||||
def test_empty_response(self):
|
||||
|
||||