Compare commits

..
Author SHA1 Message Date
Vito Sansevero c42149d424 docs(ui): propose shared foundation and interactive design preview 2026-10-16 10:27:53 -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
61 changed files with 4156 additions and 195 deletions
+5
View File
@@ -21,3 +21,8 @@ example.py
CLAUDE.md
CONTRIBUTING.md
CODE_OF_CONDUCT.md
tools/
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", "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"
+5 -3
View File
@@ -16,7 +16,7 @@ jobs:
- 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'
@@ -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
@@ -144,7 +145,7 @@ jobs:
- 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'
@@ -175,13 +176,14 @@ jobs:
- 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
+8 -35
View File
@@ -10,6 +10,8 @@ on:
jobs:
create-release:
outputs:
version: ${{ steps.get_version.outputs.version }}
runs-on: ubuntu-latest
permissions:
contents: write
@@ -18,47 +20,18 @@ jobs:
- 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
@@ -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"
+72 -7
View File
@@ -23,7 +23,7 @@ jobs:
- 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,10 +40,19 @@ 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
coverage:
name: coverage gate (85%)
@@ -51,7 +60,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
@@ -61,11 +70,22 @@ 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
@@ -73,9 +93,54 @@ jobs:
steps:
- uses: actions/checkout@v7
# No JS unit tests yet; at least make sure every module parses
- uses: actions/setup-node@v7
with:
node-version: "24"
# Check every shipped module, including files without behavioral tests.
- name: Check web/*.js parse as ES modules
run: |
for f in web/*.js; 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: Shared UI design preview interactions
run: node tools/testing/node_modules/@playwright/test/cli.js test --config docs/ui-preview/playwright.config.mjs
- 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
+6
View File
@@ -169,3 +169,9 @@ referance/
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/
+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.
+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..."
+33 -4
View File
@@ -50,6 +50,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 |
@@ -301,6 +303,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.
@@ -539,7 +563,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
@@ -1003,8 +1027,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
@@ -1064,7 +1088,7 @@ MIT License - see [LICENSE](LICENSE) file for details.
- **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)
- **Test Coverage**: Enforced 85% Python statement/branch coverage and per-file frontend coverage floors
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-genai for Gemini)
@@ -1093,3 +1117,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.
+100
View File
@@ -0,0 +1,100 @@
# 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 | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint |
Browser 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
The standalone shared UI design preview has a separate Playwright configuration.
Run `node tools/testing/node_modules/@playwright/test/cli.js test --config
docs/ui-preview/playwright.config.mjs` after installing the browser dependencies.
It runs in the existing frontend CI job and writes screenshots/traces under
`tools/testing/test-results/ui-preview/`. See
[the preview guide](docs/ui-preview/README.md) for local review and its limits.
These checks cover a mockup, not production widget or renderer compatibility.
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`.
Real ComfyUI/LiteGraph compatibility, GPU execution, cloud inference, network
model downloads, and visual design still need targeted live checks when changed.
The browser host is a test double; these workflows do not run the real ComfyUI
frontend or certify every node's UI. Type checking and security jobs retain their
existing advisory behavior.
## Required merge checks
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
Python coverage, frontend syntax, frontend workflows/coverage, and the existing
`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.
+157
View File
@@ -0,0 +1,157 @@
# ComfyAssets UI foundation
Status: proposed. This document defines the implementation sequence; it does not
claim that the current extensions meet these requirements.
Tracking: [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81)
and [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186).
## Problem and intended result
KikoTools extensions independently construct controls, styles, windows, and host
integration. The Local Image Loader combines these concerns in
`web/kiko_local_image_loader.js`. PromptManager has useful `--pm-*` theme tokens,
but its pages and dialogs have different behavior and lifecycle requirements.
Build an original ComfyAssets foundation so both projects can offer reusable
controls, consistent editor layouts, shared settings/help/window behavior, and
tested Classic/Nodes 2.0 compatibility. Both projects are one toolset and must
look identical. Preserve existing node interfaces, workflows, routes, and features.
Architecture inspiration: [Pixaroma's editor framework](https://github.com/pixaroma/ComfyUI-Pixaroma/blob/78745d0860649d796032265d50711601e70f748e/js/framework/index.mjs)
and [shared infrastructure](https://github.com/pixaroma/ComfyUI-Pixaroma/blob/78745d0860649d796032265d50711601e70f748e/js/shared/index.mjs).
Credit this inspiration in the shipped documentation. Write our own implementation
and visual design; do not copy or vendor Pixaroma code, assets, or styles.
## Boundaries
| Layer | Owns | Must remain independent of |
| --- | --- | --- |
| Controls | Buttons, fields, selections, accessible labels and state | Node names, HTTP routes, renderer internals |
| Layout | Title, toolbar, workspace, optional sidebar and status slots | Gallery fetching and workflow serialization |
| Services | Settings, help, floating windows, cleanup scopes | Product colors and node-specific data |
| ComfyUI adapter | DOM widget attachment, renderer changes, zoom/resize, graph edits | Product layouts and themes |
| Product controllers | API calls, selection, commands, existing persistence | Renderer-specific implementation details |
| Theme | Scoped tokens, typography, spacing, colors, focus and interaction states | Business behavior |
Use native DOM modules and semantic controls for the pilot. Introduce no frontend
framework just for this work. Controls receive values and callbacks; controllers
retain state. Mounting returns an idempotent disposer. Disposal cancels pending
requests and timers, disconnects observers, removes listeners, and closes owned
windows. Late asynchronous responses cannot update a removed or superseded view.
Keep ComfyUI imports behind the host adapter. Detect supported capabilities and
subscribe to host changes where available. Any fallback watcher belongs in the
adapter, has one explicit owner, and stops on disposal. Preserve existing host
hooks and their return values. Do not scatter renderer checks through controls.
## Independent installation and shared source
During the pilot, keep the original source in KikoTools under `web/ui/`, with
product controllers outside that directory. Avoid a premature published package
or a second implementation in PromptManager.
Before PromptManager adopts controls, extract the proven core into one versioned
ComfyAssets source package. Use it as a pinned build-time dependency. Commit the
generated browser distribution, license, version, source revision, and integrity
metadata in each consumer so ordinary git/Manager installations require neither
npm nor a second custom node pack. A documented update command must regenerate
the distribution; CI must reject hand-edited or stale output. Package hosting
and release automation are decisions for that extraction PR.
Each consumer uses relative imports and its own root namespace. Avoid global
custom-element registration and mutable page-wide singletons in the pilot.
Settings and storage keys include a product identifier. Styles remain below the
consumer root. Floating windows inherit that consumer's tokens even when attached
to the document body. Different bundled versions must coexist without selecting
whichever version happened to load first. Test this explicitly during extraction.
## Agreed styling and theme direction
Both projects share exactly the same identity: controls, typography, spacing,
colors and interaction states. Follow ComfyUI's light/dark mode with our own
styling. Use ComfyUI-style blue for the primary accent and a strong, readable red
for errors and destructive actions. Pixaroma's orange is not our accent.
The maintainer's screenshot references establish compact, uniform buttons,
pill switches, segmented selections, structured editable rows, and correct
resizing. Existing styles are migration context, not the target. Define shared
semantic tokens and present the [interactive pilot mockup](ui-preview/README.md)
for review before production migration. Keep behavioral APIs independent of theme.
Follow host light/dark changes while mounted. Standalone PromptManager pages
need an explicit theme/settings bridge because a separate
page does not inherit its CSS variables. Any light/dark or accent choices must
preserve readable text and focus indicators.
Review the pilot in light and dark themes, at narrow and wide node sizes, and
with keyboard focus visible before extending the style system to other nodes.
Record screenshots and concrete contrast measurements; do not treat screenshot
existence as proof of accessibility. No global resets or host-wide style overrides.
## Pilot: Local Image Loader
Keep browsing, autocomplete, saved paths, sorting, hidden-folder filtering,
pagination, refresh, image/video/audio selection, and existing API error handling.
Keep node names, inputs/outputs, widget serialization, and persisted settings
compatible. Characterize existing saved workflows before changing implementation.
First extract the shell, button, select, text field, checkbox, and status controls
actually used by this node. Add sliders and number fields when a second editor
needs them. Windows/help/settings services follow after the pilot; do not add
unused abstractions to satisfy a component checklist.
## Acceptance and test strategy
Use TDD for each behavioral slice: record a failing regression or new contract
test, implement the minimum change, then run affected suites and required gates.
Characterization tests should pass before refactoring. Never weaken existing
coverage thresholds. Extend source discovery and coverage to nested `web/ui/`
modules before introducing them; the current collector only includes `web/*.js`.
| Boundary | Required evidence |
| --- | --- |
| Controls/services | Unit tests for state, callbacks, disposal, settings isolation and repeat mount/unmount |
| Product workflows | Existing browser fixture tests plus request races, errors and preserved selection/settings |
| Real host | Actual ComfyUI frontend, graph, settings, widgets and workflow serialization; no substituted host objects |
| Visual/keyboard | Theme screenshots, keyboard operation, visible focus, accessible names and measured contrast |
| Distribution | Both packs separately and together, with matching and different UI package versions |
The real-host harness must pin ComfyUI and frontend versions and record them in
test artifacts. Select and document a baseline that actually supports both
renderers before claiming compatibility. Test initial Classic and Nodes 2.0,
switching both ways without reload, zoom/pan/resize, node delete/recreate,
save/reload of an existing workflow, and duplicate-node behavior.
Opening or closing a view, changing theme, and moving a floating window must not
create graph edits. Committed node value changes must use the host's normal edit
path and participate in undo/redo. Assert serialized values and history behavior;
do not use canvas redraw as evidence of correct graph persistence.
Run the pinned baseline as a PR check once reproducible. Keep the existing fast
fixtures and cross-browser checks. Run a separately pinned newer-host matrix on
a schedule to detect upstream changes without silently changing PR dependencies.
Missing renderer support must fail or be reported as unsupported, never silently
skip the renderer and report compatibility. Model inference and GPU execution
are outside this UI suite; startup and UI interaction should use disposable data.
## Delivery sequence
1. **Proposal:** agree on these boundaries, pilot behavior, and test evidence.
2. **Host harness:** add reproducible real-ComfyUI tests for the existing loader;
report any existing failures separately before the refactor.
3. **Pilot:** make nested source coverage exhaustive; add tokens, minimal controls,
shell, adapter and loader migration in reviewable TDD slices. Include visual
evidence and preserve the existing suites.
4. **Shared behavior:** migrate the Kiko image viewer; extract settings/help/window
services from demonstrated needs and test cleanup, persistence and keyboard use.
5. **Second consumer:** extract and version the core, add reproducibility checks,
then migrate one PromptManager dialog and its theme bridge. Prove standalone
and combined installation before wider adoption.
6. **Incremental adoption:** migrate remaining editors individually. Remove old
code only after replacement behavior is covered; document package updates and
compatibility support.
Each PR links its tracked issue, includes validation commands/results, and states
which real-host and visual checks were performed. Keep runtime changes out of the
proposal PR. No backend or workflow schema change is planned for the UI pilot.
+60
View File
@@ -0,0 +1,60 @@
# ComfyAssets UI preview
Interactive design review for [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81)
and [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186).
This standalone mockup changes no shipped nodes, APIs, settings, or workflows.
## Open the preview
From the KikoTools repository:
```sh
python3 -m http.server 8769 --bind 127.0.0.1 --directory docs/ui-preview
```
Open <http://127.0.0.1:8769/>. Stop the server with Ctrl+C.
No build, ComfyUI installation, or external requests are needed. Paths, files,
gallery images and prompt rows are sample content; actions affect this page only.
Browse and refresh intentionally reuse the same samples. Nothing is persisted.
## What to try
- Switch Dark/Light to simulate ComfyUI's mode. Production will follow the host;
this review toggle is not a new product theme setting.
- Change Preview node width and Thumbnail size. Also resize the browser.
- Select and clear images, filter file names, switch Grid/List and change sorting.
- Operate switches with the keyboard; add, disable, remove and reset prompt rows.
- Show the error example and Retry to compare the red treatment against blue.
Both product samples use exactly the same visual tokens and controls. Blue
`#0877bb` and red `#d52430` are review candidates chosen to keep white button text
readable; they are not claimed to be exact colors sampled from the references.
Give feedback on these colors, density, borders, control proportions and resize
behavior before production migration.
Captured review views: [dark](screenshots/dark.png), [light](screenshots/light.png),
and [narrow](screenshots/narrow.png). These show the current candidate design,
not production screenshots or automatic visual regression baselines.
## Automated review checks
With Node.js 24 and the existing frontend test dependencies installed:
```sh
npm ci --prefix tools/testing --ignore-scripts
npx --prefix tools/testing playwright install chromium firefox
node tools/testing/node_modules/@playwright/test/cli.js test \
--config docs/ui-preview/playwright.config.mjs --project chromium --project firefox
```
Stop the manual preview server first; the suite starts its own disposable server
on port 8769. CI also runs WebKit and touch Chromium using the same configuration.
The tests cover basic interactions, resizing, keyboard operation, theme
consistency and text contrast on sampled surfaces. Screenshots are written under
`tools/testing/test-results/ui-preview/` locally. The suite does not certify screen
reader usability, all state/border contrast, or real ComfyUI compatibility.
See the [foundation proposal](../shared-ui-foundation.md) for the renderer adapter,
real-host tests and incremental migration plan. Pixaroma's control organization
inspired this direction; this preview's code, styles and sample illustrations are
original and include none of his implementation or assets.
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Local Image Loader · ComfyAssets UI preview</title>
<link rel="stylesheet" href="preview.css">
<script type="module" src="preview.mjs"></script>
</head>
<body class="ca-preview">
<svg class="ca-symbols" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<symbol id="folder" viewBox="0 0 24 24"><path d="M3 6h7l2 3h9v11H3Z"/></symbol>
<symbol id="refresh" viewBox="0 0 24 24"><path d="M20 10a8 8 0 1 0-1 8M20 4v6h-6"/></symbol>
<symbol id="up" viewBox="0 0 24 24"><path d="m6 11 6-6 6 6M12 5v15"/></symbol>
<symbol id="star" viewBox="0 0 24 24"><path d="m12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1Z"/></symbol>
<symbol id="grid" viewBox="0 0 24 24"><path d="M3 3h7v7H3ZM14 3h7v7h-7ZM3 14h7v7H3ZM14 14h7v7h-7Z"/></symbol>
<symbol id="list" viewBox="0 0 24 24"><path d="M9 5h12M9 12h12M9 19h12M3 5h1M3 12h1M3 19h1"/></symbol>
<symbol id="check" viewBox="0 0 24 24"><path d="m5 12 5 5L20 7"/></symbol>
<symbol id="alert" viewBox="0 0 24 24"><path d="m12 3 10 18H2ZM12 9v5M12 17v1"/></symbol>
</svg>
<header class="ca-page-header">
<a class="ca-wordmark" href="#main"><span class="ca-brand-mark" aria-hidden="true">C<span>A</span></span> ComfyAssets</a>
<span class="ca-caption">Shared UI / Design preview 01</span>
</header>
<main id="main" class="ca-page-main">
<div class="ca-intro">
<div><h1>One set of tools. One design.</h1><p>Local Image Loader + PromptManager · compact controls, shared behavior, your ComfyUI theme.</p></div>
<span class="ca-preview-badge">Interactive mockup</span>
</div>
<section class="ca-review-controls" aria-label="Preview controls">
<fieldset class="ca-segments"><legend>ComfyUI theme preview</legend>
<label><input type="radio" name="theme" value="dark" checked><span>Dark</span></label>
<label><input type="radio" name="theme" value="light"><span>Light</span></label>
</fieldset>
<label class="ca-width-control" for="node-width">Preview node width <input id="node-width" type="range" min="320" max="800" step="20" value="800"><output for="node-width" data-width>800 px</output></label>
<span class="ca-review-hint">Try resizing and switching themes</span>
</section>
<div class="ca-review-grid">
<div class="ca-canvas">
<article class="ca-node ca-loader" data-loader data-product-sample>
<header class="ca-titlebar"><h2><span class="ca-node-dot" aria-hidden="true"></span>Local Image Loader</h2><span class="ca-product-label">KikoTools</span></header>
<div class="ca-ports"><span class="ca-port">directory</span><span>media <i aria-hidden="true"></i></span></div>
<div class="ca-node-body">
<div class="ca-row ca-favorites"><label for="saved-paths" class="ca-visually-hidden">Saved paths</label><select id="saved-paths"><option value="/input/references">References</option><option value="/input/portraits">Portraits</option><option value="/input">All input files</option></select><button type="button" data-save-path><svg><use href="#star"/></svg>Save path</button></div>
<form class="ca-path-form"><label for="directory" class="ca-field-label">Directory</label><div class="ca-path-row"><button type="button" class="ca-icon-button" aria-label="Parent folder" data-parent><svg><use href="#up"/></svg></button><input id="directory" value="/input/references" spellcheck="false"><button type="submit" class="ca-primary"><svg><use href="#folder"/></svg>Browse</button></div></form>
<div class="ca-options">
<label class="ca-switch" for="hide-dot"><input id="hide-dot" type="checkbox" role="switch" checked>Hide dot folders</label>
<div class="ca-row ca-sort"><label for="sort">Sort</label><select id="sort"><option value="name">Name</option><option value="date">Date</option><option value="size">Size</option></select><button type="button" class="ca-icon-button" aria-label="Sort descending" aria-pressed="false" data-sort-order>↑</button><button type="button" class="ca-icon-button" aria-label="Refresh sample folder" data-refresh><svg><use href="#refresh"/></svg></button></div>
</div>
<div class="ca-gallery-tools"><label for="filter" class="ca-visually-hidden">Filter sample files</label><input id="filter" type="search" placeholder="Filter files…"><fieldset class="ca-segments ca-view-options"><legend class="ca-visually-hidden">Gallery layout</legend><label><input type="radio" name="view" value="grid" checked><span><svg aria-hidden="true"><use href="#grid"/></svg>Grid</span></label><label><input type="radio" name="view" value="list"><span><svg aria-hidden="true"><use href="#list"/></svg>List</span></label></fieldset></div>
<div class="ca-error" role="alert" hidden><svg aria-hidden="true"><use href="#alert"/></svg><span><strong>Folder unavailable</strong><br>Check the path and try again.</span><button type="button" data-retry>Retry</button></div>
<div class="ca-gallery" data-gallery data-view="grid"></div>
<p class="ca-empty" data-empty hidden>No matching sample files.</p>
<div class="ca-gallery-footer"><span data-count>6 sample files</span><span class="ca-page-count">Page 1 of 1</span></div>
<div class="ca-thumbnail-control"><label for="thumbnail-size">Thumbnail size</label><input id="thumbnail-size" type="range" min="112" max="208" step="8" value="144"><output for="thumbnail-size" data-thumbnail-size>144 px</output></div>
<div class="ca-selection"><span class="ca-selection-icon" aria-hidden="true"><svg><use href="#check"/></svg></span><div><span class="ca-field-label">Selected image</span><strong data-selection>No image selected</strong></div><button type="button" data-clear disabled>Clear</button></div>
</div>
<footer class="ca-node-status" role="status" data-status>Sample folder ready · choose an image</footer>
</article>
<p class="ca-canvas-caption">Same component system at every node size. All files shown here are demo content.</p>
</div>
<aside class="ca-reference">
<div class="ca-reference-heading"><h2>The same family</h2><p>Same controls. Same spacing. Same blue.</p></div>
<article class="ca-node ca-prompt-sample" data-product-sample>
<header class="ca-titlebar"><h3><span class="ca-node-dot" aria-hidden="true"></span>Prompt editor</h3><span class="ca-product-label">PromptManager</span></header>
<div class="ca-node-body">
<div class="ca-prompt-rows" data-prompt-rows></div>
<div class="ca-row"><button type="button" data-add-row>+ Add row</button><button type="button" data-reset-rows disabled>Reset</button></div>
</div>
</article>
<section class="ca-reference-panel"><h3>One accent, clear states</h3><div class="ca-state-buttons"><button type="button" class="ca-primary" data-primary-example>Primary action</button><button type="button" class="ca-danger" data-error-example>Show error example</button><button type="button" disabled>Unavailable</button></div><p class="ca-muted-sample">Blue for active controls.<br>Strong red when something needs attention.</p></section>
<section class="ca-reference-panel ca-design-notes"><h3>What to review</h3><ul><li>Button weight and spacing</li><li>Blue and red in both themes</li><li>Switches and segmented choices</li><li>Narrow and wide node layouts</li></ul><p>This page previews appearance and interactions. ComfyUI renderer compatibility comes in the integration phase.</p></section>
</aside>
</div>
</main>
<footer class="ca-page-footer"><span>ComfyAssets · KikoTools + PromptManager</span><span>Original implementation · inspired by Pixaroma’s thoughtful controls</span></footer>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('./', import.meta.url));
export default defineConfig({
testDir: './tests',
outputDir: '../../tools/testing/test-results/ui-preview',
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: {
baseURL: 'http://127.0.0.1:8769',
viewport: { width: 1440, height: 1050 },
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command: 'python3 -m http.server 8769 --bind 127.0.0.1',
cwd: root,
url: 'http://127.0.0.1:8769/',
reuseExistingServer: false,
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
{ name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } },
],
});
+184
View File
@@ -0,0 +1,184 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--ca-page: #19191c;
--ca-canvas: #202023;
--ca-surface: #2b2b2e;
--ca-title: #242427;
--ca-field: #202023;
--ca-control: #37373b;
--ca-hover: #45454b;
--ca-text: #eeeeF1;
--ca-muted: #b5b5bd;
--ca-line: #48484f;
--ca-control-border: #7d7d87;
--ca-accent: #0877bb;
--ca-accent-hover: #096ba6;
--ca-accent-text: #ffffff;
--ca-focus: #5bc2ff;
--ca-error: #d52430;
--ca-error-bg: #391d23;
--ca-error-text: #ffadb3;
--ca-grid: #29292d;
--ca-shadow: 0 3px 9px #00000030;
}
:root[data-theme="light"] {
color-scheme: light;
--ca-page: #f4f4f6;
--ca-canvas: #ededf0;
--ca-surface: #ffffff;
--ca-title: #f6f6f8;
--ca-field: #f5f5f7;
--ca-control: #eeeeF1;
--ca-hover: #e2e2e8;
--ca-text: #23232b;
--ca-muted: #5c5c68;
--ca-line: #d4d4dc;
--ca-control-border: #85858f;
--ca-focus: #0066a6;
--ca-error-bg: #fff0f1;
--ca-error-text: #a20d20;
--ca-grid: #e1e1e7;
--ca-shadow: 0 3px 9px #1b1b2612;
}
.ca-preview { margin: 0; background: var(--ca-page); color: var(--ca-text); font-size: 13px; line-height: 1.5; }
.ca-preview :where(article, section, header, footer, div, input, button, select, textarea, fieldset, label, aside, main) { box-sizing: border-box; }
.ca-preview :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
.ca-preview :where(button, select, input:not([type="radio"], [type="range"], [role="switch"]), textarea) { border: 1px solid var(--ca-control-border); border-radius: 5px; min-block-size: 30px; }
.ca-preview button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 11px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
.ca-preview button:hover:not(:disabled) { background: var(--ca-hover); }
.ca-preview button:disabled { opacity: .42; cursor: default; }
.ca-preview svg:not(.ca-symbols) { inline-size: 15px; block-size: 15px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.ca-symbols { position: absolute; inline-size: 0; block-size: 0; overflow: hidden; }
.ca-preview :where(input:not([type="radio"], [type="range"], [role="switch"]), select, textarea) { background: var(--ca-field); padding: 4px 9px; min-inline-size: 0; }
.ca-preview select { padding-inline-end: 22px; }
.ca-preview :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
.ca-preview :where(button, input, select, textarea, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
.ca-preview .ca-primary { background: var(--ca-accent); color: var(--ca-accent-text); border-color: var(--ca-accent); }
.ca-preview .ca-primary:hover:not(:disabled) { background: var(--ca-accent-hover); }
.ca-preview .ca-danger { background: var(--ca-error); color: #ffffff; border-color: var(--ca-error); }
.ca-preview .ca-danger:hover:not(:disabled) { background: #b51c28; }
.ca-page-header, .ca-page-footer { max-inline-size: 1280px; margin-inline: auto; padding: 24px 32px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.ca-page-header { border-block-end: 1px solid var(--ca-line); }
.ca-wordmark { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ca-text); font-size: 17px; font-weight: 650; letter-spacing: -.4px; }
.ca-brand-mark { color: var(--ca-accent); font-weight: 800; letter-spacing: -3px; font-size: 22px; padding-inline-end: 4px; }
.ca-brand-mark span { color: var(--ca-text); }
.ca-caption, .ca-page-footer, .ca-review-hint, .ca-canvas-caption { color: var(--ca-muted); font-size: 12px; }
.ca-page-main { max-inline-size: 1280px; margin-inline: auto; padding: 32px; }
.ca-intro { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-block-end: 24px; }
.ca-intro h1 { font-size: 25px; line-height: 1.3; font-weight: 600; letter-spacing: -.7px; margin: 0 0 8px; }
.ca-intro p { color: var(--ca-muted); margin: 0; }
.ca-preview-badge { color: var(--ca-muted); border: 1px solid var(--ca-line); padding: 4px 9px; border-radius: 4px; font-size: 11px; }
.ca-review-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 24px; padding: 16px 0; border-block: 1px solid var(--ca-line); margin-block-end: 24px; }
.ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; align-items: center; gap: 2px; min-inline-size: 0; }
.ca-segments legend { float: inline-start; margin-inline-end: 12px; color: var(--ca-muted); }
.ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
.ca-segments input { position: absolute; inset: 0; opacity: 0; margin: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
.ca-segments label > span { display: inline-flex; gap: 5px; align-items: center; justify-content: center; padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-control-border); border-radius: 4px; }
.ca-segments input:checked + span { color: var(--ca-accent-text); background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
.ca-width-control { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; color: var(--ca-muted); }
.ca-preview input[type="range"] { min-inline-size: 60px; block-size: 30px; margin: 0; cursor: pointer; }
.ca-width-control input { inline-size: 120px; }
.ca-preview output { font-variant-numeric: tabular-nums; font-size: 12px; min-inline-size: 44px; }
.ca-review-hint { margin-inline-start: auto; }
.ca-review-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; align-items: start; }
.ca-canvas { padding: 26px 18px 14px; border: 1px solid var(--ca-line); border-radius: 8px; background-color: var(--ca-canvas); background-image: linear-gradient(var(--ca-grid) 1px, transparent 1px), linear-gradient(90deg, var(--ca-grid) 1px, transparent 1px); background-size: 16px 16px; min-inline-size: 0; }
.ca-node { background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 8px; box-shadow: var(--ca-shadow); overflow: hidden; }
.ca-loader { inline-size: min(100%, var(--ca-preview-width, 800px)); margin-inline: auto; container-type: inline-size; }
.ca-titlebar { display: flex; align-items: center; justify-content: space-between; gap: 8px; background: var(--ca-title); padding: 9px 12px; border-block-end: 1px solid var(--ca-line); }
.ca-titlebar :where(h2, h3) { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 550; margin: 0; }
.ca-node-dot { inline-size: 8px; block-size: 8px; border-radius: 50%; background: var(--ca-accent); flex-shrink: 0; }
.ca-product-label { color: var(--ca-muted); font-size: 10px; white-space: nowrap; }
.ca-ports { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px; font-size: 11px; color: var(--ca-muted); }
.ca-ports span { display: flex; align-items: center; gap: 6px; }
.ca-ports i { inline-size: 6px; block-size: 6px; border: 1px solid var(--ca-control-border); background: var(--ca-focus); border-radius: 50%; }
.ca-node-body { padding: 12px; display: grid; gap: 14px; }
.ca-row { display: flex; align-items: center; gap: 7px; min-inline-size: 0; }
.ca-favorites select { flex: 1; }
.ca-field-label { color: var(--ca-muted); font-size: 11px; display: block; margin-block-end: 5px; }
.ca-path-row { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 6px; }
.ca-preview .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
.ca-options { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; }
.ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
.ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-control-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
.ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
.ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
.ca-sort { font-size: 12px; color: var(--ca-muted); }
.ca-sort select { inline-size: 90px; color: var(--ca-text); }
.ca-gallery-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
.ca-gallery-tools > input { flex: 1; inline-size: 120px; }
.ca-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ca-thumbnail-size, 144px)), 1fr)); gap: 8px; }
.ca-preview .ca-file { position: relative; padding: 4px; display: flex; flex-direction: column; gap: 0; background: var(--ca-field); border-color: var(--ca-control-border); white-space: normal; text-align: start; overflow: hidden; min-inline-size: 0; }
.ca-preview .ca-file[aria-pressed="true"] { border: 2px solid var(--ca-focus); padding: 3px; }
.ca-file-name { inline-size: 100%; font-size: 11px; padding: 6px 3px 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.ca-file-meta { inline-size: 100%; color: var(--ca-muted); font-size: 10px; padding: 1px 3px 4px; }
.ca-file-check { display: none; position: absolute; inset-block-start: 9px; inset-inline-end: 9px; background: var(--ca-accent); color: white; border: 1px solid white; border-radius: 3px; inline-size: 20px; block-size: 20px; text-align: center; }
.ca-file[aria-pressed="true"] .ca-file-check { display: block; }
.ca-scene { position: relative; display: block; inline-size: 100%; aspect-ratio: 1.5; overflow: hidden; border-radius: 3px; background: linear-gradient(170deg, #719ca5, #b9cfbf 55%, #46647b 56%, #203947); }
.ca-scene::before, .ca-scene::after { content: ""; position: absolute; inset: 12% -8% 25%; clip-path: polygon(0 100%, 35% 5%, 48% 43%, 64% 20%, 100% 100%); background: #40596c; }
.ca-scene::after { inset: 38% -20% -15% 20%; background: #1e3549; }
.ca-scene-dune { background: linear-gradient(150deg, #e2c9a8, #8b8d98); }
.ca-scene-dune::before { background: #b79a83; clip-path: polygon(0 100%, 75% 10%, 100% 90%); }
.ca-scene-dune::after { background: #705f64; clip-path: polygon(0 60%, 80% 20%, 100% 100%, 0 100%); }
.ca-scene-forest { background: linear-gradient(160deg, #9eb7a9, #3f6259); }
.ca-scene-forest::before { background: #45665b; clip-path: polygon(0 100%, 16% 5%, 27% 85%, 45% 0, 61% 100%, 80% 10%, 100% 100%); }
.ca-scene-forest::after { background: #243e39; }
.ca-scene-coast { background: linear-gradient(170deg, #a3bfce 40%, #4e8a9e 41%, #294c65); }
.ca-scene-coast::before { background: #a1b1a0; clip-path: polygon(0 0, 100% 80%, 0 100%); }
.ca-scene-coast::after { background: #526f69; clip-path: polygon(0 30%, 70% 100%, 0 100%); }
.ca-scene-studio { background: linear-gradient(135deg, #b0a5b6, #5e5d78); }
.ca-scene-studio::before { inset: 18% 28%; border-radius: 40px 40px 0 0; clip-path: none; background: #d2c2be; }
.ca-scene-studio::after { inset: 40% 35% 0; clip-path: none; border-radius: 40px 40px 0 0; background: #636378; }
.ca-scene-night { background: linear-gradient(140deg, #49516a, #202a42); }
.ca-scene-night::before { background: #6a6580; }
.ca-scene-night::after { background: #273044; }
.ca-gallery[data-view="list"] { grid-template-columns: 1fr; }
.ca-preview .ca-gallery[data-view="list"] .ca-file { display: grid; grid-template-columns: 64px minmax(0, 1fr); column-gap: 8px; justify-content: start; }
.ca-gallery[data-view="list"] .ca-scene { grid-row: span 2; }
.ca-gallery[data-view="list"] .ca-file-meta { align-self: start; }
.ca-gallery-footer { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--ca-muted); }
.ca-thumbnail-control { display: flex; align-items: center; gap: 10px; color: var(--ca-muted); font-size: 11px; }
.ca-thumbnail-control input { flex: 1; }
.ca-selection { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; display: flex; align-items: center; gap: 10px; min-inline-size: 0; }
.ca-selection > div { flex: 1; min-inline-size: 0; }
.ca-selection strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
.ca-selection .ca-field-label { margin-block-end: 0; }
.ca-selection-icon { color: var(--ca-focus); }
.ca-node-status { padding: 7px 12px; border-block-start: 1px solid var(--ca-line); background: var(--ca-title); color: var(--ca-muted); font-size: 11px; }
.ca-canvas-caption { text-align: center; margin: 14px 6px 0; }
.ca-reference { display: grid; gap: 20px; }
.ca-reference-heading h2 { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
.ca-reference-heading p { margin: 0; color: var(--ca-muted); font-size: 12px; }
.ca-prompt-sample .ca-titlebar { align-items: start; flex-direction: column; gap: 4px; }
.ca-prompt-sample .ca-titlebar .ca-product-label { padding-inline-start: 16px; }
.ca-prompt-rows { display: grid; gap: 8px; }
.ca-prompt-row { background: var(--ca-field); border: 1px solid var(--ca-line); padding: 8px; border-radius: 5px; display: grid; gap: 8px; }
.ca-prompt-row .ca-row { justify-content: space-between; }
.ca-preview .ca-prompt-row textarea { inline-size: 100%; min-block-size: 78px; resize: vertical; font-size: 12px; }
.ca-reference-panel { padding: 16px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-surface); }
.ca-reference-panel h3 { margin: 0 0 12px; font-size: 12px; font-weight: 600; }
.ca-state-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.ca-state-buttons button { flex: 1 1 100%; }
.ca-muted-sample { color: var(--ca-muted); background: var(--ca-surface); font-size: 12px; margin: 12px 0 0; }
.ca-design-notes ul { padding-inline-start: 16px; color: var(--ca-muted); line-height: 1.9; font-size: 12px; }
.ca-design-notes p { font-size: 11px; color: var(--ca-muted); border-block-start: 1px solid var(--ca-line); padding-block-start: 10px; margin-block-end: 0; }
.ca-error { display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--ca-error-bg); color: var(--ca-error-text); border: 1px solid var(--ca-error); border-radius: 5px; font-size: 12px; }
.ca-error span { flex: 1; }
.ca-error strong { font-weight: 600; }
.ca-preview [hidden] { display: none; }
.ca-empty { text-align: center; padding: 32px 12px; color: var(--ca-muted); }
.ca-page-footer { border-block-start: 1px solid var(--ca-line); padding-block: 18px; flex-wrap: wrap; font-size: 11px; }
.ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
@container (max-width: 400px) {
.ca-options { align-items: stretch; }
.ca-sort { flex: 1; }
.ca-sort select { flex: 1; }
.ca-gallery { grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ca-thumbnail-size, 144px)), 1fr)); }
.ca-options > .ca-switch { flex-basis: 100%; }
}
@media (max-width: 1000px) { .ca-review-grid { grid-template-columns: minmax(0, 1fr); } .ca-reference { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } .ca-reference-heading { grid-column: 1 / -1; } }
@media (max-width: 600px) { .ca-page-main { padding: 20px 12px; } .ca-page-header, .ca-page-footer { padding-inline: 16px; } .ca-caption { display: none; } .ca-canvas { padding: 14px 6px; } .ca-review-hint { margin-inline-start: 0; } .ca-intro h1 { font-size: 22px; } .ca-width-control { gap: 8px; } }
@media (pointer: coarse) { .ca-preview :where(button, select), .ca-segments label > span { min-block-size: 40px; } .ca-switch { min-block-size: 40px; } }
@media (forced-colors: active) { :root, :root[data-theme="light"] { --ca-accent: Highlight; --ca-accent-text: HighlightText; --ca-focus: Highlight; --ca-control-border: ButtonText; --ca-line: CanvasText; } .ca-file[aria-pressed="true"] { outline: 2px solid Highlight; } .ca-switch input:checked::before { background: HighlightText; } }
+142
View File
@@ -0,0 +1,142 @@
const root = document.documentElement;
const loader = document.querySelector('[data-loader]');
const gallery = document.querySelector('[data-gallery]');
const status = document.querySelector('[data-status]');
const selection = document.querySelector('[data-selection]');
const clear = document.querySelector('[data-clear]');
const error = document.querySelector('.ca-error');
const filter = document.querySelector('#filter');
const path = document.querySelector('#directory');
const sort = document.querySelector('#sort');
let selected = '';
let descending = false;
const files = [
{ name: 'alpine-lake.png', scene: 'lake', size: '1536 × 1024', order: 3 },
{ name: 'desert-dunes.png', scene: 'dune', size: '1536 × 1024', order: 6 },
{ name: 'forest-study.png', scene: 'forest', size: '1024 × 1536', order: 4 },
{ name: 'coastal-light.png', scene: 'coast', size: '1536 × 1024', order: 1 },
{ name: 'studio-shapes.png', scene: 'studio', size: '1024 × 1024', order: 2 },
{ name: 'night-ridge.png', scene: 'night', size: '1536 × 1024', order: 5 },
];
function renderFiles() {
const visible = files.filter(file => file.name.includes(filter.value.toLowerCase()));
visible.sort((a, b) => {
const order = sort.value === 'name' ? a.name.localeCompare(b.name)
: sort.value === 'size' ? a.size.localeCompare(b.size) : a.order - b.order;
return descending ? -order : order;
});
gallery.replaceChildren(...visible.map(file => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ca-file';
button.setAttribute('aria-label', `Select ${file.name}`);
button.setAttribute('aria-pressed', String(file.name === selected));
const scene = document.createElement('span');
scene.className = `ca-scene ca-scene-${file.scene}`;
scene.setAttribute('aria-hidden', 'true');
const check = document.createElement('span');
check.className = 'ca-file-check';
check.textContent = '✓';
check.setAttribute('aria-hidden', 'true');
const name = document.createElement('span');
name.className = 'ca-file-name';
name.textContent = file.name;
const meta = document.createElement('span');
meta.className = 'ca-file-meta';
meta.textContent = `${file.size} · PNG`;
button.append(scene, check, name, meta);
button.addEventListener('click', () => {
selected = file.name;
selection.textContent = file.name;
clear.disabled = false;
for (const item of gallery.children) item.setAttribute('aria-pressed', String(item === button));
status.textContent = `Selected ${file.name} · demo selection`;
});
return button;
}));
document.querySelector('[data-empty]').hidden = visible.length > 0;
document.querySelector('[data-count]').textContent = `${visible.length} sample files`;
}
document.querySelectorAll('[name="theme"]').forEach(input => {
input.addEventListener('change', () => { root.dataset.theme = input.value; });
});
document.querySelector('#node-width').addEventListener('input', event => {
loader.style.setProperty('--ca-preview-width', `${event.target.value}px`);
document.querySelector('[data-width]').value = `${event.target.value} px`;
});
document.querySelectorAll('[name="view"]').forEach(input => {
input.addEventListener('change', () => { gallery.dataset.view = input.value; });
});
document.querySelector('#thumbnail-size').addEventListener('input', event => {
gallery.style.setProperty('--ca-thumbnail-size', `${event.target.value}px`);
document.querySelector('[data-thumbnail-size]').value = `${event.target.value} px`;
});
filter.addEventListener('input', renderFiles);
sort.addEventListener('change', renderFiles);
document.querySelector('[data-sort-order]').addEventListener('click', event => {
descending = !descending;
event.currentTarget.setAttribute('aria-pressed', String(descending));
event.currentTarget.setAttribute('aria-label', descending ? 'Sort ascending' : 'Sort descending');
event.currentTarget.textContent = descending ? '↓' : '↑';
renderFiles();
});
document.querySelector('#hide-dot').addEventListener('change', event => {
status.textContent = event.target.checked ? 'Dot folders hidden · sample content unchanged' : 'Dot folders visible · no hidden folders in this sample';
});
function browse() {
error.hidden = true;
status.textContent = `Previewing ${path.value || '/input'} · sample content only`;
renderFiles();
}
document.querySelector('.ca-path-form').addEventListener('submit', event => { event.preventDefault(); browse(); });
document.querySelector('[data-refresh]').addEventListener('click', browse);
document.querySelector('[data-retry]').addEventListener('click', browse);
document.querySelector('#saved-paths').addEventListener('change', event => { path.value = event.target.value; browse(); });
document.querySelector('[data-save-path]').addEventListener('click', () => {
if (!path.value.trim()) { status.textContent = 'Enter a directory before saving a sample path'; return; }
const presets = document.querySelector('#saved-paths');
if (![...presets.options].some(option => option.value === path.value)) presets.add(new Option(path.value, path.value));
presets.value = path.value;
status.textContent = 'Path saved for this preview session';
});
document.querySelector('[data-parent]').addEventListener('click', () => {
path.value = path.value.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
browse();
});
clear.addEventListener('click', () => {
selected = '';
selection.textContent = 'No image selected';
clear.disabled = true;
renderFiles();
status.textContent = 'Selection cleared';
});
document.querySelector('[data-error-example]').addEventListener('click', () => {
error.hidden = false;
status.textContent = 'Error example shown · use Retry to return';
});
document.querySelector('[data-primary-example]').addEventListener('click', () => {
status.textContent = 'Primary action example · blue is shared across both tools';
});
let nextRow = 1;
const rows = document.querySelector('[data-prompt-rows]');
const reset = document.querySelector('[data-reset-rows]');
function addRow() {
const id = nextRow++;
const row = document.createElement('div');
row.className = 'ca-prompt-row';
row.innerHTML = `<div class="ca-row"><label class="ca-switch" for="prompt-enabled-${id}"><input id="prompt-enabled-${id}" type="checkbox" role="switch" checked>Row ${id}</label><button type="button" class="ca-icon-button" aria-label="Remove row ${id}">×</button></div><label for="prompt-${id}" class="ca-visually-hidden">Prompt row ${id}</label><textarea id="prompt-${id}" placeholder="Write a prompt…"></textarea>`;
row.querySelector('button').addEventListener('click', () => {
row.remove();
reset.disabled = rows.children.length === 1 && rows.firstChild.querySelector('textarea').value === '';
document.querySelector('[data-add-row]').focus();
});
row.querySelector('textarea').addEventListener('input', () => { reset.disabled = false; });
row.querySelector('input').addEventListener('change', event => { row.querySelector('textarea').disabled = !event.target.checked; reset.disabled = false; });
rows.append(row);
}
document.querySelector('[data-add-row]').addEventListener('click', () => { addRow(); reset.disabled = false; rows.lastChild.querySelector('textarea').focus(); });
reset.addEventListener('click', () => { rows.replaceChildren(); nextRow = 1; addRow(); reset.disabled = true; document.querySelector('[data-add-row]').focus(); });
addRow();
renderFiles();
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

+95
View File
@@ -0,0 +1,95 @@
import { test, expect } from '../../../tools/testing/node_modules/@playwright/test/index.mjs';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('theme preview updates native color scheme and both product samples together', async ({ page }) => {
await page.getByRole('radio', { name: 'Light', exact: true }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
const samples = page.locator('[data-product-sample]');
expect(await samples.count()).toBe(2);
const backgrounds = await samples.evaluateAll(elements => elements.map(el => getComputedStyle(el).backgroundColor));
expect(new Set(backgrounds).size).toBe(1);
await page.getByRole('radio', { name: 'Dark', exact: true }).check();
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
});
test('gallery selection, filtering and layout controls operate on sample content', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Select alpine-lake.png' })).toBeVisible();
await page.getByRole('button', { name: 'Select alpine-lake.png' }).click();
await expect(page.getByRole('button', { name: 'Select alpine-lake.png' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('[data-selection]')).toContainText('alpine-lake.png');
await page.getByRole('radio', { name: 'List', exact: true }).check();
await expect(page.locator('[data-gallery]')).toHaveAttribute('data-view', 'list');
await page.getByLabel('Filter sample files').fill('dune');
await expect(page.locator('[data-gallery] button')).toHaveCount(1);
await page.getByLabel('Filter sample files').fill('missing');
await expect(page.getByText('No matching sample files.')).toBeVisible();
});
test('prompt rows support editing, disabling, removing and reset', async ({ page }) => {
await page.getByRole('button', { name: '+ Add row', exact: true }).click();
await page.getByLabel('Prompt row 2', { exact: true }).fill('A quiet lake at dawn');
await page.getByRole('switch', { name: 'Row 2', exact: true }).uncheck();
await expect(page.getByLabel('Prompt row 2', { exact: true })).toBeDisabled();
await page.getByRole('button', { name: 'Remove row 1', exact: true }).click();
await expect(page.getByLabel('Prompt row 1', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Reset', exact: true }).click();
await expect(page.getByLabel('Prompt row 1', { exact: true })).toBeEnabled();
await expect(page.getByLabel('Prompt row 1', { exact: true })).toHaveValue('');
await expect(page.getByRole('button', { name: 'Reset', exact: true })).toBeDisabled();
});
test('switches support keyboard and error example has an actionable recovery', async ({ page }) => {
const toggle = page.getByRole('switch', { name: 'Hide dot folders' });
await toggle.focus();
await page.keyboard.press('Space');
await expect(toggle).not.toBeChecked();
await expect(toggle).toBeFocused();
await expect(toggle).toHaveCSS('outline-style', 'solid');
await page.getByRole('button', { name: 'Show error example' }).click();
await expect(page.getByRole('alert')).toContainText('Folder unavailable');
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByRole('alert')).not.toBeVisible();
});
test('node controls resize without horizontal overflow and range supports keyboard', async ({ page }) => {
for (const width of [320, 460, 800]) {
await page.getByLabel('Preview node width').fill(String(width));
await page.getByLabel('Preview node width').dispatchEvent('input');
expect((await page.locator('[data-loader]').boundingBox()).width).toBeLessThanOrEqual(width);
const overflow = await page.locator('[data-loader]').evaluate(el => el.scrollWidth - el.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
}
await page.setViewportSize({ width: 375, height: 850 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: test.info().outputPath('narrow.png'), fullPage: true });
const size = page.getByLabel('Thumbnail size');
await size.focus();
await page.keyboard.press('ArrowRight');
await expect(size).toHaveValue('152');
});
test('text, accent and error treatments have measurable contrast in both themes', async ({ page }) => {
const luminance = color => {
const channels = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(value => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
for (const theme of ['Light', 'Dark']) {
await page.getByRole('radio', { name: theme, exact: true }).check();
for (const selector of ['.ca-node', '.ca-primary', '.ca-danger', '.ca-muted-sample']) {
const colors = await page.locator(selector).first().evaluate(el => {
const style = getComputedStyle(el);
return [style.color, style.backgroundColor];
});
const [a, b] = colors.map(luminance).sort((x, y) => y - x);
expect((a + 0.05) / (b + 0.05), `${theme} ${selector}`).toBeGreaterThanOrEqual(4.5);
}
await page.screenshot({ path: test.info().outputPath(`${theme.toLowerCase()}.png`), fullPage: true });
}
});
+6
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
@@ -23,6 +24,7 @@ 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
@@ -69,6 +71,8 @@ NODE_CLASS_MAPPINGS = {
"FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode,
"CheckpointFolderIterator": CheckpointFolderIteratorNode,
"ModelCompareSheet": ModelCompareSheetNode,
"WidthHeightToVec2": WidthHeightToVec2Node,
"KikoWorkflowTimer": KikoWorkflowTimerNode,
"KikoProjectFilePath": ProjectFilePathNode,
@@ -105,6 +109,8 @@ 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",
+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)
@@ -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,),
}
@@ -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
+2
View File
@@ -16,6 +16,7 @@ dev = [
"pytest-cov>=4.0.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",
@@ -71,6 +72,7 @@ files = ["kikotools"]
exclude = ["tests"]
[tool.coverage.run]
branch = true
source = ["kikotools"]
omit = ["*/tests/*", "*/__init__.py"]
+1
View File
@@ -5,6 +5,7 @@ pytest>=7.0.0
pytest-cov>=4.0.0
pytest-mock>=3.15.1
pytest-asyncio>=0.21.0
aiohttp>=3.8.0
# Code quality
black>=23.0.0
View File
+116
View File
@@ -0,0 +1,116 @@
// A test double for the ComfyUI host; extension implementations load unchanged.
const extensions = [];
const settings = 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) { if (!settings.has(setting.id)) settings.set(setting.id, setting.defaultValue); },
getSettingValue: id => settings.get(id),
}, 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, 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()
+285
View File
@@ -0,0 +1,285 @@
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
import { isFixtureURL } from '../../tools/testing/network-policy.mjs';
import { startCoverage, saveCoverage } from '../../tools/testing/browser-coverage.mjs';
test.beforeEach(async ({ page, request, context, browserName, baseURL }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.errors = [];
page.on('pageerror', error => page.errors.push(error.message));
page.on('response', response => { if (response.status() >= 500) page.errors.push(`${response.status()} ${response.url()}`); });
await context.route('**/*', route => {
const url = new URL(route.request().url());
if (isFixtureURL(url.href, baseURL)) return route.continue();
page.errors.push(`Unexpected external request: ${url.origin}`);
return route.abort();
});
await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.errors).toEqual([]);
});
async function openHost(page) {
await page.goto('/__test__/');
await expect(page.locator('body')).toHaveAttribute('data-ready', 'true');
}
async function createNode(page, name, values = {}) {
return page.evaluate(({ name, values }) => kikoHost.createNode(name, values), { name, values });
}
async function state(request) { return (await (await request.get('/__test__/state')).json()); }
test('plugin startup registers every frontend extension and exposes real autocomplete routes', async ({ page, request }) => {
await openHost(page);
const manifest = await state(request);
expect(manifest.nodes).toEqual(expect.arrayContaining(['DisplayAny', 'DisplayText', 'WidthHeightSelector', 'KikoLocalImageLoader', 'KikoSaveImage']));
const registered = await page.evaluate(() => kikoHost.extensions.map(extension => extension.name));
expect(registered).toHaveLength(manifest.extensions.length);
expect(new Set(registered).size).toBe(registered.length);
const embeddings = await (await request.get('/kikotools/autocomplete/embeddings')).json();
expect(embeddings).toContainEqual(expect.objectContaining({ name: 'cat', path: 'portraits/cat' }));
});
test('actual swap handlers preserve preset values and custom dimensions', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'WidthHeightSelector', { preset: '832×1216 - portrait', width: 832, height: 1216 });
const swapped = await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.onMouseDown({ canvasX: 475, canvasY: 140 });
return node.widgets.map(widget => widget.value);
}, id);
expect(swapped).toEqual(['1216×832 - landscape', '1216', '832']);
await createNode(page, 'EmptyLatentBatch', { preset: 'custom', width: 640, height: 960 });
await page.getByRole('button', { name: /Swap W/ }).click();
const latent = page.locator('[data-node="EmptyLatentBatch"]');
await expect(latent.getByLabel('width')).toHaveValue('960');
await expect(latent.getByLabel('height')).toHaveValue('640');
});
test('local image selection persists and the real node loads the selected PNG tensor', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'KikoLocalImageLoader');
const image = page.getByAltText('cat & dog.png', { exact: true });
await expect(image).toBeVisible();
await expect.poll(() => image.evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
const selected = page.waitForResponse(response => response.url().endsWith('/set_node_selection'));
await image.click();
expect((await selected).ok()).toBe(true);
const selectedPath = `${(await state(request)).input}/cat & dog.png`;
expect((await state(request)).selections[String(id)].image.path).toBe(selectedPath);
const executed = await request.post('/__test__/execute/KikoLocalImageLoader', { data: { unique_id: String(id) } });
await expect(executed).toBeOK();
expect((await executed.json()).shape).toEqual([1, 12, 16, 3]);
await page.reload();
await expect(page.locator('body')).toHaveAttribute('data-ready', 'true');
expect(await createNode(page, 'KikoLocalImageLoader')).toBe(id);
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
expect((await state(request)).selections[String(id)].image.path).toBe(selectedPath);
});
test('gallery navigates folders and saves and removes paths through the actual routes', async ({ page, request }) => {
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
await page.locator('.kiko-folder-name').filter({ hasText: /^sub$/ }).click();
await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible();
await page.locator('.kiko-save-path').click();
await expect.poll(async () => (await state(request)).config.saved_paths).toHaveLength(1);
const saved = (await state(request)).config.saved_paths[0];
await page.reload();
await expect(page.locator('body')).toHaveAttribute('data-ready', 'true');
await createNode(page, 'KikoLocalImageLoader');
await expect(page.locator('.kiko-path-presets option')).toHaveCount(2);
await page.locator('.kiko-path-presets').selectOption(saved);
await page.locator('.kiko-delete-path').click();
await expect.poll(async () => (await state(request)).config.saved_paths).toEqual([]);
await page.locator('.kiko-up-folder').click();
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
});
test('gallery rejects disallowed folders and retries after a network error', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'KikoLocalImageLoader');
const info = await state(request);
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
await page.locator('.kiko-path-input').fill(info.outside);
const denied = page.waitForResponse(response => response.url().includes('/images?'));
await page.locator('.kiko-browse').click();
expect((await denied).status()).toBe(403);
await expect(page.locator('.kiko-status-message')).toBeVisible();
const deniedSelection = await request.post('/kiko_local_image_loader/set_node_selection', { data: { node_id: id, path: `${info.outside}/secret.png`, type: 'image' } });
expect(deniedSelection.status()).toBe(403);
expect((await state(request)).selections).toEqual({});
await page.locator('.kiko-path-input').fill(info.input);
await page.route('**/kiko_local_image_loader/images?*', route => route.abort('failed'), { times: 1 });
await page.locator('.kiko-browse').click();
await expect(page.locator('.kiko-status-message')).toHaveText('Error loading directory');
await page.locator('.kiko-browse').click();
await expect(page.locator('.kiko-status-message')).toBeHidden();
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
});
test('real display node output renders as text and replaces previous widgets', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'DisplayText');
const text = 'Positive prompt: <img src=x onerror="window.injected=true">\nNegative prompt: blur';
const response = await request.post('/__test__/execute/DisplayText', { data: { text } });
await expect(response).toBeOK();
const data = await response.json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: data.ui });
await expect(page.getByLabel('text_positive')).toHaveValue('<img src=x onerror="window.injected=true">');
await expect(page.getByLabel('text_negative')).toHaveValue('blur');
expect(await page.evaluate(() => window.injected)).toBeUndefined();
const next = await (await request.post('/__test__/execute/DisplayText', { data: { text: 'Replacement' } })).json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: next.ui });
await expect(page.locator('textarea')).toHaveCount(1);
await expect(page.getByLabel('text_display')).toHaveValue('Replacement');
await expect(page.getByLabel('text_display')).toHaveAttribute('readonly', '');
});
test('seed history persists across reloads and removal clears its watcher', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'SeedHistory', { seed: 42 });
await page.getByRole('button', { name: /Generate/ }).click();
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('comfyui_kikotools_seed_history')));
expect(saved).toHaveLength(1);
expect(saved[0].seed).toBeGreaterThanOrEqual(0);
await page.reload();
await expect(page.locator('body')).toHaveAttribute('data-ready', 'true');
await createNode(page, 'SeedHistory', { seed: 42 });
expect(await page.evaluate(() => kikoHost.nodes.get('1').seedHistory[0].seed)).toBe(saved[0].seed);
await page.getByRole('button', { name: /Clear/ }).click();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('comfyui_kikotools_seed_history')))).toEqual([]);
await expect.poll(() => page.evaluate(() => Boolean(kikoHost.nodes.get('1').seedValueWatcher))).toBe(true);
expect(await page.evaluate(id => {
const node = kikoHost.removeNode(id);
return { watcher: node.seedValueWatcher, registered: window.seedHistoryNodes.includes(node), removed: node.removed };
}, id)).toEqual({ watcher: null, registered: false, removed: true });
});
test('workflow timer responds to execution events and detaches removed nodes', async ({ page }) => {
await page.clock.install();
await openHost(page);
const id = await createNode(page, 'KikoWorkflowTimer');
await page.evaluate(() => kikoHost.emit('execution_start'));
await page.clock.runFor(1234);
await expect(page.locator('.kiko-timer-display')).not.toHaveText('00:00:000');
await page.evaluate(() => kikoHost.emit('execution_interrupted'));
await expect(page.locator('.kiko-timer-display')).not.toHaveClass(/running/);
const stopped = await page.locator('.kiko-timer-display').textContent();
await page.clock.runFor(500);
await expect(page.locator('.kiko-timer-display')).toHaveText(stopped);
expect(await page.evaluate(id => kikoHost.removeNode(id).removed, id)).toBe(true);
await page.evaluate(() => { kikoHost.emit('execution_start'); kikoHost.emit('executing', null); });
await expect(page.locator('.kiko-timer-display')).toHaveCount(0);
});
test('autocomplete inserts embedding paths from the real plugin endpoint and cleans up its widget', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.getByLabel('text', { exact: true });
await prompt.fill('embedding:ca');
const suggestions = page.locator('.kikotools-autocomplete-suggestions');
await expect(suggestions).toBeVisible();
await expect(suggestions).toContainText('cat');
await prompt.press('ArrowDown');
await prompt.press('Enter');
await expect(prompt).toHaveValue(/embedding:portraits\/cat/);
await expect(suggestions).toBeHidden();
expect(await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
const entry = node.widgets.find(widget => widget.name === 'text');
kikoHost.removeNode(id);
return window.kikotools.autocomplete.activeWidgets.has(entry);
}, id)).toBe(false);
});
test('real image save output opens a preview, closes, and reuses the viewer on execution', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'KikoSaveImage', { format: 'PNG', quality: 90 });
const response = await request.post('/__test__/execute/KikoSaveImage', { data: { filename_prefix: 'Browser image' } });
await expect(response).toBeOK();
const result = await response.json();
expect(result.ui.kiko_enhanced).toHaveLength(1);
const execute = () => page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: result.ui });
await execute();
const viewer = page.locator('kiko-image-viewer');
await expect(viewer).toBeVisible();
await expect(viewer.locator('img')).toHaveCount(1);
await expect.poll(() => viewer.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
await viewer.getByTitle('Close', { exact: true }).click();
await expect(viewer).toBeHidden();
await execute();
await expect(viewer).toHaveCount(1);
await expect(viewer).toBeVisible();
await viewer.getByTitle('Minimize', { exact: true }).click();
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
});
test('Display Any renders real multiline output and restores serialized values without duplicate widgets', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'DisplayAny');
const response = await request.post('/__test__/execute/DisplayAny', { data: { input: Array.from({ length: 45 }, (_, index) => `Line ${index}`).join('\n') } });
await expect(response).toBeOK();
const result = await response.json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: result.ui });
await expect(page.locator('textarea')).toHaveCount(3);
expect(await page.locator('textarea').evaluateAll(elements => elements.map(element => element.value).join('\n'))).toBe(result.ui.text[0]);
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.configure({ widgets_values: ['Restored value'] });
node.onConfigure();
}, id);
await expect(page.locator('textarea')).toHaveCount(1);
await expect(page.getByLabel('display_0')).toHaveValue('Restored value');
});
test('gallery pagination sorting and hidden-folder controls use real directory pages', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 52 } })).toBeOK();
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
const folder = name => page.locator('.kiko-folder-name').filter({ hasText: new RegExp(`^${name}$`) });
await expect(folder('sub')).toBeVisible();
await expect(folder('\\.hidden')).toHaveCount(0);
await page.locator('.kiko-hide-dot-folders').uncheck();
await expect(folder('\\.hidden')).toBeVisible();
await folder('bulk').click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50);
await expect(page.locator('.kiko-card-info-panel').first()).toHaveText('0000.png');
await page.locator('.kiko-sort-order').selectOption('desc');
await expect(page.locator('.kiko-card-info-panel').first()).toHaveText('0051.png');
await page.locator('.kiko-pagination').getByRole('button', { name: '2', exact: true }).click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(2);
await expect(page.getByAltText('0000.png', { exact: true })).toBeVisible();
await page.locator('.kiko-pagination').getByRole('button', { name: '◀', exact: true }).click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50);
await page.locator('.kiko-pagination').getByRole('button', { name: '▶', exact: true }).click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(2);
});
test('gallery directory autocomplete handles keyboard selection escape and navigation', async ({ page, request }) => {
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
const input = page.locator('.kiko-path-input');
const dropdown = page.locator('.kiko-autocomplete-dropdown');
const directory = (await state(request)).input;
await input.fill(`${directory}/su`);
await expect(dropdown).toBeVisible();
await expect(dropdown.locator('.folder-name')).toContainText('sub');
await input.press('ArrowDown');
await expect(dropdown.locator('.selected')).toHaveCount(1);
await input.press('ArrowUp');
await expect(dropdown.locator('.selected')).toHaveCount(0);
await input.press('Escape');
await expect(dropdown).toBeHidden();
await input.fill(`${directory}/su`);
await expect(dropdown).toBeVisible();
await input.press('ArrowDown');
await input.press('Tab');
await expect(input).toBeFocused();
await expect(input).toHaveValue(`${directory}/sub/`);
await expect(dropdown).toBeHidden();
await input.press('Enter');
await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible();
});
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
const sourceRoot = join(process.cwd(), 'web');
import { checkCoverage } from '../../tools/testing/frontend-coverage.mjs';
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/);
});
+160
View File
@@ -0,0 +1,160 @@
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';
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']) {
const { extension, app } = load('width_height_swap.js');
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: 'WidthHeightSelector' }, app);
const node = new Node();
node.onNodeCreated();
assert.equal(originalCreated, 1);
return node;
}
test('custom dimensions swap and callbacks receive the actual node', async () => {
const node = await dimensionNode('custom');
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('formatted preset swaps to its matching landscape preset without throwing', async () => {
const node = await dimensionNode('832×1216 - portrait');
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
});
test('a preset without a reverse switches to custom dimensions', async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait']);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
});
test('preset changes preserve original callbacks and update both dimensions', async () => {
const node = await dimensionNode('custom');
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('unrelated node definitions retain their lifecycle methods', async () => {
const { extension, app } = load('width_height_swap.js');
class Node { onNodeCreated() {} }
const original = Node.prototype.onNodeCreated;
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
assert.equal(Node.prototype.onNodeCreated, original);
});
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);
});
+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);
}
});
+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 == []
+23
View File
@@ -0,0 +1,23 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
export async function startCoverage(page, browserName) {
if (process.env.KIKO_JS_COVERAGE && browserName === 'chromium') {
await page.coverage.startJSCoverage({ resetOnNavigation: false });
}
}
export async function saveCoverage(page, browserName) {
if (!process.env.KIKO_JS_COVERAGE || browserName !== 'chromium') return;
const entries = await page.coverage.stopJSCoverage();
const result = [];
for (const entry of entries) {
const match = entry.url.match(/\/extensions\/kikotools\/([a-z0-9_-]+\.js)(?:\?.*)?$/i);
if (match) result.push({ ...entry, url: pathToFileURL(join(repositoryRoot, 'web', match[1])).href });
}
mkdirSync(process.env.KIKO_JS_COVERAGE, { recursive: true });
writeFileSync(join(process.env.KIKO_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
}
@@ -0,0 +1,80 @@
{
"display_any.js": {
"lines": 84,
"branches": 66,
"functions": 83,
"statements": 84
},
"display_text.js": {
"lines": 71,
"branches": 74,
"functions": 42,
"statements": 71
},
"empty_latent_batch_swap.js": {
"lines": 41,
"branches": 91,
"functions": 64,
"statements": 41
},
"gemini_prompt_ui.js": {
"lines": 52,
"branches": 85,
"functions": 42,
"statements": 52
},
"kikoEmbeddingAutocomplete.js": {
"lines": 44,
"branches": 43,
"functions": 59,
"statements": 44
},
"kiko_custom_colors.js": {
"lines": 46,
"branches": 98,
"functions": 8,
"statements": 46
},
"kiko_follow_execution.js": {
"lines": 60,
"branches": 79,
"functions": 73,
"statements": 60
},
"kiko_local_image_loader.js": {
"lines": 72,
"branches": 69,
"functions": 40,
"statements": 72
},
"kiko_save_image_ui.js": {
"lines": 66,
"branches": 49,
"functions": 57,
"statements": 66
},
"kiko_workflow_timer_ui.js": {
"lines": 44,
"branches": 73,
"functions": 34,
"statements": 44
},
"kikotools_extensions.js": {
"lines": 12,
"branches": 98,
"functions": 0,
"statements": 12
},
"seed_history_ui.js": {
"lines": 63,
"branches": 58,
"functions": 56,
"statements": 63
},
"width_height_swap.js": {
"lines": 60,
"branches": 79,
"functions": 73,
"statements": 60
}
}
+75
View File
@@ -0,0 +1,75 @@
import { readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const metrics = ['lines', 'branches', 'functions', 'statements'];
export function checkCoverage(summary, baseline, sourceRoot, files) {
const failures = [];
for (const file of files) {
const floor = baseline[file];
if (!floor) { failures.push(`Missing baseline for ${file}`); continue; }
const measured = summary[join(sourceRoot, file)];
if (!measured) { failures.push(`Missing coverage for ${file}`); continue; }
for (const metric of metrics) {
if (!Number.isFinite(floor[metric]) || floor[metric] < 0 || floor[metric] > 100) {
throw new Error(`Invalid baseline for ${file}: ${metric}`);
}
if (!Number.isFinite(measured[metric]?.pct) || measured[metric].pct < floor[metric]) {
failures.push(`${file} ${metric}: ${measured[metric]?.pct ?? 'missing'} < ${floor[metric]}`);
}
}
}
return failures;
}
function run(args, env = {}) {
const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
env: { ...process.env, ...env } });
if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${args.join(' ')}`);
}
async function main() {
const tools = dirname(fileURLToPath(import.meta.url));
const reportRoot = join(repositoryRoot, 'coverage/js');
const raw = join(reportRoot, 'raw');
const sourceRoot = join(repositoryRoot, 'web');
const files = readdirSync(sourceRoot).filter(file => file.endsWith('.js')).sort();
rmSync(reportRoot, { recursive: true, force: true });
mkdirSync(raw, { recursive: true });
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.mjs')).map(file => `tests/js/${file}`);
run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
const playwright = join(tools, 'node_modules/@playwright/test/cli.js');
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs'),
'--project', 'chromium', '--project', 'touch'], { KIKO_JS_COVERAGE: raw });
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
'--include', 'web/*.js', '--temp-directory', raw, '--reports-dir', reportRoot,
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
if (process.argv.includes('--update-baseline')) {
const previous = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, previous, sourceRoot, files.filter(file => previous[file]));
if (failures.length) throw new Error(`Baseline updates cannot lower coverage:\n${failures.join('\n')}`);
const next = Object.fromEntries(files.map(file => {
const measured = summary[join(sourceRoot, file)];
if (!measured || metrics.some(metric => !Number.isFinite(measured[metric]?.pct))) {
throw new Error(`Missing coverage for ${file}`);
}
if (Object.keys(previous).length && !previous[file] && measured.lines.pct < 80) {
throw new Error(`New frontend files need at least 80% line coverage: ${file}`);
}
return [file, Object.fromEntries(metrics.map(metric => [metric, Math.max(previous[file]?.[metric] || 0, Math.floor(measured[metric].pct) - 2, 0)]))];
}));
writeFileSync(baselinePath, JSON.stringify(next, null, 2) + '\n');
} else {
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, baseline, sourceRoot, files);
if (failures.length) throw new Error(`Frontend coverage regressed:\n${failures.join('\n')}`);
}
console.log(`Frontend coverage passed for ${files.length} files. Report: ${relative(repositoryRoot, reportRoot)}/index.html`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(error => { console.error(error.message); process.exitCode = 1; });
}
+4
View File
@@ -0,0 +1,4 @@
export function isFixtureURL(raw, baseURL) {
const url = new URL(raw);
return url.origin === new URL(baseURL).origin && ['http:', 'https:', 'blob:'].includes(url.protocol);
}
+785
View File
@@ -0,0 +1,785 @@
{
"name": "kikotools-test-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kikotools-test-tools",
"devDependencies": {
"@playwright/test": "1.64.1",
"c8": "12.0.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/c8": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz",
"integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^8.0.0",
"v8-to-istanbul": "^9.0.0",
"yargs": "^18.0.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/string-width": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "kikotools-test-tools",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"test": "node --test ../../tests/js/*.test.mjs",
"test:browser": "playwright test --config playwright.config.mjs",
"test:coverage": "node frontend-coverage.mjs",
"coverage:update": "node frontend-coverage.mjs --update-baseline"
},
"devDependencies": { "@playwright/test": "1.64.1", "c8": "12.0.0" }
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
const port = Number(process.env.KIKO_TEST_PORT || 8766);
export default defineConfig({
testDir: '../../tests/browser',
outputDir: './test-results',
workers: 1,
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: 0,
reporter: 'list',
use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: {
command: `node tools/testing/start-browser-server.mjs --port ${port}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${port}/__test__/`,
reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
{ name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } },
],
});
+8
View File
@@ -0,0 +1,8 @@
import { spawn } from 'node:child_process';
const child = spawn(process.env.KIKO_TEST_PYTHON || 'python',
['-m', 'tests.browser.server', ...process.argv.slice(2)], { stdio: 'inherit' });
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
child.on('exit', code => { process.exitCode = code ?? 1; });
for (const signal of ['SIGTERM', 'SIGINT']) {
process.once(signal, () => child.kill(signal));
}
+2 -3
View File
@@ -451,9 +451,8 @@ app.registerExtension({
</div>
<!-- Gallery -->
<div class="kiko-cardholder">
<div class="kiko-status-message">Loading...</div>
</div>
<div class="kiko-status-message">Loading...</div>
<div class="kiko-cardholder"></div>
<!-- Pagination -->
<div class="kiko-pagination"></div>
+8 -8
View File
@@ -123,13 +123,13 @@ app.registerExtension({
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(swappedFormattedPreset, this, presetWidget);
presetWidget.callback(swappedFormattedPreset, app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, this, widthWidget);
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, this, heightWidget);
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
@@ -138,13 +138,13 @@ app.registerExtension({
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", this, presetWidget);
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, this, widthWidget);
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, this, heightWidget);
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
}
} else {
@@ -155,10 +155,10 @@ app.registerExtension({
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, this, widthWidget);
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, this, heightWidget);
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}