Compare commits

..
Author SHA1 Message Date
Vito eb2db82a80 Merge pull request #106 from ComfyAssets/test/pinned-comfyui-ci
test(ci): automate real ComfyUI checks and prepare 1.2.0
2026-10-16 18:32:11 -07:00
Vito Sansevero 1820fb19ea test(ci): enforce reviewed CPU dependency versions 2026-10-16 17:29:08 -07:00
Vito Sansevero 7edf08f1e2 docs: repair legacy README references 2026-10-16 17:26:23 -07:00
Vito Sansevero 1721778cb5 test(ci): automate pinned ComfyUI checks and prepare 1.2.0 2026-10-16 17:23:11 -07:00
Vito 6bac864c4d Merge pull request #104 from ComfyAssets/fix/follow-execution-lifecycle
fix(follow_execution): honor live settings and stabilize subscriptions
2026-10-16 17:05:02 -07:00
Vito Sansevero 8dc81364a0 test(follow_execution): verify native menus in both renderers 2026-10-16 16:58:34 -07:00
Vito Sansevero 957ca3c142 fix(follow_execution): honor live settings and stabilize subscriptions 2026-10-16 16:50:58 -07:00
Vito ad73422472 Merge pull request #101 from ComfyAssets/chore/deprecate-gemini
chore(gemini): soft-deprecate legacy prompt node
2026-10-16 16:42:35 -07:00
Vito 1a7e0dfcd7 Merge branch 'main' into chore/deprecate-gemini 2026-10-16 16:36:20 -07:00
Vito 0b85600d38 Merge pull request #102 from ComfyAssets/feat/shared-custom-colors
feat(custom_colors): adopt shared ComfyAssets picker
2026-10-16 16:34:34 -07:00
Vito Sansevero ca65cc7961 test(loader): await initial gallery before keyboard controls 2026-10-16 16:25:33 -07:00
Vito Sansevero 6adba639bb feat(custom_colors): adopt shared ComfyAssets picker 2026-10-16 16:17:28 -07:00
Vito Sansevero 1e86b73168 chore(gemini): soft-deprecate legacy prompt node 2026-10-16 15:44:31 -07:00
Vito 0fa595a9b7 Merge pull request #98 from ComfyAssets/feat/shared-embedding-autocomplete
feat(autocomplete): unify UI and restore tag management
2026-10-16 15:38:45 -07:00
Vito Sansevero f8ce4cb982 fix(autocomplete): address review findings and WebKit readiness 2026-10-16 15:31:46 -07:00
Vito Sansevero 42b6009f1b feat(autocomplete): restore tag imports and advanced settings 2026-10-16 15:11:56 -07:00
Vito Sansevero 963ac0a4af docs(autocomplete): document versioned Danbooru tag pack and terms 2026-10-16 14:45:29 -07:00
Vito Sansevero b4bd8b98b1 feat(ui): unify Embedding Autocomplete controls and suggestions 2026-10-16 13:49:11 -07:00
Vito ed6675721d Merge pull request #95 from ComfyAssets/feat/shared-display-views
feat(ui): unify Display Text and Display Any output views
2026-10-16 13:25:23 -07:00
Vito Sansevero b0c0d53676 feat(ui): unify Display Text and Display Any output views 2026-10-16 13:20:28 -07:00
Vito b9103ad9c4 Merge pull request #93 from ComfyAssets/feat/shared-node-dimensions-dialog
feat(ui): migrate Node Dimensions to shared dialog controls
2026-10-16 13:09:13 -07:00
Vito Sansevero b5694ecfe5 feat(ui): migrate Node Dimensions to shared dialog controls 2026-10-16 13:00:36 -07:00
Vito 1255c4e9cc Merge pull request #91 from ComfyAssets/feat/shared-dimension-swap
feat(ui): unify dimension swap controls for both nodes
2026-10-16 12:45:22 -07:00
Vito Sansevero 26557a99b6 feat(ui): unify dimension swap controls for both nodes 2026-10-16 12:35:23 -07:00
Vito 848674d474 Merge pull request #89 from ComfyAssets/feat/shared-workflow-timer
feat(ui): migrate Workflow Timer to shared controls
2026-10-16 12:23:27 -07:00
Vito Sansevero 0c47dc6085 feat(ui): migrate Workflow Timer to shared controls 2026-10-16 12:18:02 -07:00
Vito ee7d1e9011 Merge pull request #87 from ComfyAssets/feat/shared-seed-history
feat(ui): migrate Seed History to shared controls
2026-10-16 12:00:07 -07:00
Vito Sansevero b7a29a1731 feat(ui): migrate Seed History to shared controls 2026-10-16 11:55:57 -07:00
Vito 3cabbffb40 Merge pull request #85 from ComfyAssets/feat/shared-image-viewer
feat(ui): migrate Save Image viewer to shared window controls
2026-10-16 11:37:46 -07:00
Vito Sansevero 47dc177517 feat(ui): migrate saved-image viewer to shared window controls 2026-10-16 11:30:11 -07:00
Vito ddb356fe17 Merge pull request #83 from ComfyAssets/feat/live-image-loader-ui
feat(ui): ship shared ComfyAssets controls in Local Image Loader
2026-10-16 11:08:36 -07:00
Vito Sansevero 9e2b271824 fix(ui): keep gallery thumbnails large and adjustable 2026-10-16 11:03:33 -07:00
Vito Sansevero 2e7fac4b70 docs(ui): capture the loader in both ComfyUI renderers 2026-10-16 10:53:07 -07:00
Vito Sansevero 740eccec27 feat(ui): migrate Local Image Loader to shared ComfyAssets controls 2026-10-16 10:52:28 -07:00
Vito 55bce1004b Merge pull request #79 from ComfyAssets/dependabot/github_actions/actions/setup-node-7
build(deps): bump actions/setup-node from 6 to 7
2026-10-16 09:18:54 -07:00
Vito 63a8596b81 Merge branch 'main' into dependabot/github_actions/actions/setup-node-7 2026-10-16 09:14:46 -07:00
Vito 11494c747c Merge pull request #78 from ComfyAssets/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 6 to 7
2026-10-16 09:14:32 -07:00
Vito 88a9238ee2 Merge branch 'main' into dependabot/github_actions/actions/setup-python-7 2026-10-16 09:10:46 -07:00
Vito 3879f18d79 Merge pull request #72 from ComfyAssets/feat/checkpoint-folder-iterator
feat(xyz_helpers): Checkpoint Folder Iterator and Model Compare Sheet
2026-10-16 09:10:28 -07:00
Vito Sansevero 64f70b6970 fix(model_compare_sheet): hide staging files from output image discovery 2026-10-16 09:03:56 -07:00
Vito Sansevero ae0fb7f229 fix(model_compare_sheet): replace rerun tiles without retaining stale batches 2026-10-16 08:59:29 -07:00
Vito ddf73a667e Merge branch 'main' into feat/checkpoint-folder-iterator 2026-10-16 08:57:09 -07:00
dependabot[bot] 46999c8d90 build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:59 +00:00
dependabot[bot] 852864a270 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:47 +00:00
Vito c182c775f4 Merge pull request #77 from ComfyAssets/test/merge-confidence
test: add extension workflows coverage and required merge checks
2026-10-16 08:46:26 -07:00
Vito Sansevero 02108be2d1 test: cover gallery pagination and directory autocomplete deterministically 2026-10-16 08:42:23 -07:00
Vito Sansevero 2a9fbe78c7 test: allow same-origin WebKit blob requests in network guard 2026-10-16 08:36:38 -07:00
Vito Sansevero 5a8a8b62e4 test: add extension workflows coverage and required merge checks 2026-10-16 08:32:11 -07:00
Vito Sansevero 335cc53961 fix(model_compare_sheet): reject run_name segments on both separators
- Split run_name on "/" and "\" and reject empty, "." and ".." parts, so
  a Windows-style "..\foo" can no longer resolve outside model_compare
  and let the index-0 archive step rename an unrelated output folder
