test: add extension workflows coverage and required merge checks
This commit is contained in:
@@ -21,3 +21,8 @@ example.py
|
||||
CLAUDE.md
|
||||
CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
|
||||
tools/
|
||||
coverage/
|
||||
TESTING.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
exclude =
|
||||
node_modules,
|
||||
.git,
|
||||
__pycache__,
|
||||
.mypy_cache,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"required_status_checks": {
|
||||
"strict": true,
|
||||
"contexts": [
|
||||
"py3.10 on ubuntu-latest", "py3.11 on ubuntu-latest", "py3.12 on ubuntu-latest", "py3.13 on ubuntu-latest",
|
||||
"py3.10 on windows-latest", "py3.11 on windows-latest", "py3.12 on windows-latest", "py3.13 on windows-latest",
|
||||
"py3.10 on macos-latest", "py3.11 on macos-latest", "py3.12 on macos-latest", "py3.13 on macos-latest",
|
||||
"coverage gate (85%)", "frontend syntax", "frontend workflows and coverage", "code-quality"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
"required_pull_request_reviews": null,
|
||||
"restrictions": null,
|
||||
"allow_force_pushes": false,
|
||||
"allow_deletions": false
|
||||
}
|
||||
@@ -8,3 +8,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tools/testing"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -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
|
||||
@@ -182,6 +183,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: Check architecture compliance
|
||||
|
||||
@@ -10,6 +10,8 @@ on:
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -25,40 +27,11 @@ 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: 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"
|
||||
|
||||
@@ -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%)
|
||||
@@ -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,52 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# No JS unit tests yet; at least make sure every module parses
|
||||
- uses: actions/setup-node@v6
|
||||
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@v6
|
||||
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@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: tools/testing/package-lock.json
|
||||
- run: npm ci --prefix tools/testing --ignore-scripts
|
||||
- run: npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
|
||||
- name: JavaScript regressions, Chromium workflows, and coverage gate
|
||||
run: npm run --prefix tools/testing test:coverage
|
||||
- name: Firefox and WebKit workflows
|
||||
run: npm run --prefix tools/testing test:browser -- --project firefox --project webkit
|
||||
- name: Upload frontend reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
path: |
|
||||
tools/testing/test-results/
|
||||
coverage/js/
|
||||
!coverage/js/raw/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
@@ -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
@@ -4,10 +4,9 @@
|
||||
repos:
|
||||
# Python code formatting with Black
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
rev: 26.10.1
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.10
|
||||
args: ['--line-length=88'] # Match CI configuration
|
||||
|
||||
# Python linting with flake8
|
||||
@@ -61,9 +60,27 @@ repos:
|
||||
args: ['--baseline', '.secrets.baseline']
|
||||
exclude: '^(tests/|\.git/)'
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: python-regressions
|
||||
name: Python regression suite
|
||||
entry: python -m pytest tests
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-push]
|
||||
- id: frontend-regressions
|
||||
name: JavaScript regression suite
|
||||
entry: npm test --prefix tools/testing
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-push]
|
||||
|
||||
# Configuration for specific hooks
|
||||
default_stages: [pre-commit]
|
||||
default_language_version:
|
||||
python: python3.10
|
||||
python: python3
|
||||
|
||||
# Run hooks on all files by default
|
||||
fail_fast: false
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`kikotools/tools/<tool>/logic.py` contains reusable logic; `node.py` defines the
|
||||
ComfyUI node. Shared classes live in `kikotools/base/`. Register nodes in
|
||||
`kikotools/__init__.py`; the root `__init__.py` exposes the plugin and API routes.
|
||||
`web/` contains browser extensions. Tests live in `tests/unit/`, `tests/js/`, and
|
||||
`tests/browser/`. Examples are in `examples/`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Install Python dependencies with `python -m pip install -r requirements-dev.txt`.
|
||||
Run `python -m pytest tests` or `make test` for the complete Python suite.
|
||||
Use `python -m pytest tests --cov=kikotools` for the 85% statement/branch gate.
|
||||
Format with `black .`; use `flake8 .` for lint findings.
|
||||
|
||||
Frontend tooling needs Node.js 24 and `npm ci --prefix tools/testing
|
||||
--ignore-scripts`. Run `npm test --prefix tools/testing` for JavaScript tests,
|
||||
`npm run --prefix tools/testing test:coverage` for coverage and Chromium workflows,
|
||||
and `npm run --prefix tools/testing test:browser` for all browser projects.
|
||||
See `TESTING.md` for browser installation, environment overrides, and artifacts.
|
||||
|
||||
## Coding Style & Naming
|
||||
|
||||
Use four-space Python indentation and Black's 88-character formatting.
|
||||
Use `snake_case` for Python functions/modules, `PascalCase` for classes, and
|
||||
`UPPER_CASE` for constants. Keep node interfaces separate from logic. Preserve
|
||||
existing ComfyUI lifecycle hooks when extending widgets and clean up timers,
|
||||
listeners, and observers when nodes are removed.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Write regression tests first and verify failure before implementing fixes.
|
||||
Name Python tests `test_*.py` and JavaScript tests `*.test.mjs`; put browser
|
||||
workflows in `tests/browser/*.spec.mjs`. Test behavior and persistence, including
|
||||
failure recovery. Keep fixtures disposable and external services mocked.
|
||||
Never lower coverage floors to make a change pass.
|
||||
|
||||
## Commits & Pull Requests
|
||||
|
||||
Use Conventional Commits, such as `fix(local_image_loader): preserve error
|
||||
status`. Link the tracked issue, describe observable behavior, and report the
|
||||
commands and results used for validation. Include screenshots for visual changes.
|
||||
Required checks must pass before merging. Keep credentials and runtime files out
|
||||
of commits; restart ComfyUI after installing Python changes.
|
||||
+4
-1
@@ -357,9 +357,12 @@ Brief description of changes.
|
||||
|
||||
### Review Process
|
||||
|
||||
See [TESTING.md](TESTING.md) for automated suites, coverage gates, required merge
|
||||
checks, and the remaining ComfyUI/GPU/service boundaries.
|
||||
|
||||
1. **Automated Checks**: CI runs tests and quality checks
|
||||
2. **Code Review**: Maintainer reviews code and provides feedback
|
||||
3. **Testing**: Manual testing in ComfyUI environment
|
||||
3. **Testing**: Run relevant automated suites; use live ComfyUI checks for host, GPU, service, or visual changes
|
||||
4. **Merge**: After approval, changes are merged to main
|
||||
|
||||
## 🎨 Code Style Guidelines
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ComfyUI-KikoTools Development Makefile
|
||||
|
||||
.PHONY: help install test test-fast lint format type-check quality-check clean setup dev-test release-test
|
||||
.PHONY: coverage frontend-test browser-test frontend-coverage help install test test-fast lint format type-check quality-check clean setup dev-test release-test
|
||||
|
||||
# Python and virtual environment setup
|
||||
PYTHON := python3
|
||||
@@ -34,7 +34,11 @@ help:
|
||||
@echo " quality-check - Run all quality checks"
|
||||
@echo ""
|
||||
@echo "Testing:"
|
||||
@echo " test - Run comprehensive tests"
|
||||
@echo " test - Run the complete pytest suite"
|
||||
@echo " coverage - Python statement/branch coverage (85% gate)"
|
||||
@echo " frontend-test - JavaScript regression tests"
|
||||
@echo " browser-test - Extension workflows in four browser configurations"
|
||||
@echo " frontend-coverage - JavaScript tests, browser tests, and coverage gate"
|
||||
@echo " test-fast - Run core functionality tests"
|
||||
@echo " dev-test - Quick development test"
|
||||
@echo " release-test - Full release validation"
|
||||
@@ -120,13 +124,20 @@ test-fast: $(VENV_DIR)
|
||||
print('🎉 All core tests passed!'); \
|
||||
"
|
||||
|
||||
test: test-fast
|
||||
@echo "Running comprehensive test suite..."
|
||||
@echo "✅ Test case 1: 512×512 → 1024×1024 (scale: 2.0)"
|
||||
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)"
|
||||
@echo "✅ Test case 3: 832×1216 → 1272×1864 (scale: 1.53)"
|
||||
@echo "✅ Error handling test passed"
|
||||
@echo "🎉 All comprehensive tests passed!"
|
||||
test: $(VENV_DIR)
|
||||
@$(PYTHON_CMD) -m pytest tests
|
||||
|
||||
coverage: $(VENV_DIR)
|
||||
@$(PYTHON_CMD) -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html
|
||||
|
||||
frontend-test:
|
||||
npm test --prefix tools/testing
|
||||
|
||||
browser-test:
|
||||
npm run --prefix tools/testing test:browser
|
||||
|
||||
frontend-coverage:
|
||||
npm run --prefix tools/testing test:coverage
|
||||
|
||||
release-test: quality-check test
|
||||
@echo "Running release validation..."
|
||||
|
||||
@@ -539,7 +539,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 +1003,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 +1064,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 +1093,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.
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# 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, saved paths,
|
||||
denied paths and network recovery, display output, seed persistence/cleanup, and
|
||||
workflow timing, autocomplete insertion, and saved-image previews. They run in Chromium, Firefox, WebKit, and touch Chromium.
|
||||
The harness binds to localhost and writes configuration, selections, and fixture
|
||||
images only inside a temporary directory. External browser requests fail tests;
|
||||
custom-word downloads are disabled in the fixture. No credentials, model
|
||||
weights, running ComfyUI, or GPU are required.
|
||||
|
||||
## Run locally
|
||||
|
||||
Use Python 3.10–3.13 and Node.js 24. Install the CPU PyTorch wheel on Linux if you
|
||||
do not need CUDA, then install the development dependencies:
|
||||
|
||||
```sh
|
||||
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
python -m pip install -r requirements-dev.txt
|
||||
python -m pytest tests
|
||||
python -m pytest tests --cov=kikotools --cov-report=term-missing
|
||||
npm ci --prefix tools/testing --ignore-scripts
|
||||
npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
|
||||
npm test --prefix tools/testing
|
||||
npm run --prefix tools/testing test:coverage
|
||||
npm run --prefix tools/testing test:browser -- --project firefox --project webkit
|
||||
```
|
||||
|
||||
`make test` runs the full pytest suite and propagates failures. `make coverage`,
|
||||
`make frontend-test`, `make browser-test`, and `make frontend-coverage` provide
|
||||
shortcuts. The Python Make targets use the existing virtual-environment setup.
|
||||
Install local commit and push hooks with `pre-commit install --hook-type
|
||||
pre-commit --hook-type pre-push`. The push hooks run Python and JavaScript
|
||||
regressions; CI additionally runs all browser workflows and coverage gates.
|
||||
For a quick browser check, use `test:browser -- --project chromium`.
|
||||
`KIKO_TEST_PYTHON` selects a Python executable with the development dependencies;
|
||||
`KIKO_TEST_PORT` changes the default port 8766. Servers are never reused.
|
||||
Unsupported Linux distributions may lack WebKit libraries; Ubuntu CI validates
|
||||
all engines.
|
||||
|
||||
## Coverage and diagnosis
|
||||
|
||||
Python CI checks all twelve Python/OS combinations and enforces 85% combined
|
||||
statement/branch coverage. Every production `web/*.js` file appears in frontend
|
||||
coverage, including code without behavioral coverage. V8 unit coverage and
|
||||
Chromium desktop/touch coverage are merged through c8. The committed per-file
|
||||
floors are an honest starting point, not a claim that every feature is tested.
|
||||
|
||||
`npm run --prefix tools/testing coverage:update` reruns the suites and raises
|
||||
floors with two percentage points of runtime allowance. It cannot lower existing
|
||||
floors; new source files require 80% line coverage when added with this command.
|
||||
Missing measurements, missing baselines, and per-file regressions fail CI.
|
||||
|
||||
CI artifacts include Python JUnit results, Python HTML/XML coverage, and
|
||||
`frontend-test-reports` with frontend HTML coverage and browser failure traces
|
||||
and screenshots. Open traces using Playwright's `show-trace` command. Local
|
||||
frontend coverage is at `coverage/js/index.html`.
|
||||
|
||||
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.
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,167 @@
|
||||
"""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() -> None:
|
||||
(root / "selections.json").unlink(missing_ok=True)
|
||||
shutil.rmtree(root / "output")
|
||||
(root / "output").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"),
|
||||
("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)
|
||||
|
||||
@routes.post("/__test__/reset")
|
||||
async def reset_route(request: web.Request) -> web.Response:
|
||||
await asyncio.to_thread(reset)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/state")
|
||||
async def state(request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"input": str(root / "input"),
|
||||
"outside": str(root / "outside"),
|
||||
"selections": loader.load_selections(),
|
||||
"config": loader.load_config(),
|
||||
"nodes": sorted(plugin.NODE_CLASS_MAPPINGS),
|
||||
"extensions": sorted(
|
||||
path.name for path in (REPOSITORY / "web").glob("*.js")
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@routes.post("/__test__/execute/{name}")
|
||||
async def execute(request: web.Request) -> web.Response:
|
||||
name = request.match_info["name"]
|
||||
data = await request.json()
|
||||
if name not in {
|
||||
"DisplayAny",
|
||||
"DisplayText",
|
||||
"KikoLocalImageLoader",
|
||||
"KikoSaveImage",
|
||||
}:
|
||||
raise web.HTTPNotFound()
|
||||
if name == "KikoSaveImage":
|
||||
data["images"] = loader.load_image_from_path(
|
||||
str(root / "input" / "cat & dog.png")
|
||||
)[0]
|
||||
cls = plugin.NODE_CLASS_MAPPINGS[name]
|
||||
result = await asyncio.to_thread(getattr(cls(), cls.FUNCTION), **data)
|
||||
if name == "KikoLocalImageLoader":
|
||||
tensor, info = result
|
||||
return web.json_response(
|
||||
{"shape": list(tensor.shape), "info": json.loads(info) if info else {}}
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.get("/__test__/")
|
||||
async def index(request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
text='<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>KikoTools extension test host</title></head><body><main id="nodes"></main><script type="module" src="/__test__/host.mjs"></script></body></html>',
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
@routes.get("/__test__/host.mjs")
|
||||
async def host(request: web.Request) -> web.Response:
|
||||
return web.FileResponse(FIXTURES / "host.mjs")
|
||||
|
||||
@routes.get("/scripts/{name}.js")
|
||||
async def host_import(request: web.Request) -> web.Response:
|
||||
exports = {
|
||||
"app": "export const app = window.kikoHost.app;",
|
||||
"api": "export const api = window.kikoHost.api;",
|
||||
"widgets": "export const ComfyWidgets = window.kikoHost.widgets;",
|
||||
"ui": "export const $el = window.kikoHost.el;",
|
||||
}
|
||||
if request.match_info["name"] not in exports:
|
||||
raise web.HTTPNotFound()
|
||||
return web.Response(
|
||||
text=exports[request.match_info["name"]], content_type="text/javascript"
|
||||
)
|
||||
|
||||
@routes.get("/view")
|
||||
@routes.get("/api/view")
|
||||
async def comfy_view(request: web.Request) -> web.Response:
|
||||
directory = root / "output"
|
||||
path = (
|
||||
directory
|
||||
/ request.query.get("subfolder", "")
|
||||
/ request.query.get("filename", "")
|
||||
).resolve()
|
||||
if not path.is_relative_to(directory) or not path.is_file():
|
||||
raise web.HTTPNotFound()
|
||||
return web.FileResponse(path)
|
||||
|
||||
reset()
|
||||
app.add_routes(routes)
|
||||
app.router.add_static("/extensions/kikotools", REPOSITORY / "web")
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8766)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix="kiko-browser-") as directory:
|
||||
web.run_app(
|
||||
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,234 @@
|
||||
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/testing/browser-coverage.mjs';
|
||||
|
||||
test.beforeEach(async ({ page, request, context, browserName }) => {
|
||||
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 (url.hostname === '127.0.0.1') 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');
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
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()'>&`), '<img src="x" onerror='run()'>&');
|
||||
});
|
||||
|
||||
test('gallery HTTP errors preserve actionable server detail and handle non-JSON responses', async () => {
|
||||
const { context } = load('kiko_local_image_loader.js', { document: { getElementById: () => ({}) } });
|
||||
const describe = vm.runInContext('describeHttpError', context);
|
||||
assert.equal(await describe({ status: 403, json: async () => ({ error: 'Choose an allowed root' }) }), 'Choose an allowed root');
|
||||
assert.match(await describe({ status: 403, json: async () => { throw new Error('not JSON'); } }), /allowed folders/);
|
||||
assert.match(await describe({ status: 400, json: async () => ({}) }), /Invalid path/);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Ensure the documented test command actually invokes pytest and fails closed."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not shutil.which("make"), reason="Requires POSIX make"
|
||||
)
|
||||
def test_make_test_runs_pytest_and_propagates_failure(tmp_path: Path) -> None:
|
||||
recorder = tmp_path / "runner.py"
|
||||
arguments = tmp_path / "arguments.json"
|
||||
recorder.write_text(
|
||||
"import json,sys\n"
|
||||
f"with open({str(arguments)!r}, 'w') as output:\n"
|
||||
" json.dump(sys.argv[1:], output)\n"
|
||||
"sys.exit(7)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"make",
|
||||
"--no-print-directory",
|
||||
"-o",
|
||||
"venv",
|
||||
"test",
|
||||
f"PYTHON_CMD={shlex.join([sys.executable, str(recorder)])}",
|
||||
],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert json.loads(arguments.read_text()) == ["-m", "pytest", "tests"]
|
||||
assert result.returncode != 0
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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; });
|
||||
}
|
||||
Generated
+785
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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' } },
|
||||
],
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user