From 5a8a8b62e4dfa8f0efe46bb67c7bea94a5456550 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 16 Oct 2026 08:32:11 -0700 Subject: [PATCH] test: add extension workflows coverage and required merge checks --- .comfyignore | 5 + .flake8 | 1 + .github/branch-protection.json | 16 + .github/dependabot.yml | 4 + .github/workflows/code-quality.yml | 2 + .github/workflows/release.yml | 41 +- .github/workflows/tests.yml | 73 +- .gitignore | 6 + .pre-commit-config.yaml | 23 +- AGENTS.md | 46 + CONTRIBUTING.md | 5 +- Makefile | 29 +- README.md | 13 +- TESTING.md | 91 ++ pyproject.toml | 2 + requirements-dev.txt | 1 + tests/browser/__init__.py | 0 tests/browser/host.mjs | 116 +++ tests/browser/server.py | 167 ++++ tests/browser/workflows.spec.mjs | 234 ++++++ tests/js/coverage.test.mjs | 22 + tests/js/extensions.test.mjs | 146 ++++ tests/test_development_commands.py | 41 + tools/testing/browser-coverage.mjs | 23 + tools/testing/frontend-coverage-baseline.json | 80 ++ tools/testing/frontend-coverage.mjs | 75 ++ tools/testing/package-lock.json | 785 ++++++++++++++++++ tools/testing/package.json | 13 + tools/testing/playwright.config.mjs | 27 + tools/testing/start-browser-server.mjs | 8 + web/kiko_local_image_loader.js | 5 +- web/width_height_swap.js | 16 +- 32 files changed, 2049 insertions(+), 67 deletions(-) create mode 100644 .github/branch-protection.json create mode 100644 AGENTS.md create mode 100644 TESTING.md create mode 100644 tests/browser/__init__.py create mode 100644 tests/browser/host.mjs create mode 100644 tests/browser/server.py create mode 100644 tests/browser/workflows.spec.mjs create mode 100644 tests/js/coverage.test.mjs create mode 100644 tests/js/extensions.test.mjs create mode 100644 tests/test_development_commands.py create mode 100644 tools/testing/browser-coverage.mjs create mode 100644 tools/testing/frontend-coverage-baseline.json create mode 100644 tools/testing/frontend-coverage.mjs create mode 100644 tools/testing/package-lock.json create mode 100644 tools/testing/package.json create mode 100644 tools/testing/playwright.config.mjs create mode 100644 tools/testing/start-browser-server.mjs diff --git a/.comfyignore b/.comfyignore index b7d06d2..2a6679c 100644 --- a/.comfyignore +++ b/.comfyignore @@ -21,3 +21,8 @@ example.py CLAUDE.md CONTRIBUTING.md CODE_OF_CONDUCT.md + +tools/ +coverage/ +TESTING.md +AGENTS.md diff --git a/.flake8 b/.flake8 index 34e56f4..948054a 100644 --- a/.flake8 +++ b/.flake8 @@ -2,6 +2,7 @@ max-line-length = 127 max-complexity = 10 exclude = + node_modules, .git, __pycache__, .mypy_cache, diff --git a/.github/branch-protection.json b/.github/branch-protection.json new file mode 100644 index 0000000..2b136a0 --- /dev/null +++ b/.github/branch-protection.json @@ -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 +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 645c171..b7d6656 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,7 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "npm" + directory: "/tools/testing" + schedule: + interval: "weekly" diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 0969c6e..69168a1 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baf6a44..fd01af8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ef057d1..d99ddcc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index d99fa64..db485e2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 68d93d8..c913bd2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c397c8d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +`kikotools/tools//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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de836d6..129f541 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/Makefile b/Makefile index e034663..9ce1306 100644 --- a/Makefile +++ b/Makefile @@ -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..." diff --git a/README.md b/README.md index afeb975..5742148 100644 --- a/README.md +++ b/README.md @@ -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) + +## Automated testing + +See [TESTING.md](TESTING.md) for Python and JavaScript tests, cross-browser +workflows, coverage gates, CI reports, and required merge checks. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..f07d49c --- /dev/null +++ b/TESTING.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 8ee96c9..a005b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 0da8aa6..8fc8a9c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 diff --git a/tests/browser/__init__.py b/tests/browser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/browser/host.mjs b/tests/browser/host.mjs new file mode 100644 index 0000000..5a494e8 --- /dev/null +++ b/tests/browser/host.mjs @@ -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'; diff --git a/tests/browser/server.py b/tests/browser/server.py new file mode 100644 index 0000000..a91a17f --- /dev/null +++ b/tests/browser/server.py @@ -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='KikoTools extension test host
', + 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() diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs new file mode 100644 index 0000000..5219c6a --- /dev/null +++ b/tests/browser/workflows.spec.mjs @@ -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: \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(''); + 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'); +}); diff --git a/tests/js/coverage.test.mjs b/tests/js/coverage.test.mjs new file mode 100644 index 0000000..69222a3 --- /dev/null +++ b/tests/js/coverage.test.mjs @@ -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/); +}); diff --git a/tests/js/extensions.test.mjs b/tests/js/extensions.test.mjs new file mode 100644 index 0000000..4ccf43f --- /dev/null +++ b/tests/js/extensions.test.mjs @@ -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()'>&'); +}); + +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/); +}); diff --git a/tests/test_development_commands.py b/tests/test_development_commands.py new file mode 100644 index 0000000..0e30544 --- /dev/null +++ b/tests/test_development_commands.py @@ -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 diff --git a/tools/testing/browser-coverage.mjs b/tools/testing/browser-coverage.mjs new file mode 100644 index 0000000..98d1a17 --- /dev/null +++ b/tools/testing/browser-coverage.mjs @@ -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 })); +} diff --git a/tools/testing/frontend-coverage-baseline.json b/tools/testing/frontend-coverage-baseline.json new file mode 100644 index 0000000..300f078 --- /dev/null +++ b/tools/testing/frontend-coverage-baseline.json @@ -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 + } +} diff --git a/tools/testing/frontend-coverage.mjs b/tools/testing/frontend-coverage.mjs new file mode 100644 index 0000000..7e206fb --- /dev/null +++ b/tools/testing/frontend-coverage.mjs @@ -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; }); +} diff --git a/tools/testing/package-lock.json b/tools/testing/package-lock.json new file mode 100644 index 0000000..e0d0131 --- /dev/null +++ b/tools/testing/package-lock.json @@ -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" + } + } + } +} diff --git a/tools/testing/package.json b/tools/testing/package.json new file mode 100644 index 0000000..4814452 --- /dev/null +++ b/tools/testing/package.json @@ -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" } +} diff --git a/tools/testing/playwright.config.mjs b/tools/testing/playwright.config.mjs new file mode 100644 index 0000000..3ead3b9 --- /dev/null +++ b/tools/testing/playwright.config.mjs @@ -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' } }, + ], +}); diff --git a/tools/testing/start-browser-server.mjs b/tools/testing/start-browser-server.mjs new file mode 100644 index 0000000..9b66c1f --- /dev/null +++ b/tools/testing/start-browser-server.mjs @@ -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)); +} diff --git a/web/kiko_local_image_loader.js b/web/kiko_local_image_loader.js index 0a5bca6..4da71a0 100644 --- a/web/kiko_local_image_loader.js +++ b/web/kiko_local_image_loader.js @@ -451,9 +451,8 @@ app.registerExtension({ -
-
Loading...
-
+
Loading...
+
diff --git a/web/width_height_swap.js b/web/width_height_swap.js index 3addd46..cc9e5e6 100644 --- a/web/width_height_swap.js +++ b/web/width_height_swap.js @@ -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); } }