- Reject drive-letter names and require the resolved directory to sit
  strictly below output/model_compare after symlinks are followed
- Nested run names such as project/date/sweep still work
2026-10-16 08:11:17 -07:00
Vito Sansevero c5e00c130b feat(xyz_helpers): add Checkpoint Folder Iterator and Model Compare Sheet
- Checkpoint Folder Iterator loads one checkpoint per queued run from a
  folder (relative to models/checkpoints or absolute), so a whole folder
  can be swept with only one model in memory at a time
- Model Compare Sheet saves each run's image as a tile under
  output/model_compare/<run_name>/ and rebuilds a labeled contact sheet
  from disk every run, so the grid survives between independent prompts
- Starting a sweep at index 0 archives stale tiles to a timestamped folder
- Running past the last checkpoint raises a clear error instead of
  wrapping, so an over-queued batch never restarts and archives a
  finished sweep
- Checkpoints load from the scanned absolute path, so folders outside the
  configured roots work as the tooltip promises
- Extract the LoRA Folder Batch folder-scanning, natural-sort and regex
  filter helpers into kikotools/base/model_folders.py so both tools share
  one implementation
2026-10-16 08:08:00 -07:00
Vito 78f1498ea8 Merge pull request #71 from ComfyAssets/release/1.1.2
Release 1.1.2 - ComfyUI scanner fixes
2026-10-15 04:21:22 -07:00
Vito Sansevero c65f3aad03 chore(release): 1.1.2 with .comfyignore for registry archive
- Add .comfyignore so `comfy node publish` ships only the runtime
  package: __init__.py, kikotools/, web/, examples/, README, LICENSE,
  pyproject.toml, requirements.txt
- Exclude tests/, scripts/, .github/, dev tooling configs, contributor
  docs, and the stray example.py reference file (not imported anywhere)
- Registry scanner flagged 1.1.1 on five info-level pattern matches;
  two were in tests/ and the historical Model.eval matches came from
  example.py, none of which should be in the published archive
- Bump version to 1.1.2
2026-10-15 04:16:17 -07:00
Vito 9a55c23282 Merge pull request #70 from ComfyAssets/release/1.1.1
Release 1.1.1
2026-10-13 18:36:23 -07:00
Vito Sansevero e90bde5849 chore: release 1.1.1
Gemini Prompt node migrated from the deprecated google-generativeai SDK to google-genai.
2026-10-13 18:29:24 -07:00
Vito Sansevero 49523e10de chore(gemini_prompt): migrate to google-genai SDK
- google-generativeai is deprecated and emits a FutureWarning at node
  load; google-genai is Google's supported replacement
- Image analysis now uses genai.Client().models.generate_content
  instead of the module-level configure/GenerativeModel API
- Model listing reads supported_actions, the new SDK's name for
  supported_generation_methods
- Install instructions, requirements.txt, and README updated to the
  new package name
2026-10-13 18:27:52 -07:00
Vito 8e6cf4233a Merge pull request #69 from ComfyAssets/release/1.1.0
Release 1.1.0
2026-10-13 08:12:57 -07:00
Vito Sansevero 6301cf90f8 chore: release 1.1.0
Security hardening and fixes (#64), Project File Path node (#65, #67), runtime state untracked (#66), dependency bumps (#68).
2026-10-13 08:01:04 -07:00
Vito f87eb959b3 Merge pull request #68 from ComfyAssets/chore/dependency-bumps
Apply pending Dependabot bumps (#56-#63)
2026-10-13 07:47:06 -07:00
Vito Sansevero 7783ed8a16 build(deps): apply pending Dependabot bumps
Combines #56-#63:
- torch >=2.11.0, pillow >=12.2.0, mypy >=1.20.0, pytest-mock >=3.15.1,
  pre-commit >=4.5.1 in both pyproject.toml and requirements-dev.txt
  (Dependabot only edited pyproject, but CI installs requirements-dev.txt)
- actions/checkout v7, actions/cache v6, softprops/action-gh-release v3
  across all workflows, including the new test/coverage/js jobs
2026-10-13 07:44:16 -07:00
Vito 726fcee761 Merge pull request #66 from ComfyAssets/chore/untrack-runtime-state
Stop tracking runtime state files
2026-10-13 07:42:21 -07:00
Vito 98f3defe50 Merge pull request #67 from ComfyAssets/fix/project-file-path-output-label
Project File Path: label output STRING like djz-nodes
2026-10-13 07:41:55 -07:00
Vito Sansevero 143a6c47f6 fix(project_file_path): label the output STRING like djz-nodes
Drop RETURN_NAMES so ComfyUI shows the output as STRING, matching the original Project File Path Generator. Links connect by slot index, so existing workflows are unaffected.
2026-10-13 07:38:33 -07:00
Vito Sansevero 76a52765c7 chore: stop tracking runtime state files
The image loader's config.json/selections.json (one user's saved folders
and selections) and the Gemini model cache were committed, so every
install shipped them as defaults, and running from a symlinked checkout
dirtied the tree. Untrack them and ignore them, plus gemini_config.json
which would hold an API key. All loaders already handle the files being
absent.
2026-10-13 07:37:27 -07:00
Vito f308b23631 Merge pull request #65 from ComfyAssets/feature/project-file-path
Add Project File Path node
2026-10-13 07:32:25 -07:00
Vito Sansevero d65c11c76c feat(project_file_path): add Project File Path node
Builds a save prefix like MyProject/2026-10-13/images/image for a save
node's filename_prefix, replacing djz-nodes' Project File Path Generator
with a fresh implementation:

- Same inputs, root defaults to empty (prefixes are already relative to
  the output folder, so "output" nested as output/output/...)
- Optional date folder; IS_CHANGED tracks the date instead of forcing a
  re-run on every queue
- Components are split on / and \ with '..' segments dropped, so a path
  can't climb out of the output folder
- Only real file extensions are stripped (render_v1.5 keeps its dot)
- The separator option is applied last instead of being undone by
  normpath; an empty filename raises a clear error
2026-10-13 07:27:49 -07:00
Vito 1879f42f83 Merge pull request #64 from ComfyAssets/fix/review-security-and-coverage
Security hardening, crash fixes, cross-platform CI and 95% coverage
2026-10-13 07:26:02 -07:00
147 changed files with 10572 additions and 4701 deletions
+29
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@
max-line-length = 127
max-complexity = 10
exclude =
node_modules,
.git,
__pycache__,
.mypy_cache,
+16
View File
@@ -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
}
+4
View File
@@ -8,3 +8,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/tools/testing"
schedule:
interval: "weekly"
+9 -7
View File
@@ -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
+1 -1
View File
@@ -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 -37
View File
@@ -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"
+132 -12
View File
@@ -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
+14
View File
@@ -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/
+20 -3
View File
@@ -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
+46
View File
@@ -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.
+38
View File
@@ -0,0 +1,38 @@
# Changelog
## 1.2.0
### Unified ComfyAssets UI
- Consistent blue controls and strong red destructive actions that follow
ComfyUI's light/dark theme.
- Updated Local Image Loader, Save Image viewer, Seed History, Workflow Timer,
dimension swaps, Node Dimensions, Display Text/Any and Embedding Autocomplete.
- Larger adjustable gallery thumbnails and saved-image previews that use the
available space; responsive layouts, keyboard controls and lifecycle cleanup.
- Shared Custom Colors picker with hex entry, HSV sliders and presets.
- Restored autocomplete tag management, four-column CSV parsing, worker-based
imports and persistent tag caching. Tag packs remain a separate download;
see [tag-pack provenance and terms](docs/tag-packs/README.md).
- Follow Execution honors live enablement, avoids duplicate subscriptions and
safely groups navigation menus.
### Compatibility and testing
- Classic and Nodes 2.0 coverage includes fresh startup in each renderer,
switching both ways, themes, saved workflows and removal cleanup.
- Required CPU-only real-ComfyUI CI joins the existing Python, JavaScript,
Chromium, touch, Firefox and WebKit suites. Runtime pins and failure artifacts
make compatibility checks repeatable.
- Retained [screenshots](docs/screenshots/README.md) and documented
[testing commands](TESTING.md).
### Deprecation and upgrade
Gemini Prompt Engineer is deprecated and hidden from normal new-node discovery.
Its registration, schema and execution remain available for saved workflows;
ongoing Google API/model compatibility maintenance has ended.
Restart ComfyUI and hard-refresh the browser after upgrading. Existing node
interfaces, workflow values and setting IDs are retained. No workflow conversion
is required. Follow Execution remains opt-in through its Enable setting.
+4 -1
View File
@@ -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
+20 -9
View File
@@ -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..."
+123 -46
View File
@@ -1,7 +1,7 @@
# ComfyUI-KikoTools
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)
![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
![ComfyUI](https://img.shields.io/badge/ComfyUI-compatible-green.svg)
![Tests](https://github.com/ComfyAssets/ComfyUI-KikoTools/workflows/Tests/badge.svg)
![Code Quality](https://github.com/ComfyAssets/ComfyUI-KikoTools/workflows/Code%20Quality/badge.svg)
@@ -17,6 +17,20 @@ I tried forking, patching, and submitting merge requests, but eventually decided
I’m sharing them here with the community, and I hope you find them as useful as I do.
## 1.2.0 — unified ComfyAssets UI
A consistent interface across the KikoTools controls, with ComfyUI-following
light/dark styling and Classic/Nodes 2.0 support. Existing workflow interfaces
and settings are retained. See the [release notes](CHANGELOG.md),
[migration overview](docs/ui-migration.md) and [screenshots](docs/screenshots/).
## ComfyUI Classic and Nodes 2.0
KikoTools supports both ComfyUI Classic and Nodes 2.0. Our shared ComfyAssets
controls follow ComfyUI's dark/light theme, with automated tests covering renderer
switching, workflow persistence and cleanup. See the retained
[UI screenshots](docs/screenshots/) for examples from the migrations.
## 🚀 Features
### ✨ Current Tools
@@ -30,7 +44,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images |
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display |
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts |
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | Deprecated; retained for existing workflows | 🧠 Prompts |
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
@@ -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
![Display Text Example](examples/workflows/display_text_example.png)
#### 🤖 Gemini Prompt Engineer
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation
- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details
- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions
- **Custom System Prompts**: Override templates with your own analysis instructions
- **Flexible API Key Management**: Environment variable, config file, or direct input
- **Visual Status Feedback**: Real-time processing indicators and error states
- **Help Integration**: Built-in setup guide and documentation
- **Dynamic Model Refresh**: Fetch latest Gemini models with refresh button
- **Model Caching**: Persistent model list storage for offline access
- **Enhanced SDXL Prompts**: Improved formatting with layered structure and quality boosters
**Deprecated — retained for existing workflows only.** ComfyUI hides this node
from normal new-node discovery using its deprecated-node metadata. The
`GeminiPrompt` identifier, inputs, outputs and execution remain available so
saved workflows can still load.
**Use Cases:**
- Reverse-engineer prompts from reference images
- Convert artistic descriptions between different AI model formats
- Generate consistent style descriptions across workflows
- Create detailed scene breakdowns for complex compositions
- Analyze and replicate lighting/mood from existing artwork
- Access latest Gemini models including 2.0 and 2.5 versions
![Gemini Prompt Example](examples/workflows/gemini_prompt_example.png)
Google API/model compatibility updates and UI migration have ended. Existing
workflows still depend on Google's service and the optional `google-genai`
package; deprecation does not guarantee future API compatibility. See the
[legacy Gemini documentation](examples/documentation/gemini_prompt.md) for the
existing configuration and behavior.
#### 🔍 Display Any
Universal debugging node that displays any type of input value or tensor information.
@@ -300,6 +306,28 @@ Automated batch processing for multiple LoRA models from folders.
![LoRA Folder Batch Example](examples/workflows/xyz_helpers_lora_testing.png)
#### 🔁 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
![Model Downloader Example](examples/workflows/model_downloader_example.png)
#### ⏱️ 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.
+166
View File
@@ -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.
+22
View File
@@ -0,0 +1,22 @@
# ComfyAssets UI screenshots
Retained captures from the KikoTools UI migrations. Real-ComfyUI tests cover
Classic and Nodes 2.0, renderer switching, saved workflows and cleanup.
| Interface | Captures |
| --- | --- |
| Local Image Loader | [Classic](live-image-loader/classic.png), [Nodes 2.0](live-image-loader/nodes2.png) |
| Saved-image viewer | [Dark](save-image-viewer/dark.png), [Light](save-image-viewer/light.png) |
| Seed History | [Dark](seed-history/dark.png), [Light](seed-history/light.png) |
| Workflow Timer | [Dark](workflow-timer/dark.png), [Light](workflow-timer/light.png), [Color picker](workflow-timer/color-picker.png) |
| Dimension swap | [Selector dark](dimension-swap/selector-dark.png), [Selector light](dimension-swap/selector-light.png), [Latent dark](dimension-swap/latent-dark.png), [Latent light](dimension-swap/latent-light.png) |
| Node Dimensions | [Dark](node-dimensions/dark.png), [Light](node-dimensions/light.png) |
| Display Text | [Dark](display-output/text-dark.png), [Light](display-output/text-light.png) |
| Display Any | [Dark](display-output/any-dark.png), [Light](display-output/any-light.png) |
| Embedding Autocomplete | [Suggestions dark](embedding-autocomplete/suggestions-dark.png), [Suggestions light](embedding-autocomplete/suggestions-light.png), [Settings dark](embedding-autocomplete/settings-dark.png), [Settings light](embedding-autocomplete/settings-light.png) |
| Custom Colors | [Dark](custom-colors/dark.png), [Light](custom-colors/light.png) |
| Follow Execution | [Classic](follow-execution/classic.png), [Nodes 2.0](follow-execution/nodes2.png) |
Screenshots record the tested appearance at capture time. Updated Playwright
captures are temporary test artifacts until copied into this directory and
committed for review.
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+48
View File
@@ -0,0 +1,48 @@
# Autocomplete tag packs
ComfyAssets prepares filtered autocomplete data from Danbooru's public
`tags.json` and `tag_aliases.json` APIs. Dataset releases are versioned separately
from KikoTools and stored as release assets, keeping bulk data out of Git history.
## Danbooru — Illustrious / NoobAI, 2026-10-16
[Download the CSV and manifest](https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16).
This snapshot contains 165,158 canonical tags, their categories, post counts, and
aliases. The adjacent manifest records the retrieval date, model targets,
selection filters, schema, byte size, and SHA-256 checksum.
The UTF-8 CSV has no header. Columns are `tag,category,post_count,aliases`.
Aliases share a comma-separated field; use a CSV parser that respects quoting.
An empty alias field means no aliases. Category codes are 0 (general), 1
(artist), 3 (copyright), 4 (character), and 5 (meta).
In ComfyUI Settings, search **Embedding Autocomplete**, open its settings,
then download the pack and choose the CSV under **Tag list file**. Enable
**Show Custom Words** and select **Save Settings**. Importing stages the list;
Cancel leaves the active list unchanged. The browser caches the saved file in
IndexedDB for offline use and restores it after a refresh. Clearing browser site
data removes this cache. There is no server-side autocomplete folder to populate.
Alternatively, enter a CSV/TXT URL and select **Load URL**, then save. Remote
servers must allow browser cross-origin requests; for GitHub release assets,
download and import the file locally. Existing one-column word lists and
`tag,frequency` lists remain supported. The default URL is unchanged.
Parsing runs in a Web Worker with a 32 MB UTF-8 limit. Search checks canonical
names and aliases, inserts the canonical tag, and ranks exact matches ahead of
prefixes and substrings, then by descending post count. The number of displayed
results is bounded by Maximum suggestions. Categories are retained in records;
category-specific controls are not currently exposed.
## Source and terms
Source data: [Danbooru](https://danbooru.donmai.us/), collected 2026-10-16.
Selection and packaging: ComfyAssets. This is an independent filtered snapshot,
not an official Danbooru release or a guarantee of model compatibility.
Refer to [Danbooru's Terms of Service](https://danbooru.donmai.us/terms) and the
[official terms template](https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb).
The template's Copyright Infringement section identifies tags as factual
information that is generally not copyrightable. Its API Usage section requires
compliance with API rate limits. This reference does not claim an explicit
dataset license or extend KikoTools' software license to upstream data.
@@ -0,0 +1,35 @@
{
"source": "danbooru.donmai.us tags.json + tag_aliases.json",
"pulled": "2026-10-16",
"target_models": "Illustrious 1.0-3.x, NoobAI-XL",
"filters": {
"created_before": "2025-01-01",
"min_post_count": {
"general": 50,
"artist": 30,
"copyright": 20,
"character": 20
},
"artists_kept": true,
"deprecated_removed": true,
"meta_tags": "removed except small allowlist"
},
"tag_count": 165158,
"schema_version": 1,
"file": "danbooru-illustrious-noobai-2026-10-16.csv",
"encoding": "UTF-8",
"header_row": false,
"columns": [
"tag",
"category",
"post_count",
"aliases"
],
"aliases_format": "Comma-separated inside a CSV-quoted field when necessary",
"size_bytes": 3979593,
"sha256": "733531a60f2e2d38925f53381b18cff21513ef14550d1f2ca670a0f55b86b798",
"prepared_by": "ComfyAssets",
"terms_url": "https://danbooru.donmai.us/terms",
"terms_source_url": "https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb",
"release_tag": "tags-2026-10-16"
}
+44
View File
@@ -0,0 +1,44 @@
# KikoTools UI migration
KikoTools' custom controls use the original ComfyAssets foundation in `web/ui/`.
They share blue primary actions, red destructive actions, keyboard controls and
ComfyUI-following dark/light colors. Layouts resize with the available space.
Pixaroma's toolkit inspired the organization; its code/assets are not vendored.
| Interface | Shared implementation | Retained examples |
| --- | --- | --- |
| Local Image Loader | `views/image-browser.js`, controls and scoped controller | [Loader](screenshots/live-image-loader/) |
| Save Image | Floating window, batch selection and responsive preview | [Viewer](screenshots/save-image-viewer/) |
| Seed History | `views/seed-history.js`, scoped watcher and disclosure | [History](screenshots/seed-history/) |
| Workflow Timer | Shared clock, controls and color picker | [Timer](screenshots/workflow-timer/) |
| Width Height / Empty Latent swaps | `ui/dimension-swap.js`, `ui/dimensions.js` | [Swaps](screenshots/dimension-swap/) |
| Node Dimensions | `views/node-dimensions.js`, native dialog shell | [Dimensions](screenshots/node-dimensions/) |
| Display Text / Any | Shared output view, controller and widget adapter | [Output](screenshots/display-output/) |
| Embedding Autocomplete | Shared suggestions/settings, worker parsing and cache | [Autocomplete](screenshots/embedding-autocomplete/) |
| Custom Colors | Shared color picker, native node/group menus | [Colors](screenshots/custom-colors/) |
| Follow Execution | Native host menus/settings, idempotent execution listeners | [Menus](screenshots/follow-execution/) |
`createScope()` owns listeners, observers and delayed work. Node removal preserves
previous lifecycle hooks while disposing owned resources. Host-specific text
widget handling lives in `ui/text-widget-adapter.js`; floating windows and native
dialogs manage their own focus and cleanup. See [module notes](../web/ui/README.md).
Pure Python nodes continue using ComfyUI's native widgets. Gemini Prompt
Engineer retains its legacy UI for saved workflows under the agreed deprecation;
its UI migration was cancelled.
The [test guide](../TESTING.md) distinguishes fixture coverage, real-host
compatibility and model/GPU behavior. Required real-host CI uses a reviewed
upstream commit, fresh Classic/Nodes 2.0 starts and disposable CPU-only state.
## Remaining shared-toolset work
Before PromptManager adopts the foundation, extract a versioned shared source
package with reproducible browser artifacts. Then test both node packs alone and
together, including different bundled foundation versions, isolated settings
and identical styling. These requirements remain open in issue #81; the KikoTools
migration does not establish cross-pack compatibility by itself.
The approved design-preview draft [#82](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
records the original direction. This inventory describes the shipped interfaces
and supersedes that draft's implementation sequence.
+15 -4
View File
@@ -1,4 +1,15 @@
# Gemini Prompt Engineer
# Gemini Prompt Engineer (Deprecated)
**Legacy documentation for existing workflows.** This node is soft-deprecated
and hidden from normal new-node discovery through ComfyUI's deprecated-node
metadata. Users who enable deprecated-node visibility may still find it.
The `GeminiPrompt` registration, display name, input/output schema and execution
remain available for saved workflows. Existing configuration and model-refresh
behavior remain in place. Google API/model compatibility updates and UI migration
have ended; the historical model and setup details below may no longer match
Google's current service. No replacement or automatic workflow conversion is
provided.
The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models.
@@ -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
+9
View File
@@ -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
}
+106
View File
@@ -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
}
+7 -13
View File
@@ -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
+5 -5
View File
@@ -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)
+6 -1
View File
@@ -12,6 +12,8 @@ from .models import get_available_models
class GeminiPromptNode(ComfyAssetsBaseNode):
"""Analyzes images using Gemini AI to generate optimized prompts for various AI models."""
DEPRECATED = True
@classmethod
def INPUT_TYPES(cls):
"""Define input types for the node."""
@@ -54,6 +56,9 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = """
Deprecated: retained for existing workflows only. This node no longer receives
Google API/model compatibility updates.
Analyzes images using Google's Gemini AI to generate optimized prompts.
Supports multiple prompt formats:
@@ -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)
+125
View File
@@ -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"]
+107
View File
@@ -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)
+89
View File
@@ -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
+8 -6
View File
@@ -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"]
+6 -5
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for ComfyUI-KikoTools
# Gemini API integration (optional - only needed for Gemini Prompt node)
google-generativeai
google-genai
View File
+118
View File
@@ -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';
+182
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+877
View File
@@ -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([]);
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readTagResponse } from '../../web/autocomplete/tag-download.js';
function response(chunks, headers = {}) {
let index = 0, cancelled = false;
const body = new ReadableStream({
pull(controller) { if (index < chunks.length) controller.enqueue(chunks[index++]); else controller.close(); },
cancel() { cancelled = true; },
}, { highWaterMark: 0 });
return { value: new Response(body, { headers }), cancelled: () => cancelled, read: () => index };
}
const encode = value => new TextEncoder().encode(value);
test('tag downloads stop reading at the byte limit with missing or dishonest headers', async () => {
for (const headers of [{}, { 'content-length': '1' }]) {
const stream = response([encode('1234'), encode('56789'), encode('unread')], headers);
await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i);
assert.equal(stream.cancelled(), true);
assert.equal(stream.read(), 2);
assert.equal(stream.value.body.locked, false);
}
});
test('oversized declared downloads are cancelled without reading their contents', async () => {
const stream = response([encode('unread')], { 'content-length': '100' });
await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i);
assert.equal(stream.read(), 0);
assert.equal(stream.cancelled(), true);
});
test('streamed UTF-8 survives split code points and an exact byte limit', async () => {
const bytes = encode('日本語');
const stream = response([bytes.slice(0, 2), bytes.slice(2, 5), bytes.slice(5)]);
assert.equal(await readTagResponse(stream.value, { maxBytes: bytes.length }), '日本語');
assert.equal(stream.value.body.locked, false);
assert.equal(await readTagResponse(new Response(null)), '');
});
test('aborted or failed downloads release their reader and preserve the error', { timeout: 2000 }, async () => {
const controller = new AbortController();
let cancelled = false;
const stream = new ReadableStream({ cancel() { cancelled = true; } });
const value = new Response(stream);
const pending = readTagResponse(value, { signal: controller.signal });
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(cancelled, true);
assert.equal(value.body.locked, false);
const stopped = response([encode('unread')]);
await assert.rejects(readTagResponse(stopped.value, { signal: AbortSignal.abort() }), { name: 'AbortError' });
assert.equal(stopped.cancelled(), true);
const error = new Error('Network interrupted');
const failed = new Response(new ReadableStream({ start(stream) { stream.error(error); } }));
await assert.rejects(readTagResponse(failed), /Network interrupted/);
assert.equal(failed.body.locked, false);
});
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { suggestionPosition } from '../../web/ui/autocomplete-suggestions.js';
test('completion popup stays within the viewport and flips above a low field', () => {
assert.deepEqual(suggestionPosition({ left: 20, top: 40, bottom: 80 }, { width: 220, height: 150 }, { width: 800, height: 600 }), { left: 20, top: 84 });
assert.deepEqual(suggestionPosition({ left: 300, top: 400, bottom: 440 }, { width: 220, height: 150 }, { width: 320, height: 480 }), { left: 92, top: 246 });
assert.deepEqual(suggestionPosition({ left: -30, top: 2, bottom: 10 }, { width: 300, height: 300 }, { width: 240, height: 200 }), { left: 8, top: 8 });
});
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { tagCache, parseTagFile } from '../../web/autocomplete/tag-storage.js';
test('a blocked cache operation cannot write after its caller has received an error', async () => {
const request = {};
let closed = false, wrote = false;
globalThis.indexedDB = { open: () => request };
try {
const operation = tagCache({ source: 'new-file' });
request.onblocked();
await assert.rejects(operation, /blocked/);
request.result = { close: () => { closed = true; }, transaction: () => { wrote = true; throw new Error('Abandoned operation wrote data'); } };
request.onsuccess();
assert.equal(closed, true);
assert.equal(wrote, false);
} finally { delete globalThis.indexedDB; }
});
test('cancelled imports never start workers, and running workers terminate on cancellation or failure', async () => {
await assert.rejects(parseTagFile('tag', AbortSignal.abort()), { name: 'AbortError' });
let current;
globalThis.Worker = class {
constructor() { current = this; this.terminated = false; }
postMessage() {}
terminate() { this.terminated = true; }
};
try {
const controller = new AbortController();
const aborted = parseTagFile('tag', controller.signal);
controller.abort();
await assert.rejects(aborted, { name: 'AbortError' });
assert.equal(current.terminated, true);
const failed = parseTagFile('tag', new AbortController().signal);
current.onerror();
await assert.rejects(failed, /Could not parse/);
assert.equal(current.terminated, true);
} finally { delete globalThis.Worker; }
});
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTagList, searchTags, tagQuery } from '../../web/autocomplete/tags.js';
test('four-column packs preserve categories, popularity and quoted aliases', () => {
const tags = parseTagList('\ufefftag,category,post_count,aliases\r\nblue_eyes,0,900,"blue_eye,azure_eyes"\r\nartist_name,1,700,"artist,artist_alias"\r\n');
assert.deepEqual(tags.map(({ name, category, frequency, aliases }) => ({ name, category, frequency, aliases })), [
{ name: 'blue_eyes', category: 0, frequency: 900, aliases: ['blue_eye', 'azure_eyes'] },
{ name: 'artist_name', category: 1, frequency: 700, aliases: ['artist', 'artist_alias'] },
]);
assert.equal(searchTags(tags, 'azure', 5)[0].value, 'blue_eyes');
});
test('legacy words, frequencies and headerless packs remain compatible', () => {
assert.deepEqual(parseTagList('low,2\nhigh,100\nplain\n').map(tag => [tag.name, tag.frequency]), [['high', 100], ['low', 2], ['plain', 0]]);
assert.equal(parseTagList('long_hair,0,6317335,')[0].frequency, 6317335);
assert.equal(parseTagList('"say""hello",0,12,"nickname"')[0].name, 'say"hello');
});
test('invalid data fails clearly instead of replacing a valid list with garbage', () => {
for (const text of ['', '<!doctype html><html>error</html>', '<script>alert(1)</script>', 'x,0,NaN,', 'x,2,12,', 'x,-1', '"unfinished', 'x,0,1,a,extra', 'x,0,1,"a"tail', 'name,category,post_count,aliases\n']) {
assert.throws(() => parseTagList(text), Error, text);
}
assert.throws(() => parseTagList('x'.repeat(32 * 1024 * 1024 + 1)), /large/i);
});
test('duplicates merge aliases while retaining the highest frequency', () => {
const tags = parseTagList('blue_eyes,0,100,azure\nblue_eyes,0,200,blue_eye');
assert.equal(tags.length, 1);
assert.equal(tags[0].frequency, 200);
assert.deepEqual(tags[0].aliases, ['azure', 'blue_eye']);
});
test('search ranks exact canonical and alias matches, prefixes, then substrings by popularity', () => {
const tags = parseTagList('blue_sky,0,100,azure\nblue_eyes,0,900,\nlight_blue,0,500,\nblue,0,1,\n');
assert.deepEqual(searchTags(tags, 'blue', 3).map(tag => tag.name), ['blue', 'blue_eyes', 'blue_sky']);
assert.equal(searchTags(tags, 'AZURE', 1)[0].name, 'blue_sky');
assert.deepEqual(searchTags(tags, 'missing', 3), []);
assert.deepEqual(searchTags(tags, '', 0), []);
assert.deepEqual(searchTags(tags, '', 2).map(tag => tag.name), ['blue_eyes', 'light_blue']);
});
test('search handles 300,000 tags with bounded results and canonical alias insertion', () => {
const text = Array.from({ length: 300000 }, (_, i) => `tag_${i},0,${i},alias_${i}`).join('\n');
const tags = parseTagList(text);
assert.equal(tags.length, 300000);
const matches = searchTags(tags, 'alias_299999', 20);
assert.equal(matches.length, 1);
assert.equal(matches[0].value, 'tag_299999');
});
test('tag data supports Unicode, emoticons and quoted commas as plain text', () => {
const tags = parseTagList('heart,0,50,<3\n>_<,0,20,\n日本語,3,10,"別名,にほんご"');
assert.equal(searchTags(tags, '<3', 1)[0].value, 'heart');
assert.equal(searchTags(tags, 'にほんご', 1)[0].value, '日本語');
assert.equal(searchTags(tags, '>_<', 1)[0].value, '>_<');
});
test('worker reports parsed tag records and errors through its message boundary', async () => {
const messages = [];
globalThis.self = { postMessage: message => messages.push(message) };
try {
await import('../../web/autocomplete/tag-worker.js');
self.onmessage({ data: 'blue_eyes,0,20,azure' });
self.onmessage({ data: 'bad,0,invalid,' });
assert.equal(messages[0].tags[0].frequency, 20);
assert.match(messages[1].error, /post count/);
} finally { delete globalThis.self; }
});
test('prompt query boundaries preserve Unicode, punctuation and surrounding weighting syntax', () => {
assert.deepEqual(tagQuery('sky, 日本'), { prefix: '日本', start: 5 });
assert.deepEqual(tagQuery('(o_o)'), { prefix: '(o_o)', start: 0 });
assert.deepEqual(tagQuery('<3'), { prefix: '<3', start: 0 });
assert.deepEqual(tagQuery('sky, (日本', true), { prefix: '日本', start: 6 });
assert.deepEqual(tagQuery('hatsune_miku_(vocaloid'), { prefix: 'hatsune_miku_(vocaloid', start: 0 });
assert.deepEqual(tagQuery('foo\nmiku/vocal'), { prefix: 'miku/vocal', start: 4 });
assert.deepEqual(tagQuery('🎨🎨'), { prefix: '🎨🎨', start: 0 });
assert.equal(tagQuery('sky, '), null);
assert.equal(tagQuery('(', true), null);
});
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
const output = execFileSync(process.execPath, ['tools/testing/node_modules/@playwright/test/cli.js',
'test', '--config', 'tools/testing/comfyui.config.mjs', '--list'], {
cwd: root, encoding: 'utf8', env: { ...process.env,
KIKO_COMFY_ROOT: '', KIKO_COMFY_URL: 'http://127.0.0.1:8199/' },
});
assert.match(output, /\[classic-start\]/);
assert.match(output, /\[nodes2-start\]/);
});
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { launchComfyHost } from '../../tools/testing/comfyui-host.mjs';
async function fixture(t, program) {
const directory = await mkdtemp(join(tmpdir(), 'kiko-host-test-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const checkout = join(directory, 'host');
const repository = join(directory, 'plugin');
await mkdir(checkout);
await mkdir(repository);
await writeFile(join(repository, '__init__.py'), '');
await writeFile(join(checkout, 'main.py'), program);
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
git(['init', '--quiet']);
git(['add', 'main.py']);
git(['-c', 'user.name=Kiko Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
return { checkout, repository, python: process.execPath, port: 8199,
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
}
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
test('managed ComfyUI host uses disposable CPU/offline state and preserves source directories', async t => {
const options = await fixture(t, idle);
const host = await launchComfyHost(options);
t.after(() => host.stop());
await once(host.child.stdout, 'data');
const args = host.child.spawnargs;
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'kikotools');
assert.equal(await realpath(join(host.stateDirectory, 'custom_nodes/kikotools')), await realpath(options.repository));
await access(join(host.stateDirectory, 'user'));
await host.stop();
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
await access(join(options.checkout, 'main.py'));
await access(join(options.repository, '__init__.py'));
});
test('managed ComfyUI host rejects a mismatched or modified upstream checkout', async t => {
const options = await fixture(t, idle);
await assert.rejects(launchComfyHost({ ...options, pins: { revision: '0'.repeat(40) } }), /revision/i);
await writeFile(join(options.checkout, 'main.py'), 'modified');
await assert.rejects(launchComfyHost(options), /modified|dirty/i);
});
test('managed ComfyUI host preserves crash diagnostics, exit status and cleanup', async t => {
const options = await fixture(t, `console.error('startup failed'); process.exit(7);`);
const host = await launchComfyHost(options);
const result = await host.finished;
assert.equal(result.code, 7);
assert.match(await readFile(options.logPath, 'utf8'), /startup failed/);
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
});
test('managed ComfyUI host reports spawn failures and releases its temporary state', async t => {
const options = await fixture(t, idle);
const host = await launchComfyHost({ ...options, python: join(options.checkout, 'missing-python') });
const result = await host.finished;
assert.equal(result.code, 1);
assert.match(result.error.message, /ENOENT/);
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
});
test('managed ComfyUI host fails before spawning when its log cannot be opened', async t => {
const options = await fixture(t, `require('node:fs').writeFileSync(process.argv[1] + '.started', 'yes');`);
await assert.rejects(launchComfyHost({ ...options, logPath: options.checkout }), /EISDIR|directory/i);
await assert.rejects(access(join(options.checkout, 'main.py.started')), { code: 'ENOENT' });
});
+35
View File
@@ -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 }); }
});
+60
View File
@@ -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]);
});
+38
View File
@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { outputSections } from '../../web/views/text-output.js';
import { restoreDisplayOutput } from '../../web/controllers/display-node.js';
test('display text recognizes paired prompts and preserves ordinary text verbatim', () => {
assert.deepEqual(outputSections('text', 'Positive prompt: bright ** metadata\nNegative prompt: blur ** metadata').map(section => section.text), ['bright', 'blur']);
assert.deepEqual(outputSections('text', 'POSITIVE PROMPT: sun\nNEGATIVE PROMPT: rain').map(section => section.text), ['sun', 'rain']);
for (const text of ['', 'Positive prompt: alone', '<script>hello</script>\nnext']) {
assert.deepEqual(outputSections('text', text).map(section => section.text), [text]);
}
const long = Array.from({ length: 45 }, (_, index) => `Line ${index}`).join('\n');
assert.deepEqual(outputSections('any', long).map(section => section.text), [long]);
});
test('versioned display snapshots preserve complete output including empty text', () => {
for (const kind of ['text', 'any']) {
for (const text of ['', 'mode\nfull\noutput']) {
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 1, text }, widgets_values: ['stale'] }, kind, false), text);
}
assert.equal(restoreDisplayOutput({}, kind, false), null);
assert.equal(restoreDisplayOutput({ widgets_values: [] }, kind, false), null);
}
});
test('legacy display workflows restore prompt pairs, converted inputs and every chunk', () => {
assert.equal(restoreDisplayOutput({ widgets_values: ['✓ Positive Prompt', 'sun', null, '✗ Negative Prompt', 'rain', null] }, 'text', false), 'Positive prompt: sun\nNegative prompt: rain');
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'saved text', null] }, 'text', true), 'saved text');
assert.equal(restoreDisplayOutput({ widgets_values: ['raw value', 'first\nchunk', 'second\nchunk', null] }, 'any', false), 'first\nchunk\nsecond\nchunk');
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'tensor shape', 'shape', null] }, 'any', true), 'shape');
assert.equal(restoreDisplayOutput({ widgets_values: ['tensor shape'] }, 'any', false), null);
assert.equal(restoreDisplayOutput({ widgets_values: [''] }, 'text', false), '');
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 99, text: 'unknown' }, widgets_values: ['legacy'] }, 'text', false), 'legacy');
});
test('prompt detection retains leading markdown markers accepted by existing workflows', () => {
assert.equal(outputSections('text', 'Positive prompt: **sun\nNegative prompt: rain')[0].text, '**sun');
});
+311
View File
@@ -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()'>&`), '&lt;img src=&quot;x&quot; onerror=&#39;run()&#39;&gt;&amp;');
});
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);
});
+20
View File
@@ -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);
});
+13
View File
@@ -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);
}
});
+15
View File
@@ -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);
}
});
+42
View File
@@ -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']);
});
+105
View File
@@ -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');
});
+41
View File
@@ -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
View File
+57
View File
@@ -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 == []
+27 -4
View File
@@ -24,6 +24,29 @@ from kikotools.tools.gemini_prompt.prompts import (
class TestGeminiPromptNode:
"""Test cases for GeminiPromptNode."""
def test_deprecated_registration_preserves_legacy_workflow_lookup(self) -> None:
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
cls = NODE_CLASS_MAPPINGS["GeminiPrompt"]
assert cls is GeminiPromptNode
assert NODE_DISPLAY_NAME_MAPPINGS["GeminiPrompt"] == "Gemini Prompt Engineer"
assert getattr(cls, "DEPRECATED", False) is True
assert "existing workflows" in cls.DESCRIPTION.lower()
assert "no longer receives" in cls.DESCRIPTION.lower()
def test_legacy_workflow_schema_is_retained(self) -> None:
with patch(
"kikotools.tools.gemini_prompt.node.get_available_models",
return_value=(["legacy-model"], None),
):
inputs = GeminiPromptNode.INPUT_TYPES()
assert set(inputs["required"]) == {"image", "prompt_type", "model"}
assert set(inputs["optional"]) == {"api_key", "custom_prompt", "refresh_models"}
assert inputs["required"]["model"][0] == ["legacy-model"]
assert callable(getattr(GeminiPromptNode(), GeminiPromptNode.FUNCTION))
assert GeminiPromptNode.RETURN_TYPES == ("STRING", "STRING")
assert GeminiPromptNode.RETURN_NAMES == ("prompt", "negative_prompt")
def test_node_properties(self):
"""Test node has correct properties."""
assert GeminiPromptNode.CATEGORY == "🫶 ComfyAssets/🧠 Prompts"
@@ -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:
+30 -26
View File
@@ -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):

Some files were not shown because too many files have changed in this diff Show More