diff --git a/.githooks/pii-guard.sh b/.githooks/pii-guard.sh new file mode 100644 index 0000000..3e6003b --- /dev/null +++ b/.githooks/pii-guard.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Blocks customer data from entering this public repo. +# +# Checks (staged files on commit, whole tree in CI): +# 1. Known customer file names (e.g. PermWat) are rejected outright. +# 2. Every binary fixture must be listed in .github/fixture-allowlist.txt — +# adding one forces a reviewable allowlist edit in the same change. +# 3. OOXML files (xlsx/docx/pptx) are unzipped and their XML scanned for +# email addresses outside the allowed domains. +# 4. Text additions are scanned for email addresses outside allowed domains. +# +# Note: an email rasterized into an image cannot be detected here — that is +# what the allowlist review step is for. +# +# Usage: pii-guard.sh --staged | --all + +set -u +MODE="${1:---staged}" +cd "$(git rev-parse --show-toplevel)" || exit 1 + +ALLOWLIST=".github/fixture-allowlist.txt" +EMAIL_RE='[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' +ALLOWED_DOMAINS_RE='@(applitools\.com|example\.com|proxy\.local|([A-Za-z0-9-]+\.)*noreply\.github\.com|anthropic\.com)$' +BINARY_EXT_RE='\.(pdf|xlsx?|docx?|pptx?|png|jpe?g|gif|bmp|ico|zip|jar|ps)$' +OOXML_EXT_RE='\.(xlsx|docx|pptx)$' +BLOCKED_NAME_RE='permwat|black_card' +# Exact addresses allowed anywhere: fake identities from public sample datasets. +ALLOWED_EMAILS='bob@msn.com' + +fail=0 +err() { printf 'PII-GUARD: %s\n' "$1" >&2; fail=1; } + +# OOXML scanning needs unzip; a guard that cannot scan must fail, not pass. +command -v unzip >/dev/null 2>&1 || { err "'unzip' not found — cannot scan OOXML files"; exit 1; } + +ALLOWED_EMAILS_TMP=$(mktemp) || exit 1 +ALLOWLIST_TMP=$(mktemp) || exit 1 +trap 'rm -f "$ALLOWED_EMAILS_TMP" "$ALLOWLIST_TMP"' EXIT +printf '%s\n' $ALLOWED_EMAILS > "$ALLOWED_EMAILS_TMP" +# Strip CRs: Windows checkouts render the allowlist with CRLF, which breaks +# exact-line matching. Missing allowlist -> empty file -> fail closed. +tr -d '\r' < "$ALLOWLIST" > "$ALLOWLIST_TMP" 2>/dev/null || true + +if [ "$MODE" = "--staged" ]; then + files=$(git diff --cached --name-only --diff-filter=ACMR) +else + files=$(git ls-files) +fi + +# --- 1 & 2 & 3: per-file checks ------------------------------------------- +while IFS= read -r f; do + [ -z "$f" ] && continue + lower=$(printf '%s' "$f" | tr '[:upper:]' '[:lower:]') + + if printf '%s' "$lower" | grep -qE "$BLOCKED_NAME_RE"; then + err "'$f' matches a known customer-data name and must never be committed" + continue + fi + + if printf '%s' "$lower" | grep -qE "$BINARY_EXT_RE"; then + if ! grep -qxF "$f" "$ALLOWLIST_TMP"; then + err "binary fixture '$f' is not in $ALLOWLIST — verify it contains no customer data (open any embedded images!), then add its path" + fi + fi + + if printf '%s' "$lower" | grep -qE "$OOXML_EXT_RE"; then + tmp=$(mktemp) || exit 1 + if [ "$MODE" = "--staged" ]; then + git show ":$f" > "$tmp" 2>/dev/null || cp "$f" "$tmp" + else + cp "$f" "$tmp" + fi + # Extract XML-ish entries one by one: unzip wildcard patterns do not + # match subdirectory entries on all platforms (Windows Info-ZIP), which + # made this scan silently pass. Never rely on '*.xml'. + hits=$(unzip -Z1 "$tmp" 2>/dev/null | grep -Ei '\.(xml|rels|vml)$' \ + | while IFS= read -r entry; do unzip -p "$tmp" "$entry" 2>/dev/null; done \ + | grep -aoE "$EMAIL_RE" | grep -vE "$ALLOWED_DOMAINS_RE" | grep -vxFf "$ALLOWED_EMAILS_TMP" | sort -u) + rm -f "$tmp" + if [ -n "$hits" ]; then + err "'$f' embeds non-allowlisted email(s): $(printf '%s' "$hits" | tr '\n' ' ')" + fi + fi +done </dev/null \ + | grep -vE "$ALLOWED_DOMAINS_RE" | grep -vxFf "$ALLOWED_EMAILS_TMP" | sort -u) +fi +if [ -n "$text_hits" ]; then + err "non-allowlisted email address(es) in text: $(printf '%s' "$text_hits" | tr '\n' ' ')" +fi + +if [ "$fail" -ne 0 ]; then + { + echo 'PII-GUARD: commit blocked.' + echo 'PII-GUARD: if this is genuinely clean synthetic data, add binaries to .github/fixture-allowlist.txt' + echo 'PII-GUARD: or extend the allowed-domain list in .githooks/pii-guard.sh — in the same reviewed change.' + } >&2 + exit 1 +fi +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..6390af9 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/bin/sh +# Enable with: git config core.hooksPath .githooks +exec "$(git rev-parse --show-toplevel)/.githooks/pii-guard.sh" --staged diff --git a/.github/fixture-allowlist.txt b/.github/fixture-allowlist.txt new file mode 100644 index 0000000..b9337ac --- /dev/null +++ b/.github/fixture-allowlist.txt @@ -0,0 +1,55 @@ +# Binary files approved for this public repository. +# .githooks/pii-guard.sh (pre-commit + CI) rejects any tracked binary not +# listed here. Before adding a file: open it, including every embedded image +# (xlsx/docx media can rasterize emails and names), and confirm it contains +# no customer data. Sanitize first if in doubt — see +# TestData/xlsx-header-watermark.xlsx for an example of a scrubbed fixture. +TestData/a/wikipedia.png +TestData/b/Lorem1.pdf +TestData/b/c/JustPDF/Lorem2.pdf +TestData/b/c/JustPDF/Lorem3.pdf +TestData/b/c/JustPostscript/Lorem2.ps +TestData/b/c/d/wikihow.png +TestData/b/c/d/wikiquote.png +TestData/b/c/googleforgoogle.png +TestData/diffs/actual/lorem_20.pdf +TestData/diffs/base/lorem_20.pdf +TestData/image-T0MP130031-01.png +TestData/jpegs/alphabetic/a.jpg +TestData/jpegs/alphabetic/b.jpg +TestData/jpegs/alphabetic/c.jpg +TestData/jpegs/alphabetic/d.jpg +TestData/jpegs/mixed/1.jpg +TestData/jpegs/mixed/2.jpg +TestData/jpegs/mixed/3.jpg +TestData/jpegs/mixed/4.jpg +TestData/jpegs/mixed/a.jpg +TestData/jpegs/mixed/b.jpg +TestData/jpegs/mixed/c.jpg +TestData/jpegs/mixed/d.jpg +TestData/jpegs/numeric/1.jpg +TestData/jpegs/numeric/2.jpg +TestData/jpegs/numeric/3.jpg +TestData/jpegs/numeric/4.jpg +TestData/multi-format/Accessing user tickets in Zendesk (1).docx +TestData/multi-format/Copy of Dealing with sensitive data in support.pptx +TestData/multi-format/contributing-to-docs (2).pptx +TestData/multi-format/sample.docx +TestData/multi-format/sample.pptx +TestData/multi-format/sample.ps +TestData/multi-format/test_sample_xlsx_Small.xlsx +TestData/xlsx-header-watermark.xlsx +gui-launch.png +jars/test_jbig.pdf +libs/com/applitools/eyesutilities/EyesUtilities/1.6.6/EyesUtilities-1.6.6.jar +orig_page_fullsize.png +orig_policy_doc.png +src/test/resources/fixtures/corrupted.pdf +src/test/resources/fixtures/empty.pdf +src/test/resources/fixtures/password-protected.pdf +src/test/resources/fixtures/sample.png +src/test/resources/fixtures/valid-1-page.pdf +src/test/resources/fixtures/valid-10-page.pdf +src/test/resources/fixtures/valid-2-page.pdf +src/test/resources/watermark/PDF_with_watermark.pdf +src/test/resources/watermark/PDF_without_watermark.pdf diff --git a/.github/release-template.md b/.github/release-template.md new file mode 100644 index 0000000..fd54294 --- /dev/null +++ b/.github/release-template.md @@ -0,0 +1,40 @@ +ImageTester GUI — visual testing for images, PDFs & documents, powered by Applitools Eyes. Download the installer for your machine, double-click it, and the app opens in your browser. Nothing else to install. + +## Which file do I download? + +| Your machine | File | +|---|---| +| Windows | `ImageTester-{{VERSION}}-Windows.msi` | +| Mac with Apple Silicon | `ImageTester-{{VERSION}}-macOS-AppleSilicon.dmg` | +| Mac with Intel | `ImageTester-{{VERSION}}-macOS-Intel.dmg` | +| Linux (Debian/Ubuntu) | `ImageTester-{{VERSION}}-Linux.deb` | + +Not sure which Mac you have? Apple menu → **About This Mac**: "Chip: Apple M…" means Apple Silicon; "Processor: Intel…" means Intel. + +## Command line (CLI jars) + +Prefer the CLI? Grab a jar and run it with Java 11+: `java -jar ImageTester_{{VERSION}}.jar -k [options]` + +| Environment | File | +|---|---| +| Any (largest, bundles every platform) | `ImageTester_{{VERSION}}.jar` | +| Windows x64 | `ImageTester_{{VERSION}}_Windows.jar` | +| Mac Intel | `ImageTester_{{VERSION}}_Mac.jar` | +| Mac Apple Silicon | `ImageTester_{{VERSION}}_MacArm.jar` | +| Linux x64 | `ImageTester_{{VERSION}}_Linux.jar` | +| Alpine Linux | `ImageTester_{{VERSION}}_Alpine.jar` | +| Linux ARM | `ImageTester_{{VERSION}}_Arm.jar` | + + +## First launch + +This demo build isn't code-signed yet (signed builds are planned), so your computer will warn you the first time you run it: + +- **Windows** shows "Windows protected your PC". Click **More info**, then **Run anyway**. +- **macOS** blocks the first launch. Open **System Settings → Privacy & Security**, scroll down, click **Open Anyway** next to ImageTester, and confirm. +- **macOS** may also ask to use "confidential information stored in Applitools ImageTester in your keychain" — that's the app reading back the API key it saved securely in your Keychain. Click **Always Allow**. (Unsigned builds re-ask after each update; signed builds won't.) + + +## Using the app + +Launching ImageTester opens a page in your browser. Paste your Applitools API key, choose the file or folder to test, and click **Run test**. Results stream in live, with a link to your results on the Applitools dashboard. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50b938b..9042564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,20 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + pii-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Scan tracked files for customer data + run: bash .githooks/pii-guard.sh --all + unit-tests: runs-on: ubuntu-latest @@ -45,3 +53,36 @@ jobs: env: APPLITOOLS_API_KEY: ${{ secrets.APPLITOOLS_API_KEY }} run: mvn test --batch-mode -Peyes-tests + + gui-installers: + needs: unit-tests + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: maven + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build installer + # bash on every OS: Windows' default pwsh splits -Dowasp.skip=true at the dot + shell: bash + run: mvn -Pgui-installers -DskipTests -Dowasp.skip=true clean verify --batch-mode + + - name: Upload installer + uses: actions/upload-artifact@v4 + with: + name: imagetester-installer-${{ matrix.os }} + path: target/installers/* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db9c279..b230e6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,9 @@ jobs: java-version: 17 cache: maven + - name: Scan tracked files for customer data + run: bash .githooks/pii-guard.sh --all + - name: Resolve version and validate tag id: ver shell: bash @@ -53,13 +56,16 @@ jobs: build: needs: validate strategy: + # One OS failing must not cancel the other installers mid-build. + fail-fast: false matrix: include: - os: windows-latest label: Windows - os: macos-latest label: macOS-AppleSilicon - - os: macos-13 + # macos-13 was retired from the hosted fleet; macos-15-intel is the x64 label + - os: macos-15-intel label: macOS-Intel - os: ubuntu-latest label: Linux @@ -82,6 +88,8 @@ jobs: node-version: 20 - name: Build installer + # bash on every OS: Windows' default pwsh splits -Dowasp.skip=true at the dot + shell: bash run: mvn -Pgui-installers -DskipTests -Dowasp.skip=true clean verify --batch-mode # Signing seam: fail loudly if secrets exist before signing is implemented, @@ -123,9 +131,50 @@ jobs: path: target/installers/* if-no-files-found: error + build-jars: + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 11 + cache: maven + + # Platform jars differ only by which eyes-universal-core binary is bundled, + # so a single Linux build produces all seven (frontend-maven-plugin installs + # its own Node for the GUI bundle). + - name: Build CLI jars (universal + six platform jars) + run: mvn -DskipTests package --batch-mode + + - name: Verify expected jars exist + shell: bash + run: | + cd jars + # Jars are named with the pom version; a suffixed tag (v1.2.3-rc1) still builds 1.2.3 jars. + BASE="${{ needs.validate.outputs.version }}" + BASE="${BASE%%-*}" + for suffix in "" "_Windows" "_Mac" "_MacArm" "_Linux" "_Alpine" "_Arm"; do + f="ImageTester_${BASE}${suffix}.jar" + if [ ! -f "$f" ]; then + echo "Missing expected jar: $f" >&2 + ls >&2 + exit 1 + fi + done + ls -la ImageTester_*.jar + + - uses: actions/upload-artifact@v4 + with: + name: cli-jars + path: jars/ImageTester_*.jar + if-no-files-found: error + release: if: startsWith(github.ref, 'refs/tags/') - needs: [validate, build] + needs: [validate, build, build-jars] runs-on: ubuntu-latest permissions: contents: write diff --git a/.gitignore b/.gitignore index 0e4d728..d0463a7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ out/ target/ *.iml *.jar +# libs/ is a file-based Maven repo for deps not published anywhere public +!libs/** Artifacts/ debug/ src/test/java/Private/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..89f92a2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +## One-time setup: PII guard + +This is a public repository that regularly handles customer files during +support work. A guard blocks customer data from being committed. Enable the +local hook once per clone: + +``` +git config core.hooksPath .githooks +``` + +CI runs the same scan (`.githooks/pii-guard.sh --all`) on every PR, push, and +release, so the guard holds even without the local hook — the hook just fails +faster. + +## Test fixture policy + +- **Never commit customer files.** Files from support tickets (PDFs, xlsx, + screenshots) stay outside the repo or in gitignored directories + (`TestData/` is gitignored; tracked fixtures there were explicitly + force-added). +- Every binary fixture must be listed in `.github/fixture-allowlist.txt`. + Adding one is a deliberate, reviewed act: open the file first — **including + every embedded image**, since spreadsheets and documents rasterize emails + and names into media parts that text scans cannot see. +- If a test needs a structure only a customer file has, sanitize a copy: + replace embedded media with generated images and scrub metadata (see + `TestData/xlsx-header-watermark.xlsx`, a scrubbed clone of a customer file, + and the note in `XlsxWatermarkStamperTest`). +- The email-domain allowlist lives in `.githooks/pii-guard.sh` + (`example.com`, `applitools.com`, …). Extend it only in a reviewed change. diff --git a/README.md b/README.md index 4a6d4ac..5a3762c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ If you don't have your Applitools account yet, please [sign up first]("https://applitools.com/sign-up/") and get your Applitools api-key that will be used next to execute the tests. +## ImageTester GUI (beta) + +Prefer an app over the command line? Download the ImageTester GUI installer for your OS from the +[latest release](https://github.com/applitools/ImageTester/releases) — Windows (`.msi`), +macOS (`.dmg`, Apple Silicon and Intel), or Linux (`.deb`). Each bundles everything it needs; +double-click and the app opens in your browser. See the release notes for first-launch +instructions (demo builds are not yet code-signed, so your OS will ask you to confirm once). + The tool can be invoked on a single file or a complex folder structure with mixed content. Once provided a complex folder structure the tool recursively scans the structure and determines on each level what should be the batch-name, the directoryTest-name and the tag values relatively to the target files. @@ -183,6 +191,7 @@ Options that apply when testing PDFs and other documents. + `-di [dpi]` - Rendering quality (dots per inch) for PDF pages; default = 250 + `-sp [pages]` - Comma-separated page numbers/ranges to include (e.g. 1,2,5,7,10-15); default = all pages ++ `-tp [size]` - Trim print margins (crop marks, slug area) from PDF pages before comparing. Bare `-tp` (or `-tp auto`) detects the trim area from TrimBox metadata or crop marks; `-tp 603x774` crops a centered box of that size in PDF points. Enabling trim changes checkpoint dimensions, so existing baselines will mismatch on the first trimmed run. Marks flattened into raster scans are not detected — use the explicit size for those. + `-pp [password]` - Password for opening protected PDF files + `-pn` - Preserve the original test names when testing only selected pages + `-st` - Split a multi-page document into individual single-step tests diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..928befa --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,51 @@ +# Releasing ImageTester + +One tag push builds and publishes everything: seven CLI jars and four GUI +installers, attached to a GitHub pre-release by `.github/workflows/release.yml`. + +## What a release contains + +| Asset | Built by | Runner | +|---|---|---| +| `ImageTester_.jar` (universal) + `_Windows` / `_Mac` / `_MacArm` / `_Linux` / `_Alpine` / `_Arm` | `build-jars` job — `mvn -DskipTests package` | ubuntu (one build produces all seven; they differ only by bundled eyes-universal-core binary) | +| `ImageTester--Windows.msi` | `build` matrix — `mvn -Pgui-installers -DskipTests -Dowasp.skip=true clean verify` | windows-latest | +| `ImageTester--macOS-AppleSilicon.dmg` | 〃 | macos-latest | +| `ImageTester--macOS-Intel.dmg` | 〃 | macos-15-intel | +| `ImageTester--Linux.deb` | 〃 | ubuntu-latest | + +Release notes come from `.github/release-template.md` (`{{VERSION}}` substituted). + +## Steps + +1. **Bump the version** in `pom.xml` (``). The workflow refuses tags + whose base version disagrees with the pom, so installer metadata and asset + names always match. +2. Merge to `main` with CI green. +3. **Tag and push**: + ``` + git tag v3.13.0 + git push origin v3.13.0 + ``` + Suffixed tags (`v3.13.0-rc1`) are allowed; the base must equal the pom version. +4. The Release workflow creates a **pre-release** with all eleven assets. + Download, smoke-test an installer and a jar, then flip the release from + pre-release to latest in the GitHub UI. The README's Download badge points at + the latest release. + +## Dry run + +Run the Release workflow manually (`workflow_dispatch`): it builds every asset +as workflow artifacts but never creates a release. + +## Building locally + +- CLI jars: `mvn -DskipTests package` (JDK 11+) → `jars/ImageTester_*.jar`. +- GUI installer for your OS: `mvn -Pgui-installers -DskipTests -Dowasp.skip=true clean verify` + (needs JDK 17 — jpackage) → `target/installers/`. + +## Signing + +Installers currently ship unsigned (the release notes explain the OS warnings). +The workflow fails loudly if `WINDOWS_SIGNING_CERT` / `APPLE_SIGNING_CERT` +secrets are set before the signing steps are implemented, so a "signed" release +can never silently ship unsigned. diff --git a/TestData/xlsx-header-watermark.xlsx b/TestData/xlsx-header-watermark.xlsx new file mode 100644 index 0000000..7ec2c69 Binary files /dev/null and b/TestData/xlsx-header-watermark.xlsx differ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b514b9e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + ImageTester + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2de5fd0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4554 @@ +{ + "name": "imagetester-gui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "imagetester-gui", + "version": "0.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/react": "^15.0.7", + "@types/node": "^20.19.43", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "jsdom": "^24.0.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.2.13", + "vitest": "^1.6.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^10.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "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/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.354", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz", + "integrity": "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6b50ce3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "imagetester-gui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "test": "vitest run" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/react": "^15.0.7", + "@types/node": "^20.19.43", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "jsdom": "^24.0.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.2.13", + "vitest": "^1.6.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..99417eb --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..62f8a48 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,138 @@ +import { useEffect, useReducer, useRef, useState } from "react"; +import { SetupCard } from "./components/SetupCard"; +import { StatusPane } from "./components/StatusPane"; +import { OptionsDrawer } from "./components/OptionsDrawer"; +import { api } from "./lib/api"; +import { connectSse } from "./lib/sse"; +import type { SseHandle } from "./lib/sse"; +import { getVersion } from "./lib/version"; +import { loadOptions, saveOptions, countNonDefault, toRunPayload } from "./lib/options"; +import type { RunOptions } from "./lib/options"; +import type { MatchLevel, RunStateSnapshot, SseEvent, TestRow } from "./types"; + +const LAST_SOURCE_PATH_KEY = "imagetester.lastSourcePath"; + +function readLastSourcePath(): string { + try { return window.localStorage.getItem(LAST_SOURCE_PATH_KEY) ?? ""; } catch { return ""; } +} + +function writeLastSourcePath(value: string) { + try { window.localStorage.setItem(LAST_SOURCE_PATH_KEY, value); } catch { /* private mode / disabled */ } +} + +type Action = + | { type: "set"; snapshot: RunStateSnapshot } + | { type: "sse"; event: SseEvent }; + +function reducer(state: RunStateSnapshot, action: Action): RunStateSnapshot { + if (action.type === "set") return action.snapshot; + const e = action.event; + // The server emits run-started before /api/run even responds, so this — not the + // optimistic dispatch in onRun — is the authoritative idle → running transition. + if (e.type === "run-started") return { kind: "running", runId: e.runId, tests: [] }; + if (state.kind !== "running") return state; + switch (e.type) { + case "test-started": + return { ...state, tests: [...state.tests, { name: e.name, status: "running", startedAtMs: Date.now() }] }; + case "test-finished": { + const exists = state.tests.some((t) => t.name === e.name); + const tests: TestRow[] = exists + ? state.tests.map((t) => t.name === e.name ? { ...t, status: e.status, durationMs: e.durationMs, dashboardUrl: e.dashboardUrl } : t) + : [...state.tests, { name: e.name, status: e.status, durationMs: e.durationMs, dashboardUrl: e.dashboardUrl }]; + return { ...state, tests }; + } + case "run-finished": + return { kind: "done", runId: state.runId, tests: state.tests, passed: e.passed, failed: e.failed, durationMs: e.durationMs }; + case "watermark-cleaned": + return { kind: "done", runId: state.runId, tests: state.tests, passed: 0, failed: 0, durationMs: e.durationMs, outputDir: e.outputDir, fileCount: e.fileCount }; + default: + return state; + } +} + +export function App() { + const [snapshot, dispatch] = useReducer(reducer, { kind: "idle" } as RunStateSnapshot); + const [hasKey, setHasKey] = useState(false); + const [sourcePath, setSourcePath] = useState(readLastSourcePath); + const [options, setOptions] = useState(loadOptions); + const [drawerOpen, setDrawerOpen] = useState(false); + const [logLines, setLogLines] = useState([]); + const [runError, setRunError] = useState(null); + const esRef = useRef(null); + + const setOption = (flag: string, value: unknown) => { + setOptions((prev) => { const next = { ...prev, [flag]: value }; saveOptions(next); return next; }); + }; + + useEffect(() => { + api.hasApiKey().then((r) => setHasKey(r.hasKey)).catch(() => {}); + api.status().then((s) => dispatch({ type: "set", snapshot: s as RunStateSnapshot })).catch(() => {}); + esRef.current = connectSse( + (e) => { + if (e.type === "log-line") setLogLines((l) => [...l, e.text]); + else dispatch({ type: "sse", event: e }); + }, + // Re-sync on every (re)connect — events emitted while the stream was down are gone, + // so the snapshot is the only way to catch up. + () => api.status().then((s) => dispatch({ type: "set", snapshot: s as RunStateSnapshot })).catch(() => {}), + ); + return () => esRef.current?.close(); + }, []); + + return ( +
+
+
+ + ImageTester + {getVersion() && v{getVersion()}} + Docs ↗ +
+

+ Visual regression testing for images, PDFs & documents — compares them against baselines in Applitools Eyes. +

+
+
+
+ setDrawerOpen((v) => !v)} + onSetKey={async (v) => { if (v) { await api.setApiKey(v); setHasKey(true); } }} + onChoosePath={async (t) => { const r = await api.choosePath(t, sourcePath || undefined); if (r.path) { setSourcePath(r.path); writeLastSourcePath(r.path); } }} + onMatchLevel={(l) => setOption("ml", l)} + onRun={async () => { + setRunError(null); + setLogLines([]); + try { + const r = await api.run(toRunPayload(sourcePath, options)); + dispatch({ type: "set", snapshot: { kind: "running", runId: r.runId, tests: [] } }); + } catch (err) { + setRunError(err instanceof Error ? err.message : String(err)); + } + }} + onCancel={() => { + api.cancel(); + // Wipe the right pane immediately — the user expects a clean slate, not a frozen final state. + // The reducer ignores SSE events when not in "running", so any in-flight test-finished is dropped. + dispatch({ type: "set", snapshot: { kind: "idle" } }); + setLogLines([]); + }} + /> + {runError &&

{runError}

} +
+ +
+ {drawerOpen && ( +
+ setDrawerOpen(false)} /> +
+ )} +
+ ); +} diff --git a/frontend/src/components/OptionsDrawer.tsx b/frontend/src/components/OptionsDrawer.tsx new file mode 100644 index 0000000..db6a249 --- /dev/null +++ b/frontend/src/components/OptionsDrawer.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { OPTION_SPECS, TABS, docUrl, type OptionSpec, type TabId } from "../lib/optionsSchema"; +import { countNonDefaultForTab, listNonDefault, type RunOptions } from "../lib/options"; +import { ScalarControl } from "./controls/ScalarControl"; +import { RegionBuilder } from "./controls/RegionBuilder"; +import { ProxyControl } from "./controls/ProxyControl"; +import { PropertiesControl } from "./controls/PropertiesControl"; +import { ImageCutControl } from "./controls/ImageCutControl"; +import { WatermarkOutControl } from "./controls/WatermarkOutControl"; + +interface Props { + options: RunOptions; + onChange: (flag: string, value: unknown) => void; + onClose: () => void; +} + +const FULL_ROW_TYPES = new Set(["regions", "proxy", "properties", "imagecut", "watermarkout"]); +const CHIP_VALUE_MAX_CHARS = 24; + +function chipText(spec: OptionSpec, value: unknown): string { + if (typeof value === "boolean") return spec.label; + const text = String(value); + const shown = text.length > CHIP_VALUE_MAX_CHARS ? text.slice(0, CHIP_VALUE_MAX_CHARS) + "…" : text; + return `${spec.label}: ${shown}`; +} + +function renderControl(spec: OptionSpec, value: unknown, onChange: (v: unknown) => void) { + switch (spec.type) { + case "regions": return ; + case "proxy": return ; + case "properties": return ; + case "imagecut": return ; + case "watermarkout": return ; + default: return ; + } +} + +export function OptionsDrawer({ options, onChange, onClose }: Props) { + const [active, setActive] = useState("metadata"); + const activeTab = TABS.find((t) => t.id === active); + const specs = OPTION_SPECS.filter((s) => s.tab === active); + const activeOptions = listNonDefault(options); + return ( +
+
+ Options + +
+ {activeOptions.length > 0 && ( +
+ {activeOptions.map(({ spec, value }) => ( + + ))} +
+ )} +
+ {TABS.map((t) => { + const setCount = countNonDefaultForTab(options, t.id); + return ( + + ); + })} +
+ {activeTab &&

{activeTab.description}

} +
+ {specs.map((spec) => ( +
+ {renderControl(spec, options[spec.flag], (v) => onChange(spec.flag, v))} + {spec.help && ( +

+ {spec.help}{" "} + README ↗ +

+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/SetupCard.tsx b/frontend/src/components/SetupCard.tsx new file mode 100644 index 0000000..a077785 --- /dev/null +++ b/frontend/src/components/SetupCard.tsx @@ -0,0 +1,69 @@ +import type { MatchLevel } from "../types"; + +interface Props { + hasKey: boolean; + sourcePath: string; + matchLevel: MatchLevel; + running: boolean; + optionsCount: number; + drawerOpen: boolean; + onSetKey: (value: string) => void; + onChoosePath: (type: "file" | "folder") => void; + onMatchLevel: (l: MatchLevel) => void; + onRun: () => void; + onCancel: () => void; + onToggleDrawer: () => void; +} + +export function SetupCard(p: Props) { + const canRun = p.hasKey && p.sourcePath.length > 0; + return ( +
+

Setup

+ + + +
+ +
+ {p.sourcePath || "No file or folder chosen"} +
+
+ + +
+
+ + + + + + {p.running ? ( + + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/StatusPane.tsx b/frontend/src/components/StatusPane.tsx new file mode 100644 index 0000000..b17e6d5 --- /dev/null +++ b/frontend/src/components/StatusPane.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import type { RunStateSnapshot } from "../types"; +import { TestRow } from "./TestRow"; + +interface Props { + state: RunStateSnapshot; + logLines: string[]; +} + +const TICK_INTERVAL_MS = 500; + +export function StatusPane({ state, logLines }: Props) { + const [showLog, setShowLog] = useState(true); + const [now, setNow] = useState(() => Date.now()); + + const hasRunning = state.kind === "running" && state.tests.some((t) => t.status === "running"); + useEffect(() => { + if (!hasRunning) return; + const id = window.setInterval(() => setNow(Date.now()), TICK_INTERVAL_MS); + return () => window.clearInterval(id); + }, [hasRunning]); + + return ( +
+
+

Status

+
+
+ + {state.kind === "idle" && ( +

Pick a source and click Run to start.

+ )} + + {state.kind !== "idle" && ( +
+ {state.tests.map((t) => )} +
+ )} + + {state.kind === "done" && state.outputDir && ( +
+
Cleaned {state.fileCount ?? 0} PDF(s)
+
{state.outputDir}
+
+ )} + + + {showLog && ( +
+          {logLines.join("")}
+        
+ )} +
+ ); +} + +function Header({ state }: { state: RunStateSnapshot }) { + if (state.kind === "idle") return Idle; + if (state.kind === "running") return Running {state.tests.filter((t) => t.status !== "running").length} / {state.tests.length}; + // Watermark-out run: the cleaned-files summary below already communicates the outcome + if (state.outputDir) return null; + return {state.passed} passed{state.failed > 0 && <>, {state.failed} failed}; +} diff --git a/frontend/src/components/TestRow.tsx b/frontend/src/components/TestRow.tsx new file mode 100644 index 0000000..2d7dfae --- /dev/null +++ b/frontend/src/components/TestRow.tsx @@ -0,0 +1,22 @@ +import type { TestRow as Row } from "../types"; + +interface Props { + row: Row; + now: number; +} + +export function TestRow({ row, now }: Props) { + const icon = row.status === "running" ? "⟳" : row.status === "pass" ? "✓" : "✕"; + const tone = row.status === "pass" ? "text-emerald-700" : row.status === "fail" ? "text-rose-700" : "text-gray-600"; + return ( +
+ {icon}{row.name} + + {row.status === "running" + ? row.startedAtMs ? `${Math.floor((now - row.startedAtMs) / 1000)}s` : "running" + : row.durationMs != null ? `${row.durationMs}ms` : ""} + {row.dashboardUrl && View ↗} + +
+ ); +} diff --git a/frontend/src/components/controls/ImageCutControl.tsx b/frontend/src/components/controls/ImageCutControl.tsx new file mode 100644 index 0000000..3e7f0cd --- /dev/null +++ b/frontend/src/components/controls/ImageCutControl.tsx @@ -0,0 +1,25 @@ +import type { OptionSpec } from "../../lib/optionsSchema"; + +interface Props { spec: OptionSpec; value: string; onChange: (v: string) => void; } + +export function ImageCutControl({ spec, value, onChange }: Props) { + const parts = value.split(","); + const fields = ["header", "footer", "left", "right"] as const; + const get = (i: number) => parts[i] ?? ""; + const set = (i: number, v: string) => { + const next = [get(0), get(1), get(2), get(3)]; + next[i] = v; + onChange(next.join(",")); + }; + return ( +
+ {spec.label} +
+ {fields.map((f, i) => ( + set(i, e.target.value)} className="w-16 rounded-md border border-gray-200 px-2 py-1 text-sm" /> + ))} +
+
+ ); +} diff --git a/frontend/src/components/controls/PropertiesControl.tsx b/frontend/src/components/controls/PropertiesControl.tsx new file mode 100644 index 0000000..049cf8f --- /dev/null +++ b/frontend/src/components/controls/PropertiesControl.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import type { OptionSpec } from "../../lib/optionsSchema"; + +interface Props { spec: OptionSpec; value: string; onChange: (v: string) => void; } +type Pair = { k: string; v: string }; + +const parse = (s: string): Pair[] => + s ? s.split("|").map((p) => { const [k = "", v = ""] = p.split(":"); return { k, v }; }) : []; + +const serialize = (rows: Pair[]) => + rows.filter((r) => r.k && r.v).map((r) => `${r.k}:${r.v}`).join("|"); + +export function PropertiesControl({ spec, value, onChange }: Props) { + const [rows, setRows] = useState(parse(value)); + const update = (next: Pair[]) => { setRows(next); onChange(serialize(next)); }; + return ( +
+ {spec.label} + {rows.map((r, i) => ( +
+ update(rows.map((x, idx) => idx === i ? { ...x, k: e.target.value } : x))} + className="flex-1 rounded-md border border-gray-200 px-2 py-1 text-sm" /> + update(rows.map((x, idx) => idx === i ? { ...x, v: e.target.value } : x))} + className="flex-1 rounded-md border border-gray-200 px-2 py-1 text-sm" /> + +
+ ))} + +
+ ); +} diff --git a/frontend/src/components/controls/ProxyControl.tsx b/frontend/src/components/controls/ProxyControl.tsx new file mode 100644 index 0000000..1c577c7 --- /dev/null +++ b/frontend/src/components/controls/ProxyControl.tsx @@ -0,0 +1,17 @@ +import type { OptionSpec } from "../../lib/optionsSchema"; + +interface Props { spec: OptionSpec; value: string; onChange: (v: string) => void; } + +export function ProxyControl({ spec, value, onChange }: Props) { + const [url = "", user = "", pass = ""] = value.split(","); + const emit = (u: string, n: string, p: string) => + onChange([u, n, p].slice(0, p ? 3 : n ? 2 : u ? 1 : 0).join(",")); + return ( +
+ {spec.label} + emit(e.target.value, user, pass)} className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm" /> + emit(url, e.target.value, pass)} className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm" /> + emit(url, user, e.target.value)} className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm" /> +
+ ); +} diff --git a/frontend/src/components/controls/RegionBuilder.tsx b/frontend/src/components/controls/RegionBuilder.tsx new file mode 100644 index 0000000..acfa6eb --- /dev/null +++ b/frontend/src/components/controls/RegionBuilder.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import type { OptionSpec } from "../../lib/optionsSchema"; + +interface Props { spec: OptionSpec; value: string; onChange: (v: string) => void; } +type Row = { x: string; y: string; w: string; h: string }; + +function parse(value: string): Row[] { + if (!value) return []; + return value.split("|").map((seg) => { + const [x = "", y = "", w = "", h = ""] = seg.split(","); + return { x, y, w, h }; + }); +} + +function serialize(rows: Row[]): string { + return rows.filter((r) => r.x || r.y || r.w || r.h) + .map((r) => `${r.x},${r.y},${r.w},${r.h}`).join("|"); +} + +export function RegionBuilder({ spec, value, onChange }: Props) { + const [rows, setRows] = useState(parse(value)); + const update = (next: Row[]) => { setRows(next); onChange(serialize(next)); }; + const setField = (i: number, k: keyof Row, v: string) => + update(rows.map((r, idx) => idx === i ? { ...r, [k]: v } : r)); + return ( +
+ {spec.label} + {rows.map((r, i) => ( +
+ {(["x", "y", "w", "h"] as const).map((k) => ( + setField(i, k, e.target.value)} + className="w-16 rounded-md border border-gray-200 px-2 py-1 text-sm" placeholder={k} /> + ))} + +
+ ))} + +
+ ); +} diff --git a/frontend/src/components/controls/ScalarControl.tsx b/frontend/src/components/controls/ScalarControl.tsx new file mode 100644 index 0000000..4a230d5 --- /dev/null +++ b/frontend/src/components/controls/ScalarControl.tsx @@ -0,0 +1,38 @@ +import type { OptionSpec } from "../../lib/optionsSchema"; + +interface Props { + spec: OptionSpec; + value: unknown; + onChange: (v: unknown) => void; +} + +export function ScalarControl({ spec, value, onChange }: Props) { + const id = `opt-${spec.flag}`; + if (spec.type === "checkbox") { + return ( + + ); + } + if (spec.type === "select") { + return ( + + ); + } + const inputType = spec.type === "password" ? "password" : spec.type === "number" ? "number" : "text"; + return ( + + ); +} diff --git a/frontend/src/components/controls/WatermarkOutControl.tsx b/frontend/src/components/controls/WatermarkOutControl.tsx new file mode 100644 index 0000000..9bc5a49 --- /dev/null +++ b/frontend/src/components/controls/WatermarkOutControl.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import type { OptionSpec } from "../../lib/optionsSchema"; + +interface Props { + spec: OptionSpec; + value: string; + onChange: (v: string) => void; +} + +// The checkbox is a local UI toggle; the backing option value is the output directory. +// An empty directory means "off", so a non-empty value seeds the checkbox as checked. +export function WatermarkOutControl({ spec, value, onChange }: Props) { + const [enabled, setEnabled] = useState(value !== ""); + const id = `opt-${spec.flag}`; + + const toggle = (checked: boolean) => { + setEnabled(checked); + if (!checked) onChange(""); + }; + + return ( +
+ + {enabled && ( + onChange(e.target.value)} + className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:border-brand-teal focus:outline-none" + /> + )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..45d84c0 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,29 @@ +import { getToken } from "./token"; + +async function http(method: string, path: string, body?: unknown): Promise { + const res = await fetch(path, { + method, + headers: { + "Authorization": `Bearer ${getToken()}`, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`${res.status}: ${detail}`); + } + if (res.status === 204) return undefined as unknown as T; + return res.json(); +} + +export const api = { + status: () => http("GET", "/api/status"), + hasApiKey: () => http<{ hasKey: boolean }>("GET", "/api/secret/api-key"), + setApiKey: (value: string) => http("PUT", "/api/secret/api-key", { value }), + deleteApiKey: () => http("DELETE", "/api/secret/api-key"), + choosePath: (type: "file" | "folder", start?: string) => http<{ path?: string }>("POST", "/api/choose-path", { type, start }), + run: (payload: { sourcePath: string; options: Record }) => + http<{ runId: string }>("POST", "/api/run", payload), + cancel: () => http("POST", "/api/cancel"), +}; diff --git a/frontend/src/lib/options.ts b/frontend/src/lib/options.ts new file mode 100644 index 0000000..1536932 --- /dev/null +++ b/frontend/src/lib/options.ts @@ -0,0 +1,58 @@ +import { OPTION_SPECS, type OptionSpec } from "./optionsSchema"; + +export type RunOptions = Record; + +export interface ActiveOption { + spec: OptionSpec; + value: unknown; +} + +const STORAGE_KEY = "imagetester.options"; + +export function defaultOptions(): RunOptions { + const o: RunOptions = { ml: "Strict" }; + for (const spec of OPTION_SPECS) o[spec.flag] = spec.default; + return o; +} + +function isDefault(flag: string, value: unknown): boolean { + if (value === "" || value === false || value == null) return true; + const spec = OPTION_SPECS.find((s) => s.flag === flag); + return spec ? value === spec.default : false; +} + +export function countNonDefault(o: RunOptions): number { + return OPTION_SPECS.filter((s) => !isDefault(s.flag, o[s.flag])).length; +} + +export function countNonDefaultForTab(o: RunOptions, tab: string): number { + return OPTION_SPECS.filter((s) => s.tab === tab && !isDefault(s.flag, o[s.flag])).length; +} + +export function listNonDefault(o: RunOptions): ActiveOption[] { + return OPTION_SPECS + .filter((s) => !isDefault(s.flag, o[s.flag])) + .map((s) => ({ spec: s, value: o[s.flag] })); +} + +export function toRunPayload(sourcePath: string, o: RunOptions) { + const options: Record = { ml: o.ml ?? "Strict" }; + for (const spec of OPTION_SPECS) { + const v = o[spec.flag]; + if (!isDefault(spec.flag, v)) options[spec.flag] = v; + } + return { sourcePath, options }; +} + +export function loadOptions(): RunOptions { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? { ...defaultOptions(), ...JSON.parse(raw) } : defaultOptions(); + } catch { + return defaultOptions(); + } +} + +export function saveOptions(o: RunOptions): void { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(o)); } catch { /* ignore */ } +} diff --git a/frontend/src/lib/optionsSchema.ts b/frontend/src/lib/optionsSchema.ts new file mode 100644 index 0000000..bb55547 --- /dev/null +++ b/frontend/src/lib/optionsSchema.ts @@ -0,0 +1,134 @@ +export type ControlType = + | "text" + | "password" + | "number" + | "checkbox" + | "select" + | "regions" + | "proxy" + | "properties" + | "imagecut" + | "watermarkout"; + +export type TabId = + | "metadata" + | "execution" + | "matching" + | "batch" + | "regions" + | "pdf" + | "connection" + | "watermark" + | "downloads"; + +export interface OptionSpec { + flag: string; + label: string; + type: ControlType; + tab: TabId; + help?: string; + options?: string[]; + default: unknown; +} + +const README_BASE = "https://github.com/applitools/ImageTester"; + +// GitHub auto-generates these anchors from the README's section headings. +// Each tab maps to the categorized section that documents its options. +const TAB_ANCHORS: Record = { + metadata: "#metadata-options", + execution: "#execution-options", + matching: "#matching-options", + batch: "#batch-and-branch-options", + regions: "#region-options", + connection: "#connection-options", + pdf: "#pdf-and-document-options", + watermark: "#watermark-removal", + downloads: "#download-options", +}; + +/** README section that explains a given option, used to deep-link each help tip. */ +export function docUrl(spec: OptionSpec): string { + if (spec.flag === "nf") return README_BASE + "#font-normalization"; + return README_BASE + TAB_ANCHORS[spec.tab]; +} + +export const TABS: { id: TabId; label: string; description: string }[] = [ + { id: "metadata", label: "Metadata", description: "Labels and identifiers attached to your tests — shown on the Applitools dashboard." }, + { id: "execution", label: "Execution", description: "How the run executes and what it writes to the log." }, + { id: "matching", label: "Matching", description: "How images are compared and which differences count as failures." }, + { id: "batch", label: "Batch & Branch", description: "Organize results into batches and branches on the dashboard." }, + { id: "regions", label: "Regions", description: "Mark areas to ignore, treat as content, or check for layout/accessibility only. Coordinates are x, y, width, height." }, + { id: "pdf", label: "PDF & Documents", description: "Options that apply when testing PDFs and other documents." }, + { id: "connection", label: "Connection", description: "Server, proxy, and SSL settings." }, + { id: "watermark", label: "Watermark", description: "Strip draft/proof watermarks from PDFs before comparing them." }, + { id: "downloads", label: "Downloads", description: "Enterprise: pull diff images, screenshots, and GIFs after the run. Requires a view-key." }, +]; + +export const OPTION_SPECS: OptionSpec[] = [ + // Metadata + { flag: "a", label: "App name", type: "text", tab: "metadata", help: "Application name shown on the dashboard. Default: ImageTester.", default: "" }, + { flag: "os", label: "Host OS", type: "text", tab: "metadata", help: "Operating-system label recorded for the test (metadata only).", default: "" }, + { flag: "ap", label: "Host app", type: "text", tab: "metadata", help: "Browser or hosting-application label recorded for the test (metadata only).", default: "" }, + { flag: "en", label: "Environment", type: "text", tab: "metadata", help: "Environment name identifier recorded for the test.", default: "" }, + { flag: "dn", label: "Device name", type: "text", tab: "metadata", help: "Device name shown in the dashboard (metadata only).", default: "" }, + { flag: "vs", label: "Viewport size", type: "text", tab: "metadata", help: "Viewport size identifier as WidthxHeight, e.g. 1000x600.", default: "" }, + { flag: "pr", label: "Properties", type: "properties", tab: "metadata", help: "Custom key/value properties attached to each test (searchable on the dashboard).", default: "" }, + // Execution + { flag: "th", label: "Threads", type: "number", tab: "execution", help: "Maximum concurrent worker threads. Default: 3.", default: "" }, + { flag: "rt", label: "Render threads", type: "number", tab: "execution", help: "Parallel page-render threads for multi-page PDFs. Default: min(4, CPU cores - 1); set 1 to disable.", default: "" }, + { flag: "rf", label: "File name filter", type: "text", tab: "execution", help: "Only test files whose name matches this regular expression, e.g. Lorem.* tests Lorem1.pdf and Lorem2.pdf.", default: "" }, + { flag: "debug", label: "Debug prints", type: "checkbox", tab: "execution", help: "Print verbose debug output to the log.", default: false }, + { flag: "log", label: "Verbose log", type: "checkbox", tab: "execution", help: "Enable detailed Applitools SDK logging.", default: false }, + { flag: "lf", label: "Log file", type: "text", tab: "execution", help: "Deprecated — set the log path with the APPLITOOLS_LOG_DIR environment variable instead.", default: "" }, + // Matching + { flag: "ms", label: "Match size", type: "text", tab: "matching", help: "Resize images and PDF pages to a target size before comparing. e.g. 1000x (by width), x600 (by height), 1000x600 (exact — may distort).", default: "" }, + { flag: "mt", label: "Match timeout (ms)", type: "number", tab: "matching", help: "Match/retry timeout in milliseconds. Minimum 500. Default: 500.", default: "" }, + { flag: "id", label: "Ignore displacement", type: "checkbox", tab: "matching", help: "Ignore position shifts of elements that only moved.", default: false }, + { flag: "as", label: "Auto-save failed", type: "checkbox", tab: "matching", help: "Automatically accept new baselines on failure. Use with care — saves without human review.", default: false }, + { flag: "pt", label: "Prompt new tests", type: "checkbox", tab: "matching", help: "Don't auto-save new tests; review and save them manually on the dashboard.", default: false }, + { flag: "ic", label: "Image cut", type: "imagecut", tab: "matching", help: "Trim pixels from each side before comparing. Order: header, footer, left, right (leave any blank to skip).", default: "" }, + { flag: "rc", label: "Region capture", type: "regions", tab: "matching", help: "Test only a sub-region of each image/PDF instead of the whole page. One region: x, y, width, height.", default: "" }, + { flag: "ac", label: "Accessibility", type: "text", tab: "matching", help: "Run accessibility validation. Format Level:Guideline, e.g. AA:WCAG_2_0 (AA|AAA, WCAG_2_0|WCAG_2_1).", default: "" }, + // Batch & Branch + { flag: "br", label: "Branch", type: "text", tab: "batch", help: "Branch name for this run (for branch-based baselines).", default: "" }, + { flag: "pb", label: "Parent branch", type: "text", tab: "batch", help: "Parent branch name, used when working with branches.", default: "" }, + { flag: "bn", label: "Baseline", type: "text", tab: "batch", help: "Custom baseline name to compare against.", default: "" }, + { flag: "bb", label: "Baseline branch", type: "text", tab: "batch", help: "Baseline branch name.", default: "" }, + { flag: "fb", label: "Flat batch name", type: "text", tab: "batch", help: "Put all discovered tests into one batch with this name. Append <>BATCH_ID to also set the batch id.", default: "" }, + { flag: "sq", label: "Sequence name", type: "text", tab: "batch", help: "Batch sequence name for grouped insights on the Applitools dashboard.", default: "" }, + { flag: "fn", label: "Forced name", type: "text", tab: "batch", help: "Force every test to this name — makes all files match a single baseline.", default: "" }, + { flag: "nc", label: "Notify on complete", type: "checkbox", tab: "batch", help: "Send a batch notification when the run finishes.", default: false }, + { flag: "dcb", label: "Don't close batch", type: "checkbox", tab: "batch", help: "Leave the batch open after the run instead of auto-closing it.", default: false }, + // Regions + { flag: "ir", label: "Ignore regions", type: "regions", tab: "regions", help: "Areas excluded from comparison, applied to all pages.", default: "" }, + { flag: "cr", label: "Content regions", type: "regions", tab: "regions", help: "Areas compared as content (ignore styling), applied to all pages.", default: "" }, + { flag: "lr", label: "Layout regions", type: "regions", tab: "regions", help: "Areas compared by layout only (ignore text and content), applied to all pages.", default: "" }, + { flag: "ari", label: "Accessibility: ignore", type: "regions", tab: "regions", help: "Accessibility ignore regions. Leave empty for the full page.", default: "" }, + { flag: "arr", label: "Accessibility: regular text", type: "regions", tab: "regions", help: "Accessibility regular-text regions. Empty = full viewport.", default: "" }, + { flag: "arl", label: "Accessibility: large text", type: "regions", tab: "regions", help: "Accessibility large-text regions. Empty = full viewport.", default: "" }, + { flag: "arb", label: "Accessibility: bold text", type: "regions", tab: "regions", help: "Accessibility bold-text regions. Empty = full viewport.", default: "" }, + { flag: "arg", label: "Accessibility: graphic", type: "regions", tab: "regions", help: "Accessibility graphics regions. Empty = full viewport.", default: "" }, + // PDF & Documents + { flag: "di", label: "DPI", type: "number", tab: "pdf", help: "Rendering quality (dots per inch) for PDF pages. Higher is sharper but slower. Default: 250.", default: "" }, + { flag: "sp", label: "Selected pages", type: "text", tab: "pdf", help: "Which PDF pages to test, e.g. 1,2,5,7,10-15. Default: all pages.", default: "" }, + { flag: "tp", label: "Trim print margins", type: "checkbox", tab: "pdf", help: "Remove printer margins (crop marks, slug area) from PDF pages before comparing — detects the trim area from TrimBox metadata or crop marks. For a fixed-size crop, use the CLI: -tp WxH in PDF points.", default: false }, + { flag: "pn", label: "Page numbers", type: "checkbox", tab: "pdf", help: "Preserve the original test names when testing only selected pages.", default: false }, + { flag: "pp", label: "PDF password", type: "password", tab: "pdf", help: "Password for opening protected PDF files.", default: "" }, + { flag: "st", label: "Split steps", type: "checkbox", tab: "pdf", help: "Split a multi-page document into individual single-step tests.", default: false }, + { flag: "nf", label: "Normalize fonts", type: "checkbox", tab: "pdf", help: "Rewrite all PDF fonts to Helvetica 12pt before rendering — ignores font/typography changes. Invalidates existing baselines.", default: false }, + { flag: "lo", label: "Legacy file order", type: "checkbox", tab: "pdf", help: "Use pre-2.0 file ordering to stay compatible with older baselines.", default: false }, + // Connection + { flag: "s", label: "Server URL", type: "text", tab: "connection", help: "Applitools server URL. Also settable via APPLITOOLS_SERVER_URL.", default: "" }, + { flag: "p", label: "Proxy", type: "proxy", tab: "connection", help: "Proxy server, with optional username and password.", default: "" }, + { flag: "dv", label: "Disable SSL verify", type: "checkbox", tab: "connection", help: "Disable SSL certificate validation. Insecure — only use if your network requires it.", default: false }, + // Watermark + { flag: "rwauto", label: "Auto-detect watermark", type: "checkbox", tab: "watermark", help: "Auto-detect and strip a shared watermark across PDFs in each folder by its fill color. Needs at least 2 same-source PDFs per folder.", default: false }, + { flag: "rwo", label: "Don't upload — only produce cleaned PDFs locally", type: "watermarkout", tab: "watermark", help: "Writes watermark-removed PDFs to a folder on your machine and skips uploading to Applitools — use it to preview the result. Requires Auto-detect.", default: "" }, + // Downloads + { flag: "vk", label: "View key", type: "password", tab: "downloads", help: "Applitools enterprise view-key. Required to download diffs, images, or GIFs.", default: "" }, + { flag: "gd", label: "Download diffs", type: "checkbox", tab: "downloads", help: "Download diff images of the failed steps.", default: false }, + { flag: "gi", label: "Download images",type: "checkbox", tab: "downloads", help: "Download the baseline and actual images of the failed steps.", default: false }, + { flag: "gg", label: "Download GIFs", type: "checkbox", tab: "downloads", help: "Download animated GIFs of the failed steps.", default: false }, + { flag: "of", label: "Output folder", type: "text", tab: "downloads", help: "Custom output path (or path template) for the downloaded results.", default: "" }, +]; diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts new file mode 100644 index 0000000..77b9a0a --- /dev/null +++ b/frontend/src/lib/sse.ts @@ -0,0 +1,36 @@ +import { getToken } from "./token"; +import type { SseEvent } from "../types"; + +const RECONNECT_DELAY_MS = 2000; + +export interface SseHandle { + close: () => void; +} + +export function connectSse(onEvent: (e: SseEvent) => void, onConnect?: () => void): SseHandle { + let es: EventSource | null = null; + let closed = false; + + const open = () => { + es = new EventSource(`/api/events?token=${encodeURIComponent(getToken())}`); + es.onopen = () => onConnect?.(); + es.onmessage = (m) => { + try { onEvent(JSON.parse(m.data)); } catch { /* ignore malformed */ } + }; + es.onerror = () => { + // EventSource retries transient drops itself; only a CLOSED stream is fatal + // (e.g. the server restarted), so recreate it after a short delay. + if (!closed && es?.readyState === EventSource.CLOSED) { + setTimeout(() => { if (!closed) open(); }, RECONNECT_DELAY_MS); + } + }; + }; + + open(); + return { + close: () => { + closed = true; + es?.close(); + }, + }; +} diff --git a/frontend/src/lib/token.ts b/frontend/src/lib/token.ts new file mode 100644 index 0000000..006c4b3 --- /dev/null +++ b/frontend/src/lib/token.ts @@ -0,0 +1,4 @@ +export function getToken(): string { + const meta = document.querySelector('meta[name="gui-token"]') as HTMLMetaElement | null; + return meta?.content ?? ""; +} diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts new file mode 100644 index 0000000..a229f17 --- /dev/null +++ b/frontend/src/lib/version.ts @@ -0,0 +1,7 @@ +export function getVersion(): string { + const meta = document.querySelector('meta[name="gui-version"]') as HTMLMetaElement | null; + const v = meta?.content ?? ""; + // The server replaces __GUI_VERSION__ at request time; if the literal sentinel leaks through + // (dev server, stale bundle), show nothing rather than the placeholder string. + return v.startsWith("__") ? "" : v; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..9853f0c --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./styles.css"; +import { App } from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..79b9e38 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,22 @@ +export type MatchLevel = "Strict" | "Layout" | "Content" | "Exact"; + +export interface TestRow { + name: string; + status: "running" | "pass" | "fail"; + durationMs?: number; + dashboardUrl?: string; + startedAtMs?: number; +} + +export type RunStateSnapshot = + | { kind: "idle" } + | { kind: "running"; runId: string; tests: TestRow[] } + | { kind: "done"; runId: string; tests: TestRow[]; passed: number; failed: number; durationMs: number; outputDir?: string; fileCount?: number }; + +export type SseEvent = + | { type: "run-started"; runId: string } + | { type: "test-started"; name: string } + | { type: "test-finished"; name: string; status: "pass" | "fail"; durationMs: number; dashboardUrl?: string } + | { type: "log-line"; text: string } + | { type: "run-finished"; passed: number; failed: number; durationMs: number } + | { type: "watermark-cleaned"; outputDir: string; fileCount: number; durationMs: number }; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..03a7893 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + brand: { teal: "#00CDAC", tealDark: "#00A98E", navy: "#0E1133" }, + }, + }, + }, + plugins: [], +}; diff --git a/frontend/test/App.error.test.tsx b/frontend/test/App.error.test.tsx new file mode 100644 index 0000000..4362bb1 --- /dev/null +++ b/frontend/test/App.error.test.tsx @@ -0,0 +1,42 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { App } from "../src/App"; + +describe("App run error", () => { + let origFetch: typeof globalThis.fetch; + + beforeEach(() => { + origFetch = globalThis.fetch; + // Seed a source path so the Run button can be enabled + window.localStorage.setItem("imagetester.lastSourcePath", "/test/source"); + }); + + afterEach(() => { + globalThis.fetch = origFetch; + window.localStorage.removeItem("imagetester.lastSourcePath"); + }); + + it("shows error banner when api run returns 400", async () => { + globalThis.fetch = async (url: RequestInfo | URL, init?: RequestInit) => { + const path = url.toString(); + if (path.endsWith("/api/secret/api-key") && (!init?.method || init.method === "GET")) { + return new Response(JSON.stringify({ hasKey: true }), { status: 200 }); + } + if (path.endsWith("/api/run") && init?.method === "POST") { + return new Response("Invalid source path", { status: 400 }); + } + return new Response(JSON.stringify({ kind: "idle" }), { status: 200 }); + }; + + render(); + + // Wait for hasKey to resolve so the Run button becomes enabled + const runBtn = await screen.findByRole("button", { name: /run test/i }); + await waitFor(() => expect(runBtn).not.toBeDisabled(), { timeout: 3000 }); + + fireEvent.click(runBtn); + + // Assert the error banner is shown + const alert = await screen.findByRole("alert"); + expect(alert).toBeInTheDocument(); + }); +}); diff --git a/frontend/test/App.runstarted.test.tsx b/frontend/test/App.runstarted.test.tsx new file mode 100644 index 0000000..f5d8ba5 --- /dev/null +++ b/frontend/test/App.runstarted.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, act } from "@testing-library/react"; +import { App } from "../src/App"; + +class RecordingEventSource { + static instances: RecordingEventSource[] = []; + onopen: ((e: Event) => void) | null = null; + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: ((e: Event) => void) | null = null; + readyState = 1; + constructor(public url: string) { + RecordingEventSource.instances.push(this); + } + close() {} +} + +describe("App run-started handling", () => { + let origES: typeof globalThis.EventSource; + + beforeEach(() => { + origES = globalThis.EventSource; + RecordingEventSource.instances = []; + // @ts-expect-error — test stub + globalThis.EventSource = RecordingEventSource; + }); + + afterEach(() => { + globalThis.EventSource = origES; + }); + + it("enters running state when a run-started event arrives while idle", async () => { + render(); + const es = RecordingEventSource.instances[0]; + expect(es).toBeDefined(); + + act(() => { + es.onmessage?.({ data: JSON.stringify({ type: "run-started", runId: "r-123" }) } as MessageEvent); + }); + + expect(await screen.findByText(/running/i)).toBeInTheDocument(); + }); + + it("shows the test row when test-started follows run-started", async () => { + render(); + const es = RecordingEventSource.instances[0]; + + act(() => { + es.onmessage?.({ data: JSON.stringify({ type: "run-started", runId: "r-123" }) } as MessageEvent); + es.onmessage?.({ data: JSON.stringify({ type: "test-started", name: "lorem_20.pdf" }) } as MessageEvent); + }); + + expect(await screen.findByText(/lorem_20\.pdf/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/test/OptionsDrawer.test.tsx b/frontend/test/OptionsDrawer.test.tsx new file mode 100644 index 0000000..d17321e --- /dev/null +++ b/frontend/test/OptionsDrawer.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { OptionsDrawer } from "../src/components/OptionsDrawer"; +import { defaultOptions } from "../src/lib/options"; + +describe("OptionsDrawer", () => { + it("shows metadata tab controls by default", () => { + render( {}} onClose={() => {}} />); + expect(screen.getByLabelText("App name")).toBeInTheDocument(); + }); + + it("switches to PDF tab on click", () => { + render( {}} onClose={() => {}} />); + fireEvent.click(screen.getByText("PDF & Documents")); + expect(screen.getByLabelText("DPI")).toBeInTheDocument(); + }); + + it("shows the active tab's intro description", () => { + render( {}} onClose={() => {}} />); + expect(screen.getByText(/Labels and identifiers attached to your tests/i)).toBeInTheDocument(); + }); + + it("shows help text under a region control that has no built-in help line", () => { + render( {}} onClose={() => {}} />); + fireEvent.click(screen.getByText("Regions")); + expect(screen.getByText(/Areas excluded from comparison/i)).toBeInTheDocument(); + }); + + it("links each help tip to the README", () => { + render( {}} onClose={() => {}} />); + const link = screen.getByLabelText("App name documentation"); + expect(link).toHaveAttribute("href", expect.stringContaining("github.com/applitools/ImageTester")); + }); + + it("no longer shows the manual remove-watermark-text field", () => { + render( {}} onClose={() => {}} />); + fireEvent.click(screen.getByText("Watermark")); + expect(screen.queryByText(/Remove watermark text/i)).not.toBeInTheDocument(); + }); + + it("shows a count badge on tabs that have configured options", () => { + const options = { ...defaultOptions(), sp: "1,3", tp: true }; + render( {}} onClose={() => {}} />); + expect(screen.getByRole("tab", { name: "PDF & Documents — 2 set" })).toBeInTheDocument(); + }); + + it("shows no badge on tabs where everything is default", () => { + render( {}} onClose={() => {}} />); + expect(screen.getByRole("tab", { name: "PDF & Documents" })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: /set$/ })).not.toBeInTheDocument(); + }); + + it("lists every configured option as a chip with its value", () => { + const options = { ...defaultOptions(), sp: "1,3", tp: true, fn: "black-card" }; + render( {}} onClose={() => {}} />); + expect(screen.getByRole("button", { name: "Selected pages: 1,3" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Trim print margins" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Forced name: black-card" })).toBeInTheDocument(); + }); + + it("jumps to the option's tab when its chip is clicked", () => { + const options = { ...defaultOptions(), sp: "1,3" }; + render( {}} onClose={() => {}} />); + fireEvent.click(screen.getByRole("button", { name: "Selected pages: 1,3" })); + expect(screen.getByLabelText("DPI")).toBeInTheDocument(); + }); + + it("shows no active-options row when everything is default", () => { + render( {}} onClose={() => {}} />); + expect(screen.queryByLabelText("Active options")).not.toBeInTheDocument(); + }); + + it("reveals the output-folder input only after checking the local-only box", () => { + render( {}} onClose={() => {}} />); + fireEvent.click(screen.getByText("Watermark")); + expect(screen.queryByLabelText("Output folder for cleaned PDFs")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("checkbox", { name: /only produce cleaned PDFs locally/i })); + expect(screen.getByLabelText("Output folder for cleaned PDFs")).toBeInTheDocument(); + }); +}); diff --git a/frontend/test/RegionBuilder.test.tsx b/frontend/test/RegionBuilder.test.tsx new file mode 100644 index 0000000..afbe770 --- /dev/null +++ b/frontend/test/RegionBuilder.test.tsx @@ -0,0 +1,18 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { RegionBuilder } from "../src/components/controls/RegionBuilder"; + +const spec = { flag: "ir", label: "Ignore regions", type: "regions", tab: "regions", default: "" } as const; + +describe("RegionBuilder", () => { + it("serializes one row to x,y,w,h", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("+ Add region")); + fireEvent.change(screen.getByLabelText("ir-0-x"), { target: { value: "1" } }); + fireEvent.change(screen.getByLabelText("ir-0-y"), { target: { value: "2" } }); + fireEvent.change(screen.getByLabelText("ir-0-w"), { target: { value: "3" } }); + fireEvent.change(screen.getByLabelText("ir-0-h"), { target: { value: "4" } }); + expect(onChange).toHaveBeenLastCalledWith("1,2,3,4"); + }); +}); diff --git a/frontend/test/ScalarControl.test.tsx b/frontend/test/ScalarControl.test.tsx new file mode 100644 index 0000000..4b24cd9 --- /dev/null +++ b/frontend/test/ScalarControl.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ScalarControl } from "../src/components/controls/ScalarControl"; + +describe("ScalarControl", () => { + it("emits new text value on change", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText("App name"), { target: { value: "X" } }); + expect(onChange).toHaveBeenCalledWith("X"); + }); + + it("emits boolean on checkbox toggle", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText("Normalize fonts")); + expect(onChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/frontend/test/SetupCard.test.tsx b/frontend/test/SetupCard.test.tsx new file mode 100644 index 0000000..19f72e7 --- /dev/null +++ b/frontend/test/SetupCard.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { SetupCard } from "../src/components/SetupCard"; +import { App } from "../src/App"; + +describe("SetupCard", () => { + it("disables the Run button when api key is missing", () => { + render( {}} onCancel={() => {}} onSetKey={() => {}} onChoosePath={() => {}} onMatchLevel={() => {}} onToggleDrawer={() => {}} />); + expect(screen.getByRole("button", { name: /run test/i })).toBeDisabled(); + }); + + it("disables the Run button when source path is empty", () => { + render( {}} onCancel={() => {}} onSetKey={() => {}} onChoosePath={() => {}} onMatchLevel={() => {}} onToggleDrawer={() => {}} />); + expect(screen.getByRole("button", { name: /run test/i })).toBeDisabled(); + }); + + it("enables the Run button when both api key and source are set", () => { + render( {}} onCancel={() => {}} onSetKey={() => {}} onChoosePath={() => {}} onMatchLevel={() => {}} onToggleDrawer={() => {}} />); + expect(screen.getByRole("button", { name: /run test/i })).not.toBeDisabled(); + }); + + it("shows Cancel instead of Run while a run is in flight", () => { + render( {}} onCancel={() => {}} onSetKey={() => {}} onChoosePath={() => {}} onMatchLevel={() => {}} onToggleDrawer={() => {}} />); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); +}); + +describe("SetupCard options gear", () => { + it("shows the non-default option count in the gear badge", () => { + render( {}} onChoosePath={() => {}} onMatchLevel={() => {}} + onRun={() => {}} onCancel={() => {}} onToggleDrawer={() => {}} />); + expect(screen.getByText(/3 set/)).toBeInTheDocument(); + }); +}); + +describe("App layout", () => { + it("keeps the status pane mounted when the drawer opens", () => { + render(); + fireEvent.click(screen.getByText("⚙ Options")); + expect(screen.getByText(/Logs|No file/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/test/StatusPane.test.tsx b/frontend/test/StatusPane.test.tsx new file mode 100644 index 0000000..66be81b --- /dev/null +++ b/frontend/test/StatusPane.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { StatusPane } from "../src/components/StatusPane"; +import type { RunStateSnapshot } from "../src/types"; + +describe("StatusPane", () => { + it("shows idle placeholder", () => { + render(); + expect(screen.getByText(/pick a source/i)).toBeInTheDocument(); + }); + + it("renders running tests", () => { + const s: RunStateSnapshot = { kind: "running", runId: "r", tests: [{ name: "a.png", status: "pass", durationMs: 42 }, { name: "b.pdf", status: "running" }] }; + render(); + expect(screen.getByText("a.png")).toBeInTheDocument(); + expect(screen.getByText("b.pdf")).toBeInTheDocument(); + }); + + it("shows aggregate counts when done", () => { + const s: RunStateSnapshot = { kind: "done", runId: "r", tests: [], passed: 3, failed: 1, durationMs: 1234 }; + render(); + expect(screen.getByText(/3 passed/i)).toBeInTheDocument(); + expect(screen.getByText(/1 failed/i)).toBeInTheDocument(); + }); + + it("shows log by default and hides it when toggled", () => { + render(); + expect(screen.getByText("[INFO] hello")).toBeInTheDocument(); + fireEvent.click(screen.getByText(/show log/i)); + expect(screen.queryByText("[INFO] hello")).not.toBeInTheDocument(); + }); + + it("shows cleaned-files summary for a watermark-out run", () => { + render(); + expect(screen.getByText(/Cleaned 4/)).toBeInTheDocument(); + expect(screen.getByText("/out")).toBeInTheDocument(); + expect(screen.queryByText(/0 passed/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/test/options.test.ts b/frontend/test/options.test.ts new file mode 100644 index 0000000..0ceb2cb --- /dev/null +++ b/frontend/test/options.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { defaultOptions, countNonDefault, toRunPayload, loadOptions, saveOptions } from "../src/lib/options"; + +describe("options", () => { + beforeEach(() => localStorage.clear()); + + it("defaults match level to Strict", () => { + expect(defaultOptions().ml).toBe("Strict"); + }); + + it("counts a changed option as non-default", () => { + const o = { ...defaultOptions(), di: "300" }; + expect(countNonDefault(o)).toBe(1); + }); + + it("omits default-valued options from payload", () => { + const payload = toRunPayload("/x", defaultOptions()); + expect(payload.options.di).toBeUndefined(); + }); + + it("round-trips through localStorage", () => { + saveOptions({ ...defaultOptions(), a: "App" }); + expect(loadOptions().a).toBe("App"); + }); +}); diff --git a/frontend/test/optionsSchema.test.ts b/frontend/test/optionsSchema.test.ts new file mode 100644 index 0000000..c796c32 --- /dev/null +++ b/frontend/test/optionsSchema.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { OPTION_SPECS, TABS, docUrl } from "../src/lib/optionsSchema"; + +describe("optionsSchema", () => { + it("orders metadata tab first", () => { + expect(TABS[0].id).toBe("metadata"); + }); + + it("excludes throwExceptions and batchMapper", () => { + const flags = OPTION_SPECS.map((o) => o.flag); + expect(flags).not.toContain("te"); + expect(flags).not.toContain("mp"); + }); + + it("places view key in downloads tab", () => { + const vk = OPTION_SPECS.find((o) => o.flag === "vk"); + expect(vk?.tab).toBe("downloads"); + }); + + it("places render threads in the execution tab as a number", () => { + const rt = OPTION_SPECS.find((o) => o.flag === "rt"); + expect(rt?.tab).toBe("execution"); + expect(rt?.type).toBe("number"); + }); + + it("places regex file filter in the execution tab as text", () => { + const rf = OPTION_SPECS.find((o) => o.flag === "rf"); + expect(rf?.tab).toBe("execution"); + expect(rf?.type).toBe("text"); + }); + + it("offers pdf trim as a checkbox in the pdf tab", () => { + const tp = OPTION_SPECS.find((o) => o.flag === "tp"); + expect(tp?.tab).toBe("pdf"); + expect(tp?.type).toBe("checkbox"); + }); + + it("gives every option a help tip", () => { + const withoutHelp = OPTION_SPECS.filter((o) => !o.help); + expect(withoutHelp).toEqual([]); + }); + + it("links normalize-fonts to the font-normalization README section", () => { + const nf = OPTION_SPECS.find((o) => o.flag === "nf")!; + expect(docUrl(nf)).toContain("#font-normalization"); + }); + + it("links PDF options to the documents README section", () => { + const di = OPTION_SPECS.find((o) => o.flag === "di")!; + expect(docUrl(di)).toContain("#pdf-and-document-options"); + }); + + it("links each tab to a section heading that exists in the README", () => { + // Every anchor docUrl produces must correspond to a real README heading slug. + const readme = readFileSync(resolve(process.cwd(), "../README.md"), "utf8"); + const headingSlugs = new Set( + readme.split("\n") + .filter((l) => /^#{1,6}\s/.test(l)) + .map((l) => l.replace(/^#{1,6}\s+/, "").toLowerCase() + .replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-")), + ); + for (const spec of OPTION_SPECS) { + const anchor = docUrl(spec).split("#")[1]; + expect(headingSlugs.has(anchor)).toBe(true); + } + }); +}); diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts new file mode 100644 index 0000000..efcd466 --- /dev/null +++ b/frontend/test/setup.ts @@ -0,0 +1,16 @@ +import "@testing-library/jest-dom"; + +// EventSource is not available in jsdom — provide a no-op stub so App can render in tests. +class EventSourceStub { + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: ((e: Event) => void) | null = null; + close() {} +} +// @ts-expect-error — assigning a stub for test environment +globalThis.EventSource = EventSourceStub; + +// Stub global fetch so App's on-mount API calls don't throw in jsdom. +if (!globalThis.fetch) { + globalThis.fetch = () => + Promise.resolve(new Response(JSON.stringify({ hasKey: false, kind: "idle" }), { status: 200 })); +} diff --git a/frontend/test/sse.test.ts b/frontend/test/sse.test.ts new file mode 100644 index 0000000..60cb454 --- /dev/null +++ b/frontend/test/sse.test.ts @@ -0,0 +1,72 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { connectSse } from "../src/lib/sse"; + +class FakeEventSource { + static instances: FakeEventSource[] = []; + static CLOSED = 2; + onopen: ((e: Event) => void) | null = null; + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: ((e: Event) => void) | null = null; + readyState = 1; + closed = false; + constructor(public url: string) { + FakeEventSource.instances.push(this); + } + close() { this.closed = true; } +} + +describe("connectSse", () => { + let origES: typeof globalThis.EventSource; + + beforeEach(() => { + vi.useFakeTimers(); + origES = globalThis.EventSource; + FakeEventSource.instances = []; + // @ts-expect-error — test stub + globalThis.EventSource = FakeEventSource; + }); + + afterEach(() => { + vi.useRealTimers(); + globalThis.EventSource = origES; + }); + + it("invokes onConnect when the stream opens", () => { + const onConnect = vi.fn(); + connectSse(() => {}, onConnect); + const es = FakeEventSource.instances[0]; + es.onopen?.(new Event("open")); + expect(onConnect).toHaveBeenCalledTimes(1); + }); + + it("reopens a new stream after a fatal close", () => { + connectSse(() => {}, () => {}); + const first = FakeEventSource.instances[0]; + first.readyState = FakeEventSource.CLOSED; + first.onerror?.(new Event("error")); + vi.runAllTimers(); + expect(FakeEventSource.instances.length).toBe(2); + }); + + it("delivers events from the reopened stream", () => { + const events: unknown[] = []; + connectSse((e) => events.push(e), () => {}); + const first = FakeEventSource.instances[0]; + first.readyState = FakeEventSource.CLOSED; + first.onerror?.(new Event("error")); + vi.runAllTimers(); + const second = FakeEventSource.instances[1]; + second.onmessage?.({ data: JSON.stringify({ type: "run-started", runId: "r1" }) } as MessageEvent); + expect(events).toEqual([{ type: "run-started", runId: "r1" }]); + }); + + it("does not reopen after close() is called", () => { + const handle = connectSse(() => {}, () => {}); + handle.close(); + const first = FakeEventSource.instances[0]; + first.readyState = FakeEventSource.CLOSED; + first.onerror?.(new Event("error")); + vi.runAllTimers(); + expect(FakeEventSource.instances.length).toBe(1); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2504ddf --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vitest/globals", "@testing-library/jest-dom", "node"] + }, + "include": ["src", "test"] +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..efa84ff --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/optionsdrawer.tsx","./src/components/setupcard.tsx","./src/components/statuspane.tsx","./src/components/testrow.tsx","./src/components/controls/imagecutcontrol.tsx","./src/components/controls/propertiescontrol.tsx","./src/components/controls/proxycontrol.tsx","./src/components/controls/regionbuilder.tsx","./src/components/controls/scalarcontrol.tsx","./src/components/controls/watermarkoutcontrol.tsx","./src/lib/api.ts","./src/lib/options.ts","./src/lib/optionsschema.ts","./src/lib/sse.ts","./src/lib/token.ts","./src/lib/version.ts","./test/app.error.test.tsx","./test/app.runstarted.test.tsx","./test/optionsdrawer.test.tsx","./test/regionbuilder.test.tsx","./test/scalarcontrol.test.tsx","./test/setupcard.test.tsx","./test/statuspane.test.tsx","./test/options.test.ts","./test/optionsschema.test.ts","./test/setup.ts","./test/sse.test.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4329d76 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + assetsDir: "assets", + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + "/api": { target: "http://localhost:8765", changeOrigin: false }, + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./test/setup.ts"], + }, +}); diff --git a/gui-launch.png b/gui-launch.png new file mode 100644 index 0000000..0cc11f8 Binary files /dev/null and b/gui-launch.png differ diff --git a/libs/com/applitools/eyesutilities/EyesUtilities/1.6.6/EyesUtilities-1.6.6.jar b/libs/com/applitools/eyesutilities/EyesUtilities/1.6.6/EyesUtilities-1.6.6.jar new file mode 100644 index 0000000..32e69d2 Binary files /dev/null and b/libs/com/applitools/eyesutilities/EyesUtilities/1.6.6/EyesUtilities-1.6.6.jar differ diff --git a/libs/com/applitools/eyesutilities/EyesUtilities/1.6.6/EyesUtilities-1.6.6.pom b/libs/com/applitools/eyesutilities/EyesUtilities/1.6.6/EyesUtilities-1.6.6.pom new file mode 100644 index 0000000..38538c8 --- /dev/null +++ b/libs/com/applitools/eyesutilities/EyesUtilities/1.6.6/EyesUtilities-1.6.6.pom @@ -0,0 +1,76 @@ + + + 4.0.0 + com.applitools.eyesutilities + EyesUtilities + eyes-utilities + 1.6.6 + http://maven.apache.org + + ../jars/${project.artifactId}_${project.version} + + + org.owasp + dependency-check-maven + 7.1.1 + + + + check + + + + + + maven-shade-plugin + 3.4.1 + + + package + + shade + + + + + + + com.applitools.eyesutilities.EyesUtilities + + + + + + maven-jar-plugin + 3.2.2 + + + + true + true + + + + + + + + + junit + junit + 4.13.2 + test + + + hamcrest-core + org.hamcrest + + + + + + 1.8 + 1.8 + UTF-8 + + diff --git a/orig_page_fullsize.png b/orig_page_fullsize.png new file mode 100644 index 0000000..725fc7e Binary files /dev/null and b/orig_page_fullsize.png differ diff --git a/orig_policy_doc.png b/orig_policy_doc.png new file mode 100644 index 0000000..725fc7e Binary files /dev/null and b/orig_policy_doc.png differ diff --git a/pom.xml b/pom.xml index d0b349d..fe50455 100644 --- a/pom.xml +++ b/pom.xml @@ -7,8 +7,8 @@ 3.12.0 jar - 1.8 - 1.8 + 11 + 11 @@ -21,6 +21,15 @@ jitpack.io https://jitpack.io + + + + project-libs + file://${project.basedir}/libs + @@ -148,6 +157,21 @@ jna-platform 5.18.1 + + org.eclipse.jetty + jetty-server + 11.0.20 + + + org.eclipse.jetty + jetty-servlet + 11.0.20 + + + com.github.javakeyring + java-keyring + 1.0.4 + @@ -169,10 +193,21 @@ maven-shade-plugin 3.4.1 + + + *:* + + META-INF/*.SF + META-INF/*.RSA + META-INF/*.DSA + + + com.applitools.imagetester.ImageTester + @@ -335,10 +370,63 @@ maven-compiler-plugin 3.10.1 - 1.8 - 1.8 + 11 + 11 + + + com.github.eirslett + frontend-maven-plugin + 1.15.0 + + frontend + ${project.build.directory} + v20.12.2 + + + + install-node-and-npm + install-node-and-npm + generate-resources + + + npm-ci + npm + generate-resources + ci + + + npm-run-build + npm + generate-resources + run build + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + copy-frontend-bundle + process-resources + copy-resources + + + true + ${project.build.outputDirectory}/web + + + ${project.basedir}/frontend/dist + + + + + + @@ -379,5 +467,148 @@ + + + gui-installers + + + + org.apache.maven.plugins + maven-resources-plugin + + + stage-installer-input + pre-integration-test + copy-resources + + ${project.build.directory}/installer-input + + + ${project.basedir}/jars + + ${installer.input.jar} + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.1 + + + run-jpackage + verify + exec + + jpackage + + --input + ${project.build.directory}/installer-input + --type + ${installer.type} + --name + ImageTester + --main-jar + ${installer.input.jar} + --main-class + com.applitools.imagetester.ImageTester + --arguments + --gui + --app-version + ${project.version} + --description + Visual testing tool — uploads screenshots and PDF pages to Applitools Eyes + --copyright + Copyright © Applitools + --vendor + Applitools + --dest + ${project.build.directory}/installers + + + + + + + + + + + + installer-os-windows + windows + + ImageTester_${project.version}_Windows.jar + + msi + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.1 + + + run-jpackage + + + --win-menu + --win-shortcut + + + + + + + + + + installer-os-mac-arm + macaarch64 + + ImageTester_${project.version}_MacArm.jar + dmg + + + + installer-os-mac-x64 + macx86_64 + + ImageTester_${project.version}_Mac.jar + dmg + + + + installer-os-linux + unixlinux + + ImageTester_${project.version}_Linux.jar + deb + + diff --git a/src/main/java/com/applitools/imagetester/ImageTester.java b/src/main/java/com/applitools/imagetester/ImageTester.java index dc054b4..a0f6957 100644 --- a/src/main/java/com/applitools/imagetester/ImageTester.java +++ b/src/main/java/com/applitools/imagetester/ImageTester.java @@ -25,12 +25,15 @@ import com.applitools.imagetester.lib.Logger; import com.applitools.imagetester.lib.PdfVectorWatermarkAutoMode; import com.applitools.imagetester.lib.PdfWatermarkOutMode; +import com.applitools.imagetester.lib.RunConfig; +import com.applitools.imagetester.lib.RunConfigFactory; import com.applitools.imagetester.lib.TestExecutor; import com.applitools.imagetester.lib.Utils; public class ImageTester { - private static final String cur_ver = "3.12.0"; - private static final String DEFAULT_THREADS = String.valueOf(Runtime.getRuntime().availableProcessors() * 2); + public static final String CUR_VER = "3.12.0"; + public static final int DEFAULT_THREAD_COUNT = Runtime.getRuntime().availableProcessors() * 2; + public static final String DEFAULT_THREADS = String.valueOf(DEFAULT_THREAD_COUNT); public static void main(String[] args) { System.exit(run(args)); @@ -45,10 +48,26 @@ public static int run(String[] args) { // PDFBox generates fairly unhelpful logs - suppressing these by default java.util.logging.Logger.getLogger("org.apache.pdfbox").setLevel(java.util.logging.Level.OFF); + if (java.util.Arrays.asList(args).contains("--gui")) { + if (args.length != 1) { + System.err.println("--gui must be the only argument. Got: " + java.util.Arrays.toString(args)); + return 2; + } + try { + com.applitools.imagetester.gui.GuiServer server = com.applitools.imagetester.gui.GuiServer.start(); + com.applitools.imagetester.gui.GuiLauncher.open("http://localhost:" + server.port()); + server.join(); + return 0; + } catch (Exception e) { + e.printStackTrace(); + return 1; + } + } + try { CommandLine cmd = parser.parse(options, args); logger.setDebug(cmd.hasOption("debug")); - logger.printVersion(cur_ver); + logger.printVersion(CUR_VER); if (cmd.getOptions().length == 0) { logger.printHelp(options); @@ -89,104 +108,17 @@ public static int run(String[] args) { return 0; } - Config config = new Config(); - config.apiKey = cmd.getOptionValue("k", System.getenv(ApplitoolsConstants.APPLITOOLS_API_KEY)); - config.serverUrl = cmd.getOptionValue("s", System.getenv(ApplitoolsConstants.APPLITOOLS_SERVER_URL)); - - String[] proxySettings = cmd.getOptionValues("p"); - - if(proxySettings == null) { - String proxyString = System.getenv(ApplitoolsConstants.APPLITOOLS_PROXY); - proxySettings = proxyString != null ? proxyString.split(",") : null; - } - - config.setProxy(proxySettings); - - String[] accessibilityOptions = cmd.getOptionValues("ac"); - accessibilityOptions = cmd.hasOption("ac") && accessibilityOptions == null ? new String[0] : accessibilityOptions; - - EyesFactory factory - = new EyesFactory(cur_ver, logger) - .apiKey(config.apiKey) - .serverUrl(config.serverUrl) - .proxySettings(config.proxy_settings) - .matchLevel(cmd.getOptionValue("ml", null)) - .branch(cmd.getOptionValue("br", null)) - .parentBranch(cmd.getOptionValue("pb", null)) - .baselineEnvName(cmd.getOptionValue("bn", null)) - .baselineBranchName(cmd.getOptionValue("bb", null)) - .logFile(cmd.getOptionValue("lf", null)) - .hostOs(cmd.getOptionValue("os", null)) - .hostApp(cmd.getOptionValue("ap")) - .environmentName(cmd.getOptionValue("en")) - .saveFailedTests(cmd.hasOption("as")) - .ignoreDisplacement(cmd.hasOption("id")) - .saveNewTests(!cmd.hasOption("pt")) - .imageCut(cmd.getOptionValues("ic")) - .accSettings(accessibilityOptions) - .logHandler(cmd.hasOption("log")) - .deviceName(cmd.getOptionValue("dn", null)); - - config.splitSteps = cmd.hasOption("st"); - config.logger = logger; - config.appName = cmd.getOptionValue("a", "ImageTester"); - config.DocumentConversionDPI = Float.parseFloat(cmd.getOptionValue("di", "250")); - config.renderThreads = Integer.parseInt(cmd.getOptionValue("rt", String.valueOf(config.renderThreads))); - config.pdfPass = cmd.getOptionValue("pp", null); - config.pages = cmd.getOptionValue("sp", null); - config.includePageNumbers = cmd.hasOption("pn"); - config.forcedName = cmd.getOptionValue("fn", null); - config.sequenceName = cmd.getOptionValue("sq", null); - config.legacyFileOrder = cmd.hasOption("lo"); - config.dontCloseBatches = cmd.hasOption("dcb"); - config.shouldThrowException = cmd.hasOption("te"); - config.normalizeFont = cmd.hasOption("nf"); - config.removeWatermarkText = cmd.getOptionValue("rw"); - config.removeWatermarkOutDir = cmd.getOptionValue("rwo"); - config.regexFileNameFilter = cmd.getOptionValue("rf"); - config.setViewport(cmd.getOptionValue("vs", null)); - config.setMatchSize(cmd.getOptionValue("ms", null)); - config.setBatchInfo(cmd.getOptionValue("fb", null), cmd.hasOption("nc")); - config.setIgnoreRegions(cmd.getOptionValue("ir", null)); - config.setContentRegions(cmd.getOptionValue("cr", null)); - config.setLayoutRegions(cmd.getOptionValue("lr", null)); - config.setAccessibilityIgnoreRegions(cmd.getOptionValue("ari", null)); - config.setAccessibilityRegularTextRegions(cmd.getOptionValue("arr", null)); - config.setAccessibilityLargeTextRegions(cmd.getOptionValue("arl", null)); - config.setAccessibilityBoldTextRegions(cmd.getOptionValue("arb", null)); - config.setAccessibilityGraphicsRegions(cmd.getOptionValue("arg", null)); - config.setCaptureRegion(cmd.getOptionValue("rc", null)); - config.setMatchTimeout(cmd.getOptionValue("mt", null)); - config.setProperties(cmd.getOptionValue("pr", null)); - - - // Full page for ac regions capability - if (cmd.hasOption("arr") && config.accessibilityRegularTextRegions == null) { - config.accessibilityRegularTextFullPage = true; - } - if (cmd.hasOption("arl") && config.accessibilityLargeTextRegions == null) { - config.accessibilityLargeTextFullPage = true; - } - if (cmd.hasOption("arb") && config.accessibilityBoldTextRegions == null) { - config.accessibilityBoldTextFullPage = true; - } - if (cmd.hasOption("arg") && config.accessibilityGraphicsRegions== null) { - config.accessibilityGraphicsFullPage = true; - } + RunConfig rc = RunConfigFactory.from(cmd, logger); + Config config = rc.config; + EyesFactory factory = rc.factory; File root = rwAutoCleanedDir != null ? rwAutoCleanedDir : new File(cmd.getOptionValue("f", ".")); - int maxThreads = Integer.parseInt(cmd.getOptionValue("th", DEFAULT_THREADS)); - TestExecutor executor = new TestExecutor(maxThreads, factory, config); - + TestExecutor executor = new TestExecutor(rc.threads, factory, config); Suite suite = Suite.create(root.getCanonicalFile(), config, executor); - - config.eyesUtilsConf = new EyesUtilitiesConfig(cmd); - suite.run(); - config.closeBatches(); return computeExitCode(config, logger); } catch (ParseException | IOException e) { @@ -292,7 +224,7 @@ private static void runTestWithBatchMapper(final Logger logger, final CommandLin accessibilityOptions = cmd.hasOption("ac") && accessibilityOptions == null ? new String[0] : accessibilityOptions; EyesFactory factory - = new EyesFactory(cur_ver, logger) + = new EyesFactory(CUR_VER, logger) .apiKey(currentConfiguration.apiKey) .serverUrl(currentConfiguration.serverUrl) .proxySettings(currentConfiguration.proxy_settings) @@ -388,7 +320,7 @@ private static void runTestWithBatchMapper(final Logger logger, final CommandLin } } - private static Options getOptions() { + public static Options getOptions() { Options options = new Options(); options.addOption(Option.builder("k") .longOpt("apiKey") @@ -508,6 +440,13 @@ private static Options getOptions() { .hasArg() .argName("Pages") .build()); + options.addOption(Option.builder("tp") + .longOpt("pdfTrim") + .desc("Trim print margins from PDF pages before comparing. Bare `-tp` (or `-tp auto`) detects the trim area from TrimBox metadata or crop marks; `x` (PDF points) crops a centered box ie. 603x774") + .hasArg() + .optionalArg(true) + .argName("auto|size") + .build()); options.addOption(Option.builder("sq") .longOpt("sequenceName") .desc("Set the batch sequenceName for applitools' insights") diff --git a/src/main/java/com/applitools/imagetester/Suite.java b/src/main/java/com/applitools/imagetester/Suite.java index f0d3bb9..4dd5d33 100644 --- a/src/main/java/com/applitools/imagetester/Suite.java +++ b/src/main/java/com/applitools/imagetester/Suite.java @@ -62,6 +62,8 @@ private Suite(File file, Config conf, TestExecutor executor) { if (!file.exists()) throw new RuntimeException( String.format("Fatal! The path %s does not exists \n", file.getAbsolutePath())); + if (file.isDirectory()) + conf.logger.printMessage(String.format("Discovering folder: %s%n", file.getName())); try { if (file.isFile()) { if (conf.regexFileNameFilter != null @@ -116,6 +118,7 @@ private static TestBase tryConvert(File file, Config conf) { Optional match = REGISTRY.find(file); if (!match.isPresent()) return null; try { + conf.logger.printMessage(String.format("Converting %s to PDF...%n", file.getName())); Path tempDir = Files.createTempDirectory("imagetester-"); tempDir.toFile().deleteOnExit(); File pdfTemp = match.get().convertToPdf(file, tempDir); diff --git a/src/main/java/com/applitools/imagetester/TestObjects/TestBase.java b/src/main/java/com/applitools/imagetester/TestObjects/TestBase.java index 518ffdf..3ee03c1 100644 --- a/src/main/java/com/applitools/imagetester/TestObjects/TestBase.java +++ b/src/main/java/com/applitools/imagetester/TestObjects/TestBase.java @@ -5,13 +5,10 @@ import com.applitools.eyes.images.Eyes; import com.applitools.imagetester.lib.Config; import com.applitools.imagetester.lib.Logger; +import com.applitools.imagetester.lib.MatchSizeResizer; import com.applitools.imagetester.lib.Utils; -import org.apache.commons.lang3.StringUtils; -import org.imgscalr.Scalr; - import javax.imageio.ImageIO; -import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; @@ -89,28 +86,6 @@ public Logger logger() { } protected BufferedImage getImage(File img) throws IOException { - BufferedImage bim = ImageIO.read(img); - if (StringUtils.isNotBlank(conf_.matchWidth) || StringUtils.isNotBlank(conf_.matchHeight)) { - //Resize the image - Dimension dim = getNewDimensions_(bim.getWidth(), bim.getHeight()); - BufferedImage scaledImg = Scalr.resize(bim, Scalr.Method.ULTRA_QUALITY, dim.width, dim.height); - bim = null; //perhaps a better disposal required - bim = scaledImg; - } - return bim; - } - - private Dimension getNewDimensions_(int oldWidth, int oldHeight) { - if (StringUtils.isNotBlank(conf_.matchWidth) && StringUtils.isNotBlank(conf_.matchHeight)) - return new Dimension(Integer.parseInt(conf_.matchWidth), Integer.parseInt(conf_.matchHeight)); - else if (StringUtils.isNotBlank(conf_.matchWidth)) { - // scale by width - float ratio = Float.parseFloat(conf_.matchWidth) / oldWidth; - return new Dimension(Integer.parseInt(conf_.matchWidth), Math.round(oldHeight * ratio)); - } else if (StringUtils.isNotBlank(conf_.matchHeight)) { - // scale by height - float ratio = Float.parseFloat(conf_.matchHeight) / oldHeight; - return new Dimension(Math.round(oldWidth * ratio), Integer.parseInt(conf_.matchHeight)); - } else throw new RuntimeException("The new dimensions were not provided correctly"); + return MatchSizeResizer.resize(ImageIO.read(img), conf_); } } diff --git a/src/main/java/com/applitools/imagetester/gui/EyesLogBridge.java b/src/main/java/com/applitools/imagetester/gui/EyesLogBridge.java new file mode 100644 index 0000000..d50bab2 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/EyesLogBridge.java @@ -0,0 +1,37 @@ +package com.applitools.imagetester.gui; + +import com.applitools.eyes.LogHandler; +import com.applitools.eyes.logging.ClientEvent; +import com.applitools.eyes.logging.TraceLevel; +import com.applitools.imagetester.lib.Logger; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Routes Applitools Eyes SDK trace events into our Logger so they reach the GUI log pane + * (and the listener fan-out) instead of being silently dropped. + */ +public final class EyesLogBridge extends LogHandler { + + private final Logger logger_; + private final ObjectMapper json_ = new ObjectMapper(); + + public EyesLogBridge(Logger logger) { + super(TraceLevel.Notice); + this.logger_ = logger; + } + + @Override public void open() {} + @Override public void close() {} + @Override public boolean isOpen() { return true; } + + @Override + public void onMessageInner(ClientEvent event) { + try { + String body; + Object payload = event.getEvent(); + // Strings render readably; complex events go through JSON so structure isn't lost. + body = (payload instanceof String) ? (String) payload : json_.writeValueAsString(payload); + logger_.printMessage(String.format("[eyes %s] %s%n", event.getLevel(), body)); + } catch (Throwable ignored) { /* never let the SDK's log path crash a test */ } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/GuiLauncher.java b/src/main/java/com/applitools/imagetester/gui/GuiLauncher.java new file mode 100644 index 0000000..cb0e815 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/GuiLauncher.java @@ -0,0 +1,36 @@ +package com.applitools.imagetester.gui; + +import java.awt.Desktop; +import java.io.IOException; +import java.net.URI; + +public final class GuiLauncher { + + private GuiLauncher() {} + + public static void open(String url) { + if (tryDesktop(url)) return; + tryShell(url); + } + + private static boolean tryDesktop(String url) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI.create(url)); + return true; + } + } catch (Throwable ignored) {} + return false; + } + + private static void tryShell(String url) { + String os = System.getProperty("os.name", "").toLowerCase(); + try { + if (os.contains("win")) new ProcessBuilder("cmd", "/c", "start", "", url).start(); + else if (os.contains("mac")) new ProcessBuilder("open", url).start(); + else new ProcessBuilder("xdg-open", url).start(); + } catch (IOException e) { + System.err.println("Could not open browser. Please open " + url + " manually."); + } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/GuiServer.java b/src/main/java/com/applitools/imagetester/gui/GuiServer.java new file mode 100644 index 0000000..8cc2712 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/GuiServer.java @@ -0,0 +1,206 @@ +package com.applitools.imagetester.gui; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; + +public final class GuiServer { + + private final Server server_; + private final GuiToken token_; + private final int port_; + private final RunController controller_; + + private GuiServer(Server server, GuiToken token, int port, RunController controller) { + this.server_ = server; + this.token_ = token; + this.port_ = port; + this.controller_ = controller; + } + + public int port() { return port_; } + public GuiToken token() { return token_; } + public RunController controller() { return controller_; } + + public static GuiServer start() throws Exception { return start(false); } + public static GuiServer startForTest() throws Exception { return start(true); } + + private static GuiServer start(boolean testMode) throws Exception { + GuiToken token = GuiToken.generate(); + Server server = new Server(); + ServerConnector connector = new ServerConnector(server); + connector.setHost("127.0.0.1"); + connector.setPort(0); // any free port + server.addConnector(connector); + + ServletContextHandler ctx = new ServletContextHandler(); + ctx.setContextPath("/"); + + // The filter needs the port, which isn't known until after server.start(). + // Wrap a deferred filter that delegates to the real one once port is known. + TokenAuthFilter[] filterRef = new TokenAuthFilter[1]; + Filter deferredFilter = new Filter() { + @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) + throws IOException, ServletException { + if (filterRef[0] == null) { chain.doFilter(req, resp); return; } + filterRef[0].doFilter(req, resp, chain); + } + }; + ctx.addFilter(new FilterHolder(deferredFilter), "/*", EnumSet.of(DispatcherType.REQUEST)); + + SecretsStore secrets = testMode ? SecretsStore.inMemoryForTest() : SecretsStore.forProduction(); + if (testMode) secrets.setApiKey("sk_test_test"); + RunStream stream = new RunStream(); + RunController controller = new RunController(secrets, stream); + + ctx.addServlet(new ServletHolder(new IndexHtmlServlet(token)), "/"); + ctx.addServlet(new ServletHolder(new IndexHtmlServlet(token)), "/index.html"); + ctx.addServlet(new ServletHolder(new StaticAssetServlet()), "/assets/*"); + ctx.addServlet(new ServletHolder(new SseServlet(controller)), "/api/events"); + ctx.addServlet(new ServletHolder(new ApiServlet(controller)), "/api/*"); + + server.setHandler(ctx); + server.start(); + int port = connector.getLocalPort(); + filterRef[0] = new TokenAuthFilter(token, port); + + // Skip in test mode — Swing init can be problematic on headless CI. + if (!testMode) NativePathChooser.prewarm(); + + return new GuiServer(server, token, port, controller); + } + + public void stop() throws Exception { server_.stop(); } + public void join() throws InterruptedException { server_.join(); } + + // ---- Inline servlets ---- + + private static final class StaticAssetServlet extends HttpServlet { + @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + String path = req.getPathInfo(); + if (path == null || path.contains("..")) { resp.setStatus(404); return; } + URL url = StaticAssetServlet.class.getResource("/web/assets" + path); + if (url == null) { resp.setStatus(404); return; } + resp.setHeader("Cache-Control", "public,max-age=31536000,immutable"); + if (path.endsWith(".js")) resp.setContentType("application/javascript"); + if (path.endsWith(".css")) resp.setContentType("text/css"); + try (InputStream in = url.openStream()) { in.transferTo(resp.getOutputStream()); } + } + } + + private static final class ApiServlet extends HttpServlet { + private final RunController controller_; + private final ObjectMapper json_ = new ObjectMapper(); + + ApiServlet(RunController c) { this.controller_ = c; } + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { + String path = req.getPathInfo() != null ? req.getPathInfo() : ""; + String method = req.getMethod(); + try { + if (method.equals("GET") && path.equals("/status")) { writeJson(resp, snapshotToMap(controller_.snapshot())); return; } + if (method.equals("POST") && path.equals("/run")) { handleRun(req, resp); return; } + if (method.equals("POST") && path.equals("/cancel")) { controller_.cancel(); resp.setStatus(204); return; } + if (method.equals("GET") && path.equals("/secret/api-key")) { writeJson(resp, Map.of("hasKey", controller_.secrets().hasApiKey())); return; } + if (method.equals("PUT") && path.equals("/secret/api-key")) { handleSetSecret(req, resp); return; } + if (method.equals("DELETE") && path.equals("/secret/api-key")) { controller_.secrets().deleteApiKey(); resp.setStatus(204); return; } + if (method.equals("POST") && path.equals("/choose-path")) { handleChoosePath(req, resp); return; } + resp.setStatus(404); + } catch (RunController.RunInProgressException e) { + resp.setStatus(409); writeJson(resp, Map.of("error", e.getMessage())); + } catch (RunController.MissingApiKeyException | SourcePathValidator.InvalidSourceException e) { + resp.setStatus(400); writeJson(resp, Map.of("error", e.getMessage())); + } catch (RuntimeException e) { + // Synchronous option/parse errors from start(): NumberFormatException (-di/-th), + // bare RuntimeException (Config.setViewport/setCaptureRegion/setProxy), + // IllegalArgumentException (Config.setProperties), InvalidOptionsException (-rwo guard). + resp.setStatus(400); writeJson(resp, Map.of("error", e.getMessage() != null ? e.getMessage() : "Invalid options")); + } catch (Throwable t) { + resp.setStatus(500); writeJson(resp, Map.of("error", t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName())); + } + } + + private void handleRun(HttpServletRequest req, HttpServletResponse resp) throws IOException { + RunRequest runReq = json_.readValue(req.getInputStream(), RunRequest.class); + RunController.StartResult r = controller_.start(runReq); + writeJson(resp, Map.of("runId", r.runId)); + } + + private void handleSetSecret(HttpServletRequest req, HttpServletResponse resp) throws IOException { + Map body = json_.readValue(req.getInputStream(), Map.class); + controller_.setSecretApiKey(body.get("value") == null ? null : body.get("value").toString()); + resp.setStatus(204); + } + + private void handleChoosePath(HttpServletRequest req, HttpServletResponse resp) throws IOException { + Map body = json_.readValue(req.getInputStream(), Map.class); + String type = body.get("type") == null ? null : body.get("type").toString(); + String start = body.get("start") == null ? null : body.get("start").toString(); + String chosen = "folder".equals(type) ? NativePathChooser.chooseFolder(start) : NativePathChooser.chooseFile(start); + if (chosen != null) { + writeJson(resp, Map.of("path", chosen)); + } else { + writeJson(resp, new HashMap<>()); + } + } + + private void writeJson(HttpServletResponse resp, Map body) throws IOException { + resp.setContentType("application/json"); + json_.writeValue(resp.getOutputStream(), body); + } + + private Map snapshotToMap(RunState s) { + if (s instanceof RunState.Idle) return Map.of("kind", "idle"); + if (s instanceof RunState.Running) { + RunState.Running r = (RunState.Running) s; + return Map.of("kind", "running", "runId", r.runId, "tests", r.tests); + } + if (s instanceof RunState.Done) { + RunState.Done d = (RunState.Done) s; + return Map.of("kind", "done", "runId", d.runId, "tests", d.tests, + "passed", d.passed, "failed", d.failed, "durationMs", d.durationMs); + } + return Map.of("kind", "unknown"); + } + } + + private static final class SseServlet extends HttpServlet { + private final RunController controller_; + + SseServlet(RunController c) { this.controller_ = c; } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("text/event-stream"); + resp.setCharacterEncoding("UTF-8"); + resp.setHeader("Cache-Control", "no-store"); + resp.flushBuffer(); + AsyncContext async = req.startAsync(); + async.setTimeout(0); + CountDownLatch ready = new CountDownLatch(1); + controller_.stream().addClient(resp.getWriter(), () -> async.complete(), ready); + } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/GuiToken.java b/src/main/java/com/applitools/imagetester/gui/GuiToken.java new file mode 100644 index 0000000..664b4a3 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/GuiToken.java @@ -0,0 +1,29 @@ +package com.applitools.imagetester.gui; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +public final class GuiToken { + + private final String value_; + private final byte[] bytes_; + + private GuiToken(String value) { + this.value_ = value; + this.bytes_ = value.getBytes(); + } + + public static GuiToken generate() { + byte[] raw = new byte[32]; // 256-bit + new SecureRandom().nextBytes(raw); + return new GuiToken(Base64.getUrlEncoder().withoutPadding().encodeToString(raw)); + } + + public String value() { return value_; } + + public boolean verify(String candidate) { + if (candidate == null || candidate.isEmpty()) return false; + return MessageDigest.isEqual(bytes_, candidate.getBytes()); + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/IndexHtmlServlet.java b/src/main/java/com/applitools/imagetester/gui/IndexHtmlServlet.java new file mode 100644 index 0000000..cb55d9d --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/IndexHtmlServlet.java @@ -0,0 +1,41 @@ +package com.applitools.imagetester.gui; + +import com.applitools.imagetester.ImageTester; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public final class IndexHtmlServlet extends HttpServlet { + + private final GuiToken token_; + private final byte[] cachedHtml_; + + public IndexHtmlServlet(GuiToken token) { + this.token_ = token; + this.cachedHtml_ = loadIndexHtml(); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("text/html; charset=utf-8"); + resp.setHeader("Cache-Control", "no-store"); + String html = new String(cachedHtml_, StandardCharsets.UTF_8) + .replace("__GUI_TOKEN__", token_.value()) + .replace("__GUI_VERSION__", ImageTester.CUR_VER); + resp.getWriter().write(html); + } + + private static byte[] loadIndexHtml() { + try (InputStream in = IndexHtmlServlet.class.getResourceAsStream("/web/index.html")) { + if (in == null) throw new IllegalStateException("index.html not found on classpath at /web/index.html"); + return in.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("Failed to load index.html: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/LogRedactor.java b/src/main/java/com/applitools/imagetester/gui/LogRedactor.java new file mode 100644 index 0000000..60b24c4 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/LogRedactor.java @@ -0,0 +1,22 @@ +package com.applitools.imagetester.gui; + +import java.util.regex.Pattern; + +public final class LogRedactor { + + private static final Pattern PROXY_CREDS = Pattern.compile( + "(https?://)([^/:@\\s]+):([^/@\\s]+)@" + ); + + private LogRedactor() {} + + public static String redact(String line, String apiKey) { + String out = line; + if (apiKey != null && !apiKey.isEmpty()) { + // literal replace (no regex) — apiKey may contain regex metacharacters + out = out.replace(apiKey, "***"); + } + out = PROXY_CREDS.matcher(out).replaceAll("$1***:***@"); + return out; + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/NativePathChooser.java b/src/main/java/com/applitools/imagetester/gui/NativePathChooser.java new file mode 100644 index 0000000..c6a086a --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/NativePathChooser.java @@ -0,0 +1,112 @@ +package com.applitools.imagetester.gui; + +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.SwingUtilities; +import java.awt.FileDialog; +import java.awt.Frame; +import java.io.File; +import java.util.concurrent.atomic.AtomicReference; + +public final class NativePathChooser { + + private NativePathChooser() {} + + /** + * Asynchronously initialize AWT/Swing on a background thread so the first user click on + * "Choose file"/"Choose folder" isn't delayed by cold-start: Toolkit native init, Swing UI + * delegate loading, and (on Windows) the FileSystemView drive scan all happen eagerly here. + */ + public static void prewarm() { + Thread t = new Thread(() -> { + try { + java.awt.Toolkit.getDefaultToolkit(); + javax.swing.filechooser.FileSystemView.getFileSystemView().getRoots(); + SwingUtilities.invokeAndWait(JFileChooser::new); + } catch (Throwable ignored) { /* prewarm is best-effort */ } + }, "NativePathChooser-prewarm"); + t.setDaemon(true); + t.start(); + } + + public static String chooseFile() { return choose(false, null); } + public static String chooseFolder() { return choose(true, null); } + public static String chooseFile(String startPath) { return choose(false, startPath); } + public static String chooseFolder(String startPath) { return choose(true, startPath); } + + /** + * macOS opens the native NSOpenPanel behind the browser unless the app is activated first; + * the always-on-top anchor frame is enough for Swing dialogs but ignored by native panels. + */ + private static void requestForeground() { + try { + java.awt.Desktop desktop = java.awt.Desktop.getDesktop(); + if (desktop.isSupported(java.awt.Desktop.Action.APP_REQUEST_FOREGROUND)) { + desktop.requestForeground(true); + } + } catch (Throwable ignored) { /* focus is best-effort; the dialog still opens */ } + } + + /** Returns an existing directory derived from the hint (the path itself if a dir, else its parent), or null. */ + private static File resolveStartDir(String hint) { + if (hint == null || hint.isEmpty()) return null; + File f = new File(hint); + if (f.isDirectory()) return f; + File parent = f.getParentFile(); + return (parent != null && parent.isDirectory()) ? parent : null; + } + + private static String choose(boolean folder, String startPath) { + System.out.println("[NativePathChooser] choose folder=" + folder + " edt=" + SwingUtilities.isEventDispatchThread() + " start=" + startPath); + AtomicReference result = new AtomicReference<>(); + File startDir = resolveStartDir(startPath); + Runnable task = () -> { + // Anchor an off-screen, always-on-top, focusable frame so the file dialog inherits + // its z-order and comes to the foreground — without a real owner the OS leaves the + // dialog behind the browser window that just made this HTTP call. + JFrame anchor = new JFrame(); + anchor.setUndecorated(true); + anchor.setSize(1, 1); + anchor.setLocation(-10000, -10000); + anchor.setAlwaysOnTop(true); + anchor.setType(java.awt.Window.Type.UTILITY); + anchor.setVisible(true); + anchor.toFront(); + requestForeground(); + try { + if (folder) { + JFileChooser fc = new JFileChooser(); + fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + fc.setDialogTitle("Choose source folder"); + if (startDir != null) fc.setCurrentDirectory(startDir); + System.out.println("[NativePathChooser] showing JFileChooser"); + int rc = fc.showOpenDialog(anchor); + System.out.println("[NativePathChooser] JFileChooser returned " + rc); + if (rc == JFileChooser.APPROVE_OPTION) { + result.set(fc.getSelectedFile().getAbsolutePath()); + } + } else { + FileDialog fd = new FileDialog((Frame) anchor, "Choose source file", FileDialog.LOAD); + fd.setAlwaysOnTop(true); + if (startDir != null) fd.setDirectory(startDir.getAbsolutePath()); + System.out.println("[NativePathChooser] showing FileDialog"); + fd.setVisible(true); + System.out.println("[NativePathChooser] FileDialog closed, file=" + fd.getFile()); + if (fd.getFile() != null) { + result.set(new File(fd.getDirectory(), fd.getFile()).getAbsolutePath()); + } + } + } finally { + anchor.dispose(); + } + }; + try { + if (SwingUtilities.isEventDispatchThread()) task.run(); + else SwingUtilities.invokeAndWait(task); + } catch (Exception e) { + System.out.println("[NativePathChooser] exception: " + e); + return null; + } + return result.get(); + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/RunController.java b/src/main/java/com/applitools/imagetester/gui/RunController.java new file mode 100644 index 0000000..9769c07 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/RunController.java @@ -0,0 +1,259 @@ +package com.applitools.imagetester.gui; + +import com.applitools.imagetester.ImageTester; +import com.applitools.imagetester.Suite; +import com.applitools.imagetester.lib.Config; +import com.applitools.imagetester.lib.EyesFactory; +import com.applitools.imagetester.lib.Logger; +import com.applitools.imagetester.lib.PdfVectorWatermarkAutoMode; +import com.applitools.imagetester.lib.PdfWatermarkOutMode; +import com.applitools.imagetester.lib.RunConfig; +import com.applitools.imagetester.lib.RunConfigFactory; +import com.applitools.imagetester.lib.TestExecutor; +import com.applitools.imagetester.lib.Utils; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.ParseException; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +public final class RunController { + + public static final class StartResult { + public final String runId; + public StartResult(String runId) { this.runId = runId; } + } + + public static final class RunInProgressException extends RuntimeException { + public RunInProgressException() { super("A run is already in progress."); } + } + + public static final class MissingApiKeyException extends RuntimeException { + public MissingApiKeyException() { super("No Applitools API key configured."); } + } + + public static final class InvalidOptionsException extends RuntimeException { + public InvalidOptionsException(String msg) { super(msg); } + } + + /** Builds a RunConfig for one run from the full RunRequest. Allows tests to inject mocks. */ + @FunctionalInterface + public interface RunConfigBuilder { + RunConfig build(RunRequest req, Logger logger); + } + + static RunConfig buildRunConfig(RunRequest req, Logger logger) { + try { + CommandLine cmd = new DefaultParser().parse(ImageTester.getOptions(), + RunRequestTranslator.toArgv(req)); + // Mirror main()'s -dv side effect (main applies it before the config mapping). + if (cmd.hasOption("dv")) Utils.disableCertValidation(); + return RunConfigFactory.from(cmd, logger); + } catch (ParseException e) { + throw new InvalidOptionsException(e.getMessage()); + } catch (java.security.NoSuchAlgorithmException | java.security.KeyManagementException e) { + throw new InvalidOptionsException("Could not disable SSL validation: " + e.getMessage()); + } + } + + private static final RunConfigBuilder PRODUCTION_BUILDER = RunController::buildRunConfig; + + private final SecretsStore secrets_; + private final RunStream runStream_; + private final RunConfigBuilder factoryBuilder_; + private final AtomicReference state_ = new AtomicReference<>(RunState.Idle.INSTANCE); + private final AtomicReference currentExecutor_ = new AtomicReference<>(); + private final ExecutorService runExecutor_ = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "RunController-run"); + t.setDaemon(true); + return t; + }); + + public RunController(SecretsStore secrets, RunStream runStream) { + this(secrets, runStream, PRODUCTION_BUILDER); + } + + public RunController(SecretsStore secrets, RunStream runStream, RunConfigBuilder factoryBuilder) { + this.secrets_ = secrets; + this.runStream_ = runStream; + this.factoryBuilder_ = factoryBuilder; + } + + public RunState snapshot() { return state_.get(); } + public RunStream stream() { return runStream_; } + public SecretsStore secrets() { return secrets_; } + + public void setSecretApiKey(String value) { secrets_.setApiKey(value); } + + public StartResult start(RunRequest req) { + Path validated = SourcePathValidator.validate(req.sourcePath); + String apiKey = secrets_.getApiKey(); + if (apiKey == null || apiKey.isEmpty()) throw new MissingApiKeyException(); + + validateWatermarkFlags(req); // throws InvalidOptionsException synchronously + + Logger logger = new Logger(); + RunConfig rc = factoryBuilder_.build(req, logger); // throws on malformed options synchronously + + RunState.Running running = new RunState.Running(); + // Race-safe transition: only one caller can win; stale Done is also replaced atomically. + while (true) { + RunState current = state_.get(); + if (current instanceof RunState.Running) throw new RunInProgressException(); + if (state_.compareAndSet(current, running)) break; + } + + // Wipe SSE replay history so a tab that connects mid-run only sees events from *this* run. + runStream_.resetReplay(); + // Emitted before the HTTP response returns so the frontend can enter "running" from the + // stream itself instead of racing its optimistic dispatch against test-started. + runStream_.emit(new SseEvent.RunStarted(running.runId)); + runExecutor_.submit(() -> executeRun(validated.toFile(), req, apiKey, rc, logger, running)); + return new StartResult(running.runId); + } + + private static void validateWatermarkFlags(RunRequest req) { + if (req.options == null) return; + Object rwo = req.options.get("rwo"); + if (rwo == null || rwo.toString().isEmpty()) return; + boolean auto = Boolean.TRUE.equals(req.options.get("rwauto")); + Object rw = req.options.get("rw"); + boolean hasRw = rw != null && !rw.toString().trim().isEmpty(); + if (!auto && !hasRw) throw new InvalidOptionsException("-rwo requires -rw or -rwauto"); + } + + public void cancel() { + TestExecutor executor = currentExecutor_.get(); + if (executor == null) return; + executor.cancel(); + } + + private void executeRun(File source, RunRequest req, String apiKey, RunConfig rc, Logger logger, RunState.Running running) { + long startedNs = System.nanoTime(); + Config config = rc.config; + config.apiKey = apiKey; + // The factory captured a null key at build time (no -k flag, no env var in the + // installed app); every Eyes instance it creates needs the GUI-provided key. + rc.factory.apiKey(apiKey); + config.shouldThrowException = false; // CRITICAL: a thrown diff calls System.exit + if (config.logger == null) config.logger = logger; + + Consumer logListener = line -> runStream_.emit(new SseEvent.LogLine(LogRedactor.redact(line, apiKey))); + config.logger.addListener(logListener); + + Object rwo = req.options == null ? null : req.options.get("rwo"); + if (rwo != null && !rwo.toString().isEmpty()) { + runWatermarkOut(source, req, config.logger, running, startedNs); + return; + } + + // rwauto without rwo: clean source into a temp dir, then run the Suite on the cleaned output. + // Temp dir creation is outside the Suite's try block so the finally block always fires. + final File effectiveSource; + if (req.options != null && Boolean.TRUE.equals(req.options.get("rwauto"))) { + File tempDir = null; + try { + tempDir = Files.createTempDirectory("imagetester-rwauto-").toFile(); + Runtime.getRuntime().addShutdownHook(new Thread(deleteRecursively(tempDir))); + } catch (IOException e) { + config.logger.reportException(e); + } + effectiveSource = tempDir != null ? tempDir : source; + } else { + effectiveSource = source; + } + + int passed = 0, failed = 0; + try { + EyesFactory factory = rc.factory; + factory.logHandlerInstance(new EyesLogBridge(config.logger)); + TestExecutor executor = new TestExecutor(rc.threads, factory, config); + currentExecutor_.set(executor); + executor.setTestStartedListener(name -> runStream_.emit(new SseEvent.TestStarted(name))); + executor.setTestCompletionListener(result -> { + String name = result.testResult != null ? result.testResult.getName() : "(unknown)"; + String status = result.testResult != null && result.testResult.isDifferent() ? "fail" : "pass"; + long ms = TimeUnit.NANOSECONDS.toMillis(result.runTimeNs); + String dashboard = result.testResult != null ? result.testResult.getUrl() : null; + runStream_.emit(new SseEvent.TestFinished(name, status, ms, dashboard)); + RunState.TestRow row = new RunState.TestRow(name); + row.status = status; row.durationMs = ms; row.dashboardUrl = dashboard; + running.tests.add(row); + }); + if (effectiveSource != source) { + String rwText = req.options.get("rw") != null ? req.options.get("rw").toString() : null; + PdfVectorWatermarkAutoMode.run(source, effectiveSource, rwText, config.logger); + } + config.logger.printMessage(String.format("Scanning source: %s%n", effectiveSource.getAbsolutePath())); + Suite suite = Suite.create(effectiveSource.getCanonicalFile(), config, executor); + config.logger.printMessage("Starting tests" + System.lineSeparator()); + suite.run(); + } catch (Throwable t) { + config.logger.reportException(t); + } finally { + TestExecutor executor = currentExecutor_.getAndSet(null); + if (executor != null && executor.isCancelled()) + config.logger.printMessage("Run cancelled" + System.lineSeparator()); + config.logger.removeListener(logListener); + for (RunState.TestRow r : running.tests) { + if ("pass".equals(r.status)) passed++; + else if ("fail".equals(r.status)) failed++; + } + long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNs); + RunState.Done done = new RunState.Done(running.runId, running.tests, passed, failed, durationMs); + state_.set(done); + runStream_.emit(new SseEvent.RunFinished(passed, failed, durationMs)); + } + } + + private static Runnable deleteRecursively(final File dir) { + return new Runnable() { + @Override + public void run() { + if (dir == null || !dir.exists()) return; + deleteTree(dir); + } + + private void deleteTree(File entry) { + if (entry.isDirectory()) { + File[] children = entry.listFiles(); + if (children != null) { + for (File child : children) deleteTree(child); + } + } + if (!entry.delete()) entry.deleteOnExit(); + } + }; + } + + private void runWatermarkOut(File source, RunRequest req, Logger logger, + RunState.Running running, long startedNs) { + String outPath = req.options.get("rwo").toString(); + File outDir = new File(outPath); + String rwText = req.options.get("rw") == null ? null : req.options.get("rw").toString(); + boolean auto = Boolean.TRUE.equals(req.options.get("rwauto")); + try { + // Dependent-flag guard mirrors ImageTester.validateWatermarkFlags: -rwo needs -rw or -rwauto. + if (!auto && (rwText == null || rwText.trim().isEmpty())) + throw new InvalidOptionsException("-rwo requires -rw or -rwauto"); + if (auto) PdfVectorWatermarkAutoMode.run(source, outDir, rwText, logger); + else PdfWatermarkOutMode.run(source, rwText, outDir, logger); + } catch (Throwable t) { + logger.reportException(t); + } finally { + File[] files = outDir.listFiles(); + int count = files == null ? 0 : files.length; + long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNs); + state_.set(new RunState.Done(running.runId, running.tests, 0, 0, durationMs, outPath)); + runStream_.emit(new SseEvent.WatermarkCleaned(outPath, count, durationMs)); + } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/RunRequest.java b/src/main/java/com/applitools/imagetester/gui/RunRequest.java new file mode 100644 index 0000000..e7d723e --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/RunRequest.java @@ -0,0 +1,8 @@ +package com.applitools.imagetester.gui; + +import java.util.Map; + +public final class RunRequest { + public String sourcePath; + public Map options; +} diff --git a/src/main/java/com/applitools/imagetester/gui/RunRequestTranslator.java b/src/main/java/com/applitools/imagetester/gui/RunRequestTranslator.java new file mode 100644 index 0000000..44884bc --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/RunRequestTranslator.java @@ -0,0 +1,41 @@ +package com.applitools.imagetester.gui; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class RunRequestTranslator { + + // Excluded by decision/necessity: -mp (separate flow), -te (would System.exit the server). + private static final Set BLOCKED = new HashSet<>(); + static { Collections.addAll(BLOCKED, "mp", "te", "f"); } + + private RunRequestTranslator() {} + + public static String[] toArgv(RunRequest req) { + List argv = new ArrayList<>(); + if (req.sourcePath != null) { + argv.add("-f"); + argv.add(req.sourcePath); + } + if (req.options != null) { + for (Map.Entry e : req.options.entrySet()) { + String flag = e.getKey(); + Object value = e.getValue(); + if (flag == null || BLOCKED.contains(flag) || value == null) continue; + if (value instanceof Boolean) { + if ((Boolean) value) argv.add("-" + flag); + continue; + } + String s = value.toString(); + if (s.isEmpty()) continue; + argv.add("-" + flag); + argv.add(s); + } + } + return argv.toArray(new String[0]); + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/RunState.java b/src/main/java/com/applitools/imagetester/gui/RunState.java new file mode 100644 index 0000000..4ebd725 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/RunState.java @@ -0,0 +1,54 @@ +package com.applitools.imagetester.gui; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public abstract class RunState { + + private RunState() {} + + public static final class Idle extends RunState { + public static final Idle INSTANCE = new Idle(); + private Idle() {} + } + + public static final class Running extends RunState { + public final String runId; + public final long startedAtMillis; + public final List tests; + public Running() { + this.runId = UUID.randomUUID().toString(); + this.startedAtMillis = System.currentTimeMillis(); + this.tests = new ArrayList<>(); + } + } + + public static final class Done extends RunState { + public final String runId; + public final List tests; + public final int passed; + public final int failed; + public final long durationMs; + public final String outputDir; + public Done(String runId, List tests, int passed, int failed, long durationMs) { + this(runId, tests, passed, failed, durationMs, null); + } + public Done(String runId, List tests, int passed, int failed, long durationMs, String outputDir) { + this.runId = runId; + this.tests = tests; + this.passed = passed; + this.failed = failed; + this.durationMs = durationMs; + this.outputDir = outputDir; + } + } + + public static final class TestRow { + public final String name; + public String status; // "running" | "pass" | "fail" + public Long durationMs; + public String dashboardUrl; + public TestRow(String name) { this.name = name; this.status = "running"; } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/RunStream.java b/src/main/java/com/applitools/imagetester/gui/RunStream.java new file mode 100644 index 0000000..fc11a6e --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/RunStream.java @@ -0,0 +1,134 @@ +package com.applitools.imagetester.gui; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.PrintWriter; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public final class RunStream { + + // 16k absorbs verbose Eyes-SDK / PDF upload bursts so log lines aren't shed under load. + private static final int DEFAULT_CAPACITY = 16384; + // Replay buffer: enough for a long run's worth of log lines so a tab opened mid-run still sees history. + private static final int REPLAY_CAPACITY = 16384; + private static final long LIFECYCLE_PUT_TIMEOUT_MS = 100; + + private final int capacity_; + private final List clients_ = new CopyOnWriteArrayList<>(); + private final ObjectMapper json_ = new ObjectMapper(); + private final AtomicInteger droppedLogLines_ = new AtomicInteger(); + private final Deque replayBuffer_ = new ArrayDeque<>(REPLAY_CAPACITY); + + public RunStream() { this(DEFAULT_CAPACITY); } + + public RunStream(int capacity) { this.capacity_ = capacity; } + + public void addClient(PrintWriter writer, Runnable onClose, CountDownLatch ready) { + List snapshot; + synchronized (replayBuffer_) { + snapshot = new ArrayList<>(replayBuffer_); + } + Client c = new Client(writer, onClose, snapshot); + clients_.add(c); + c.start(ready); + } + + public int activeClientCount() { return clients_.size(); } + + public int droppedLogLineCount() { return droppedLogLines_.get(); } + + public void emit(SseEvent event) { + synchronized (replayBuffer_) { + replayBuffer_.addLast(event); + while (replayBuffer_.size() > REPLAY_CAPACITY) replayBuffer_.removeFirst(); + } + for (Client c : clients_) c.offer(event); + } + + /** Wipes replay history; call at the start of a new run so a fresh tab doesn't see the previous run's tail. */ + public void resetReplay() { + synchronized (replayBuffer_) { replayBuffer_.clear(); } + } + + public void close() { + for (Client c : clients_) c.stop(); + clients_.clear(); + } + + private final class Client { + private final PrintWriter writer_; + private final Runnable onClose_; + private final BlockingQueue queue_ = new ArrayBlockingQueue<>(capacity_); + private final List replay_; + private volatile boolean alive_ = true; + private Thread drainer_; + + Client(PrintWriter w, Runnable onClose, List replay) { + this.writer_ = w; + this.onClose_ = onClose; + this.replay_ = replay; + } + + void start(CountDownLatch ready) { + drainer_ = new Thread(() -> { + ready.countDown(); + try { + // Replay buffered events first so a freshly-attached client sees the run's history, + // then enter the normal poll loop for live events. + for (SseEvent ev : replay_) { + String line = "data: " + json_.writeValueAsString(ev) + "\n\n"; + writer_.write(line); + } + if (!replay_.isEmpty()) writer_.flush(); + + while (alive_) { + SseEvent ev = queue_.poll(250, TimeUnit.MILLISECONDS); + if (ev == null) continue; + String line = "data: " + json_.writeValueAsString(ev) + "\n\n"; + writer_.write(line); + writer_.flush(); + } + } catch (Throwable t) { + // client disconnected or writer threw — prune + } finally { + alive_ = false; + clients_.remove(this); + try { onClose_.run(); } catch (Throwable ignored) {} + } + }, "RunStream-drainer"); + drainer_.setDaemon(true); + drainer_.start(); + } + + void offer(SseEvent ev) { + if (!alive_) return; + if (ev instanceof SseEvent.LogLine) { + if (!queue_.offer(ev)) droppedLogLines_.incrementAndGet(); + } else { + try { + if (!queue_.offer(ev, LIFECYCLE_PUT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + queue_.poll(); + droppedLogLines_.incrementAndGet(); + queue_.offer(ev); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + void stop() { + alive_ = false; + if (drainer_ != null) drainer_.interrupt(); + } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/SecretsStore.java b/src/main/java/com/applitools/imagetester/gui/SecretsStore.java new file mode 100644 index 0000000..5a396fe --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/SecretsStore.java @@ -0,0 +1,78 @@ +package com.applitools.imagetester.gui; + +import com.github.javakeyring.Keyring; +import com.github.javakeyring.PasswordAccessException; + +public final class SecretsStore { + + private static final String SERVICE = "Applitools ImageTester"; + private static final String ACCOUNT = "apiKey"; + + public interface Backend { + String get() throws Exception; + void set(String value) throws Exception; + void delete() throws Exception; + } + + private final Backend backend_; + + private SecretsStore(Backend backend) { this.backend_ = backend; } + + public static SecretsStore forProduction() { + return new SecretsStore(new KeyringBackend()); + } + + public static SecretsStore inMemoryForTest() { + return new SecretsStore(new InMemoryBackend()); + } + + public boolean hasApiKey() { + try { + String v = backend_.get(); + return v != null && !v.isEmpty(); + } catch (Exception e) { return false; } + } + + public String getApiKey() { + try { return backend_.get(); } catch (Exception e) { return null; } + } + + public void setApiKey(String value) { + if (value == null || value.isEmpty()) { + deleteApiKey(); + return; + } + try { backend_.set(value); } catch (Exception e) { + throw new RuntimeException("Could not save API key: " + e.getMessage(), e); + } + } + + public void deleteApiKey() { + try { backend_.delete(); } catch (Exception ignored) {} + } + + private static final class KeyringBackend implements Backend { + @Override public String get() throws Exception { + try (Keyring kr = Keyring.create()) { + try { return kr.getPassword(SERVICE, ACCOUNT); } + catch (PasswordAccessException e) { return null; } + } + } + @Override public void set(String value) throws Exception { + try (Keyring kr = Keyring.create()) { kr.setPassword(SERVICE, ACCOUNT, value); } + } + @Override public void delete() throws Exception { + try (Keyring kr = Keyring.create()) { + try { kr.deletePassword(SERVICE, ACCOUNT); } + catch (PasswordAccessException ignored) {} + } + } + } + + private static final class InMemoryBackend implements Backend { + private String value_; + @Override public synchronized String get() { return value_; } + @Override public synchronized void set(String value) { this.value_ = value; } + @Override public synchronized void delete() { this.value_ = null; } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/SourcePathValidator.java b/src/main/java/com/applitools/imagetester/gui/SourcePathValidator.java new file mode 100644 index 0000000..4311131 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/SourcePathValidator.java @@ -0,0 +1,40 @@ +package com.applitools.imagetester.gui; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class SourcePathValidator { + + public static final class InvalidSourceException extends RuntimeException { + public InvalidSourceException(String message) { + super(message); + } + } + + private SourcePathValidator() {} + + public static Path validate(String pathStr) { + if (pathStr == null || pathStr.trim().isEmpty()) { + throw new InvalidSourceException("Source path is empty."); + } + + Path resolved; + try { + resolved = Paths.get(pathStr).toAbsolutePath().toRealPath(); + } catch (IOException | java.nio.file.InvalidPathException e) { + throw new InvalidSourceException("Source path could not be resolved: " + e.getMessage()); + } + + if (!Files.exists(resolved)) { + throw new InvalidSourceException("Source path does not exist: " + resolved); + } + + if (!Files.isReadable(resolved)) { + throw new InvalidSourceException("Source path is not readable: " + resolved); + } + + return resolved; + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/SseEvent.java b/src/main/java/com/applitools/imagetester/gui/SseEvent.java new file mode 100644 index 0000000..3e48ea1 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/SseEvent.java @@ -0,0 +1,52 @@ +package com.applitools.imagetester.gui; + +public abstract class SseEvent { + + public final String type; + protected SseEvent(String type) { this.type = type; } + + public static final class RunStarted extends SseEvent { + public final String runId; + public RunStarted(String runId) { super("run-started"); this.runId = runId; } + } + + public static final class TestStarted extends SseEvent { + public final String name; + public TestStarted(String name) { super("test-started"); this.name = name; } + } + + public static final class TestFinished extends SseEvent { + public final String name; + public final String status; + public final long durationMs; + public final String dashboardUrl; + public TestFinished(String name, String status, long durationMs, String dashboardUrl) { + super("test-finished"); + this.name = name; this.status = status; this.durationMs = durationMs; this.dashboardUrl = dashboardUrl; + } + } + + public static final class LogLine extends SseEvent { + public final String text; + public LogLine(String text) { super("log-line"); this.text = text; } + } + + public static final class RunFinished extends SseEvent { + public final int passed; + public final int failed; + public final long durationMs; + public RunFinished(int passed, int failed, long durationMs) { + super("run-finished"); this.passed = passed; this.failed = failed; this.durationMs = durationMs; + } + } + + public static final class WatermarkCleaned extends SseEvent { + public final String outputDir; + public final int fileCount; + public final long durationMs; + public WatermarkCleaned(String outputDir, int fileCount, long durationMs) { + super("watermark-cleaned"); + this.outputDir = outputDir; this.fileCount = fileCount; this.durationMs = durationMs; + } + } +} diff --git a/src/main/java/com/applitools/imagetester/gui/TokenAuthFilter.java b/src/main/java/com/applitools/imagetester/gui/TokenAuthFilter.java new file mode 100644 index 0000000..0b1d107 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/gui/TokenAuthFilter.java @@ -0,0 +1,66 @@ +package com.applitools.imagetester.gui; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +public final class TokenAuthFilter implements Filter { + + private static final String BEARER = "Bearer "; + private final GuiToken token_; + private final List allowedHosts_; + private final String allowedOrigin_; + + public TokenAuthFilter(GuiToken token, int port) { + this.token_ = token; + this.allowedHosts_ = Arrays.asList("localhost:" + port, "127.0.0.1:" + port); + this.allowedOrigin_ = "http://localhost:" + port; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse resp = (HttpServletResponse) response; + String uri = req.getRequestURI(); + + if (uri == null || !uri.startsWith("/api/")) { + chain.doFilter(request, response); + return; + } + + String host = req.getHeader("Host"); + if (host == null || !allowedHosts_.contains(host)) { + resp.setStatus(403); + return; + } + + String origin = req.getHeader("Origin"); + if (origin != null && !origin.equals(allowedOrigin_)) { + resp.setStatus(403); + return; + } + + String candidate = extractToken(req); + if (candidate == null || !token_.verify(candidate)) { + resp.setStatus(401); + return; + } + + chain.doFilter(request, response); + } + + private static String extractToken(HttpServletRequest req) { + String auth = req.getHeader("Authorization"); + if (auth != null && auth.startsWith(BEARER)) return auth.substring(BEARER.length()); + return req.getParameter("token"); + } +} diff --git a/src/main/java/com/applitools/imagetester/lib/Config.java b/src/main/java/com/applitools/imagetester/lib/Config.java index 825f9c6..724bc38 100644 --- a/src/main/java/com/applitools/imagetester/lib/Config.java +++ b/src/main/java/com/applitools/imagetester/lib/Config.java @@ -38,6 +38,7 @@ public class Config { public ProxySettings proxy_settings = null; public String matchWidth = null; public String matchHeight = null; + public String pdfTrim = null; public boolean legacyFileOrder = false; public boolean dontCloseBatches = false; public String batchMapperPath = null; @@ -105,6 +106,29 @@ public void setProxy(String[] proxy) { throw new RuntimeException("Proxy setting are invalid"); } + public static final String PDF_TRIM_AUTO = "auto"; + + public void setPdfTrim(String value) { + if (value == null) return; + if (!PDF_TRIM_AUTO.equals(value) && parsePdfTrimSize(value) == null) + throw new RuntimeException( + "invalid pdf trim, make sure the call is -tp auto or -tp x (PDF points)"); + this.pdfTrim = value; + } + + /** Returns {width, height} in PDF points, or null when the value is not a valid WxH pair. */ + public static float[] parsePdfTrimSize(String value) { + String[] dims = value.split("x"); + if (dims.length != 2) return null; + try { + float width = Float.parseFloat(dims[0]); + float height = Float.parseFloat(dims[1]); + return width > 0 && height > 0 ? new float[] { width, height } : null; + } catch (NumberFormatException e) { + return null; + } + } + public void setMatchSize(String size) { if (size == null) return; diff --git a/src/main/java/com/applitools/imagetester/lib/CropMarkDetector.java b/src/main/java/com/applitools/imagetester/lib/CropMarkDetector.java new file mode 100644 index 0000000..0a52733 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/lib/CropMarkDetector.java @@ -0,0 +1,246 @@ +package com.applitools.imagetester.lib; + +import java.awt.geom.Point2D; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.PDImage; + +/** + * Detects the trim rectangle of a print-production PDF page from its crop marks: + * short, axis-aligned stroked hairlines in the page margins whose positions mark + * the cut lines. Vector-based (content-stream parsing), so the result is exact in + * PDF points and independent of render DPI. Returns null whenever the marks do + * not form an unambiguous rectangle — never guesses. + */ +public final class CropMarkDetector { + + private static final float AXIS_TOLERANCE_PT = 0.5f; + private static final float CLUSTER_TOLERANCE_PT = 1.0f; + private static final float MIN_MARK_LENGTH_PT = 4f; + private static final float MAX_MARK_LENGTH_PT = 40f; + private static final float MIN_MARGIN_PT = 1f; + // A trim box smaller than half the page is more likely a false positive than a real cut line. + private static final float MIN_TRIM_FRACTION = 0.5f; + + private CropMarkDetector() { + } + + public static PDRectangle detect(PDPage page) { + List segments; + try { + SegmentCollector collector = new SegmentCollector(page); + collector.processPage(page); + segments = collector.markCandidates(); + } catch (IOException e) { + return null; + } + + List vertical = new ArrayList<>(); + List horizontal = new ArrayList<>(); + for (Segment s : segments) { + if (s.isVertical()) vertical.add(s); + else if (s.isHorizontal()) horizontal.add(s); + } + + List xClusters = pairedClusters(cluster(vertical, true)); + List yClusters = pairedClusters(cluster(horizontal, false)); + + // Print files often nest mark sets (bleed marks outside crop marks), so try every + // edge combination and keep the smallest box that validates: the innermost set of + // marks that all sit in the margins is the cut line. + PDRectangle best = null; + for (Cluster left : xClusters) { + for (Cluster right : xClusters) { + if (right.position <= left.position) continue; + for (Cluster bottom : yClusters) { + for (Cluster top : yClusters) { + if (top.position <= bottom.position) continue; + PDRectangle box = new PDRectangle( + left.position, bottom.position, + right.position - left.position, top.position - bottom.position); + if (!isPlausibleTrimBox(box, page.getMediaBox(), left, right, bottom, top)) continue; + if (best == null || area(box) < area(best)) best = box; + } + } + } + } + return best; + } + + private static float area(PDRectangle box) { + return box.getWidth() * box.getHeight(); + } + + /** Crop marks always come in pairs per edge — lone segments are content, not marks. */ + private static List pairedClusters(List clusters) { + List paired = new ArrayList<>(); + for (Cluster c : clusters) { + if (c.segments.size() >= 2) paired.add(c); + } + return paired; + } + + private static List cluster(List segments, boolean byX) { + List clusters = new ArrayList<>(); + for (Segment s : segments) { + float position = byX ? s.x1 : s.y1; + Cluster match = null; + for (Cluster c : clusters) { + if (Math.abs(c.position - position) <= CLUSTER_TOLERANCE_PT) { match = c; break; } + } + if (match == null) { + match = new Cluster(position); + clusters.add(match); + } + match.segments.add(s); + } + return clusters; + } + + private static boolean isPlausibleTrimBox(PDRectangle box, PDRectangle media, + Cluster left, Cluster right, Cluster bottom, Cluster top) { + if (box.getLowerLeftX() < media.getLowerLeftX() + MIN_MARGIN_PT) return false; + if (box.getLowerLeftY() < media.getLowerLeftY() + MIN_MARGIN_PT) return false; + if (box.getUpperRightX() > media.getUpperRightX() - MIN_MARGIN_PT) return false; + if (box.getUpperRightY() > media.getUpperRightY() - MIN_MARGIN_PT) return false; + if (box.getWidth() < media.getWidth() * MIN_TRIM_FRACTION) return false; + if (box.getHeight() < media.getHeight() * MIN_TRIM_FRACTION) return false; + + // Real crop marks live in the margins: vertical marks span y outside the box, + // horizontal marks span x outside it. Anything inside means we mis-clustered. + for (Cluster c : new Cluster[] { left, right }) { + for (Segment s : c.segments) { + if (overlaps(Math.min(s.y1, s.y2), Math.max(s.y1, s.y2), + box.getLowerLeftY(), box.getUpperRightY())) return false; + } + } + for (Cluster c : new Cluster[] { bottom, top }) { + for (Segment s : c.segments) { + if (overlaps(Math.min(s.x1, s.x2), Math.max(s.x1, s.x2), + box.getLowerLeftX(), box.getUpperRightX())) return false; + } + } + return true; + } + + private static boolean overlaps(float min, float max, float rangeMin, float rangeMax) { + return max > rangeMin && min < rangeMax; + } + + private static final class Segment { + final float x1, y1, x2, y2; + + Segment(float x1, float y1, float x2, float y2) { + this.x1 = x1; this.y1 = y1; this.x2 = x2; this.y2 = y2; + } + + boolean isVertical() { return Math.abs(x1 - x2) <= AXIS_TOLERANCE_PT; } + boolean isHorizontal() { return Math.abs(y1 - y2) <= AXIS_TOLERANCE_PT; } + + float length() { return (float) Math.hypot(x2 - x1, y2 - y1); } + + boolean isMarkLength() { + float len = length(); + return len >= MIN_MARK_LENGTH_PT && len <= MAX_MARK_LENGTH_PT; + } + } + + /** Replays the content stream and keeps only stroked, mark-length line segments. */ + private static final class SegmentCollector extends PDFGraphicsStreamEngine { + + private final List stroked = new ArrayList<>(); + private final List pendingPath = new ArrayList<>(); + private Point2D currentPoint = new Point2D.Float(); + + SegmentCollector(PDPage page) { + super(page); + } + + List markCandidates() { + List candidates = new ArrayList<>(); + for (Segment s : stroked) { + if (s.isMarkLength() && (s.isVertical() || s.isHorizontal())) candidates.add(s); + } + return candidates; + } + + @Override + public void moveTo(float x, float y) { + currentPoint = new Point2D.Float(x, y); + } + + @Override + public void lineTo(float x, float y) { + pendingPath.add(new Segment((float) currentPoint.getX(), (float) currentPoint.getY(), x, y)); + currentPoint = new Point2D.Float(x, y); + } + + @Override + public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3) { + currentPoint = new Point2D.Float(x3, y3); + } + + @Override + public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) { + currentPoint = p0; + } + + @Override + public Point2D getCurrentPoint() { + return currentPoint; + } + + @Override + public void strokePath() { + stroked.addAll(pendingPath); + pendingPath.clear(); + } + + @Override + public void fillPath(int windingRule) { + pendingPath.clear(); + } + + @Override + public void fillAndStrokePath(int windingRule) { + stroked.addAll(pendingPath); + pendingPath.clear(); + } + + @Override + public void closePath() { + } + + @Override + public void endPath() { + pendingPath.clear(); + } + + @Override + public void clip(int windingRule) { + } + + @Override + public void drawImage(PDImage image) { + } + + @Override + public void shadingFill(COSName shadingName) { + } + } + + private static final class Cluster { + final float position; + final List segments = new ArrayList<>(); + + Cluster(float position) { + this.position = position; + } + } +} diff --git a/src/main/java/com/applitools/imagetester/lib/EyesFactory.java b/src/main/java/com/applitools/imagetester/lib/EyesFactory.java index 4827ee6..dfabda1 100644 --- a/src/main/java/com/applitools/imagetester/lib/EyesFactory.java +++ b/src/main/java/com/applitools/imagetester/lib/EyesFactory.java @@ -4,6 +4,7 @@ import com.applitools.eyes.AccessibilityLevel; import com.applitools.eyes.AccessibilitySettings; import com.applitools.eyes.FileLogger; +import com.applitools.eyes.LogHandler; import com.applitools.eyes.MatchLevel; import com.applitools.eyes.ProxySettings; import com.applitools.eyes.StdoutLogHandler; @@ -35,6 +36,7 @@ public class EyesFactory { private int[] cutValues; private AccessibilitySettings accSettings = null; private boolean logHandler; + private LogHandler logHandlerInstance; private String deviceName; public EyesFactory(String ver, Logger logger) { @@ -106,11 +108,19 @@ public String getFullAgentId() { throw new RuntimeException("Parent Branches (pb) should be combined with branches (br)."); if (this.accSettings != null) eyes.setAccessibilityValidation(this.accSettings); - if (logHandler) + // A custom instance wins over the boolean stdout flag; the GUI uses this to pipe SDK events into its log pane. + if (logHandlerInstance != null) + eyes.setLogHandler(logHandlerInstance); + else if (logHandler) eyes.setLogHandler(new StdoutLogHandler(true)); return eyes; } + public EyesFactory logHandlerInstance(LogHandler lh) { + this.logHandlerInstance = lh; + return this; + } + public boolean hasAccessibilityValidation() { return accSettings != null; } diff --git a/src/main/java/com/applitools/imagetester/lib/Logger.java b/src/main/java/com/applitools/imagetester/lib/Logger.java index 9ab8e69..74ed965 100644 --- a/src/main/java/com/applitools/imagetester/lib/Logger.java +++ b/src/main/java/com/applitools/imagetester/lib/Logger.java @@ -10,126 +10,131 @@ import java.io.File; import java.io.IOException; import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.StringWriter; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; public class Logger { private final PrintStream out_; private boolean debug_; private final SimpleDateFormat dateFormatter_ = new SimpleDateFormat("HH:mm:ss"); + private final List> listeners_ = new CopyOnWriteArrayList<>(); - public Logger() { - this(System.out, false); - } + public Logger() { this(System.out, false); } public Logger(PrintStream out, boolean debug) { this.out_ = out; this.debug_ = debug; } - public void setDebug(boolean debug) { - this.debug_ = debug; - } + public void setDebug(boolean debug) { this.debug_ = debug; } + public void setDebug() { setDebug(true); } + + public void addListener(Consumer listener) { listeners_.add(listener); } + public void removeListener(Consumer listener) { listeners_.remove(listener); } - public void setDebug() { - setDebug(true); + private void emit(String message) { + out_.print(message); + for (Consumer l : listeners_) { + try { l.accept(message); } catch (Throwable ignored) { /* never let a listener disrupt logging */ } + } } public void printBatchPojo(BatchMapPojo batchMapPojo) { - out_.printf("%s \n", batchMapPojo); + emit(String.format("%s \n", batchMapPojo)); } public void printProgress(int curr, int total) { - out_.printf("[%s/%s] \n", curr, total); + // CLI-only progress: GUI surfaces this via test-started/finished rows, where "[N/total]" is redundant noise. + out_.print(String.format("[%s/%s] \n", curr, total)); } - private void printPrefix() { - if (debug_) { - Date date = new Date(System.currentTimeMillis()); - out_.printf("[%s] [%s] ", dateFormatter_.format(date), Thread.currentThread().getName()); - } + public void printHeartbeat(String name, long elapsedSeconds) { + emit(String.format("Still running... %s - %ds elapsed \n", name, elapsedSeconds)); + } + + private String prefix() { + if (!debug_) return ""; + Date date = new Date(System.currentTimeMillis()); + return String.format("[%s] [%s] ", dateFormatter_.format(date), Thread.currentThread().getName()); } public void printMessage(String msg) { - out_.print(msg); + emit(msg); } public void reportDebug(String format, Object... args) { if (!debug_) return; - printPrefix(); - out_.printf(format, args); + emit(prefix() + String.format(format, args)); } public void reportDiscovery(File file) { if (!debug_) return; - printPrefix(); if (file.isDirectory()) - out_.printf("Discovering folder %s \n", file.getAbsolutePath()); + emit(prefix() + String.format("Discovering folder %s \n", file.getAbsolutePath())); else - out_.printf("Enqueuing file %s \n", file.getAbsolutePath()); + emit(prefix() + String.format("Enqueuing file %s \n", file.getAbsolutePath())); } public void reportResult(ExecutorResult result) { - printPrefix(); - if (debug_) out_.printf("[%d Msec] ", TimeUnit.NANOSECONDS.toMillis(result.runTimeNs)); + StringBuilder sb = new StringBuilder(prefix()); + if (debug_) sb.append(String.format("[%d Msec] ", TimeUnit.NANOSECONDS.toMillis(result.runTimeNs))); String status = result.testResult != null ? result.testResult.getStatus().toString() : "N/A"; - out_.printf("[%s], %s \n", status, result.testResult); + sb.append(String.format("[%s], %s \n", status, result.testResult)); + emit(sb.toString()); } public void reportResultAccessibility(ExecutorResult result) { if (result.testResult == null || result.testResult.getAccessibilityStatus() == null) { - out_.print("Accessibility: N/A, Level: N/A, Version: N/A \n"); + emit("Accessibility: N/A, Level: N/A, Version: N/A \n"); } else { - out_.printf( - "Accessibility: [%s], Level: [%s], Version: [%s] \n", - result.testResult.getAccessibilityStatus().getStatus().toString(), - result.testResult.getAccessibilityStatus().getLevel().toString(), - result.testResult.getAccessibilityStatus().getVersion().toString() - ); + emit(String.format( + "Accessibility: [%s], Level: [%s], Version: [%s] \n", + result.testResult.getAccessibilityStatus().getStatus().toString(), + result.testResult.getAccessibilityStatus().getLevel().toString(), + result.testResult.getAccessibilityStatus().getVersion().toString() + )); } } - public void reportException(Throwable e) { - reportException(e, null); - } + public void reportException(Throwable e) { reportException(e, null); } public void reportException(Throwable e, String filename) { - printPrefix(); + StringBuilder sb = new StringBuilder(prefix()); if (filename != null && !filename.isEmpty()) - out_.printf("File: %s \n", filename); - + sb.append(String.format("File: %s \n", filename)); switch (e.getClass().getSimpleName()) { case "FileNotFoundException": - out_.print("The file was not found \n"); - break; + sb.append("The file was not found \n"); break; case "IOException": - out_.print("Error, Please check that the file is accessible, readable and not exclusively locked. "); - out_.printf("%s\n", e.getMessage()); - break; + sb.append("Error, Please check that the file is accessible, readable and not exclusively locked. "); + sb.append(String.format("%s\n", e.getMessage())); break; case "DocumentException": case "RendererException": - out_.printf("Unable to process document, %s \n", e.getMessage()); - break; + sb.append(String.format("Unable to process document, %s \n", e.getMessage())); break; case "UnsatisfiedLinkError": - out_.print("Error, Please make sure tesseract and ghostscript are installed and in path! "); - out_.printf("%s\n", e.getMessage()); - break; + sb.append("Error, Please make sure tesseract and ghostscript are installed and in path! "); + sb.append(String.format("%s\n", e.getMessage())); break; case "ExecutionException": - out_.printf("%s\n", e.getMessage()); - break; + sb.append(String.format("%s\n", e.getMessage())); break; default: - out_.printf("Unexpected error, %s, %s \n", e.getClass().getName(), e.getMessage()); - break; + sb.append(String.format("Unexpected error, %s, %s \n", e.getClass().getName(), e.getMessage())); break; } - if (debug_) { - e.printStackTrace(out_); + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + sb.append(sw.toString()); } + emit(sb.toString()); } public void printVersion(String cur_ver) { - out_.printf("ImageTester version %s \n", cur_ver); + emit(String.format("ImageTester version %s \n", cur_ver)); } public void printHelp(Options options) { @@ -138,19 +143,13 @@ public void printHelp(Options options) { } public void logPage(BufferedImage bim, String testname, Integer page) { - try { - logPage_(bim, testname, page); - } catch (IOException e) { - reportException(e); - } + try { logPage_(bim, testname, page); } catch (IOException e) { reportException(e); } } private void logPage_(BufferedImage bim, String testname, Integer page) throws IOException { if (!debug_) return; File debugOutFolder = new File(System.getProperty("user.dir"), "debug"); - if (!debugOutFolder.exists()) - debugOutFolder.mkdir(); - + if (!debugOutFolder.exists()) debugOutFolder.mkdir(); File pageImg = new File(debugOutFolder, String.format("%s_page_%s.png", testname, page)); ImageIO.write(bim, "png", pageImg); } diff --git a/src/main/java/com/applitools/imagetester/lib/MatchSizeResizer.java b/src/main/java/com/applitools/imagetester/lib/MatchSizeResizer.java new file mode 100644 index 0000000..80885c4 --- /dev/null +++ b/src/main/java/com/applitools/imagetester/lib/MatchSizeResizer.java @@ -0,0 +1,42 @@ +package com.applitools.imagetester.lib; + +import java.awt.Dimension; +import java.awt.image.BufferedImage; + +import org.apache.commons.lang3.StringUtils; +import org.imgscalr.Scalr; + +/** + * Applies the -ms (match size) option to an image: exact WxH when both dimensions + * are given (may distort), otherwise proportional scaling by the given dimension. + * Shared by the image-file and PDF-page test paths so both honor -ms identically. + */ +public final class MatchSizeResizer { + + private MatchSizeResizer() { + } + + public static BufferedImage resize(BufferedImage image, Config config) { + boolean hasWidth = StringUtils.isNotBlank(config.matchWidth); + boolean hasHeight = StringUtils.isNotBlank(config.matchHeight); + if (!hasWidth && !hasHeight) + return image; + + Dimension target = targetDimensions(image.getWidth(), image.getHeight(), config, hasWidth, hasHeight); + if (hasWidth && hasHeight) + return Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, Scalr.Mode.FIT_EXACT, target.width, target.height); + return Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, target.width, target.height); + } + + private static Dimension targetDimensions(int oldWidth, int oldHeight, Config config, + boolean hasWidth, boolean hasHeight) { + if (hasWidth && hasHeight) + return new Dimension(Integer.parseInt(config.matchWidth), Integer.parseInt(config.matchHeight)); + if (hasWidth) { + float ratio = Float.parseFloat(config.matchWidth) / oldWidth; + return new Dimension(Integer.parseInt(config.matchWidth), Math.round(oldHeight * ratio)); + } + float ratio = Float.parseFloat(config.matchHeight) / oldHeight; + return new Dimension(Math.round(oldWidth * ratio), Integer.parseInt(config.matchHeight)); + } +} diff --git a/src/main/java/com/applitools/imagetester/lib/PdfPageRenderer.java b/src/main/java/com/applitools/imagetester/lib/PdfPageRenderer.java index bf770f0..b210fe3 100644 --- a/src/main/java/com/applitools/imagetester/lib/PdfPageRenderer.java +++ b/src/main/java/com/applitools/imagetester/lib/PdfPageRenderer.java @@ -5,6 +5,7 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.rendering.PDFRenderer; /** @@ -24,9 +25,13 @@ public static BufferedImage render(PDPage originalPage, Config config) throws IOException { boolean removeWatermark = config.removeWatermarkText != null; boolean normalize = config.normalizeFont; + // Resolve against the original page: watermark removal must not erase the crop marks first. + PDRectangle trimCrop = PdfPageTrimmer.resolveCropBox(originalPage, config); if (!removeWatermark && !normalize) { - return fallback.renderImageWithDPI(zeroBasedPageIndex, config.DocumentConversionDPI); + if (trimCrop != null) originalPage.setCropBox(trimCrop); + return MatchSizeResizer.resize( + fallback.renderImageWithDPI(zeroBasedPageIndex, config.DocumentConversionDPI), config); } PDPage page = originalPage; @@ -36,9 +41,11 @@ public static BufferedImage render(PDPage originalPage, if (normalize) { page = PdfFontNormalizer.normalize(page); } + if (trimCrop != null) page.setCropBox(trimCrop); try (PDDocument tempDoc = new PDDocument()) { tempDoc.addPage(page); - return new PDFRenderer(tempDoc).renderImageWithDPI(0, config.DocumentConversionDPI); + return MatchSizeResizer.resize( + new PDFRenderer(tempDoc).renderImageWithDPI(0, config.DocumentConversionDPI), config); } } } diff --git a/src/main/java/com/applitools/imagetester/lib/PdfPageTrimmer.java b/src/main/java/com/applitools/imagetester/lib/PdfPageTrimmer.java new file mode 100644 index 0000000..89d755f --- /dev/null +++ b/src/main/java/com/applitools/imagetester/lib/PdfPageTrimmer.java @@ -0,0 +1,41 @@ +package com.applitools.imagetester.lib; + +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; + +/** + * Resolves the -pt (pdf trim) option to a crop box for a page. In auto mode the + * page's TrimBox metadata wins when it is genuinely smaller than the MediaBox; + * otherwise crop marks are detected. Manual WxH crops a centered box, clamped to + * the MediaBox. Returns null when no crop should be applied. + */ +public final class PdfPageTrimmer { + + private PdfPageTrimmer() { + } + + public static PDRectangle resolveCropBox(PDPage page, Config config) { + if (config.pdfTrim == null) return null; + if (Config.PDF_TRIM_AUTO.equals(config.pdfTrim)) return resolveAuto(page); + return centeredBox(page.getMediaBox(), Config.parsePdfTrimSize(config.pdfTrim)); + } + + private static PDRectangle resolveAuto(PDPage page) { + PDRectangle trimBox = page.getTrimBox(); + PDRectangle mediaBox = page.getMediaBox(); + if (isStrictlySmaller(trimBox, mediaBox)) return trimBox; + return CropMarkDetector.detect(page); + } + + private static boolean isStrictlySmaller(PDRectangle inner, PDRectangle outer) { + return inner.getWidth() < outer.getWidth() || inner.getHeight() < outer.getHeight(); + } + + private static PDRectangle centeredBox(PDRectangle media, float[] size) { + float width = Math.min(size[0], media.getWidth()); + float height = Math.min(size[1], media.getHeight()); + float x = media.getLowerLeftX() + (media.getWidth() - width) / 2; + float y = media.getLowerLeftY() + (media.getHeight() - height) / 2; + return new PDRectangle(x, y, width, height); + } +} diff --git a/src/main/java/com/applitools/imagetester/lib/RunConfig.java b/src/main/java/com/applitools/imagetester/lib/RunConfig.java new file mode 100644 index 0000000..2ae872b --- /dev/null +++ b/src/main/java/com/applitools/imagetester/lib/RunConfig.java @@ -0,0 +1,13 @@ +package com.applitools.imagetester.lib; + +public final class RunConfig { + public final Config config; + public final EyesFactory factory; + public final int threads; + + public RunConfig(Config config, EyesFactory factory, int threads) { + this.config = config; + this.factory = factory; + this.threads = threads; + } +} diff --git a/src/main/java/com/applitools/imagetester/lib/RunConfigFactory.java b/src/main/java/com/applitools/imagetester/lib/RunConfigFactory.java new file mode 100644 index 0000000..3e5550c --- /dev/null +++ b/src/main/java/com/applitools/imagetester/lib/RunConfigFactory.java @@ -0,0 +1,95 @@ +package com.applitools.imagetester.lib; + +import com.applitools.imagetester.Constants.ApplitoolsConstants; +import com.applitools.imagetester.ImageTester; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.ParseException; + +public final class RunConfigFactory { + + private RunConfigFactory() {} + + public static RunConfig from(CommandLine cmd, Logger logger) throws ParseException { + Config config = new Config(); + config.apiKey = cmd.getOptionValue("k", System.getenv(ApplitoolsConstants.APPLITOOLS_API_KEY)); + config.serverUrl = cmd.getOptionValue("s", System.getenv(ApplitoolsConstants.APPLITOOLS_SERVER_URL)); + + String[] proxySettings = cmd.getOptionValues("p"); + if (proxySettings == null) { + String proxyString = System.getenv(ApplitoolsConstants.APPLITOOLS_PROXY); + proxySettings = proxyString != null ? proxyString.split(",") : null; + } + config.setProxy(proxySettings); + + String[] accessibilityOptions = cmd.getOptionValues("ac"); + accessibilityOptions = cmd.hasOption("ac") && accessibilityOptions == null ? new String[0] : accessibilityOptions; + + EyesFactory factory = new EyesFactory(ImageTester.CUR_VER, logger) + .apiKey(config.apiKey) + .serverUrl(config.serverUrl) + .proxySettings(config.proxy_settings) + .matchLevel(cmd.getOptionValue("ml", null)) + .branch(cmd.getOptionValue("br", null)) + .parentBranch(cmd.getOptionValue("pb", null)) + .baselineEnvName(cmd.getOptionValue("bn", null)) + .baselineBranchName(cmd.getOptionValue("bb", null)) + .logFile(cmd.getOptionValue("lf", null)) + .hostOs(cmd.getOptionValue("os", null)) + .hostApp(cmd.getOptionValue("ap")) + .environmentName(cmd.getOptionValue("en")) + .saveFailedTests(cmd.hasOption("as")) + .ignoreDisplacement(cmd.hasOption("id")) + .saveNewTests(!cmd.hasOption("pt")) + .imageCut(cmd.getOptionValues("ic")) + .accSettings(accessibilityOptions) + .logHandler(cmd.hasOption("log")) + .deviceName(cmd.getOptionValue("dn", null)); + + config.splitSteps = cmd.hasOption("st"); + config.logger = logger; + config.appName = cmd.getOptionValue("a", "ImageTester"); + config.DocumentConversionDPI = Float.parseFloat(cmd.getOptionValue("di", "250")); + config.renderThreads = Integer.parseInt(cmd.getOptionValue("rt", String.valueOf(config.renderThreads))); + config.pdfPass = cmd.getOptionValue("pp", null); + config.pages = cmd.getOptionValue("sp", null); + config.includePageNumbers = cmd.hasOption("pn"); + config.forcedName = cmd.getOptionValue("fn", null); + config.sequenceName = cmd.getOptionValue("sq", null); + config.legacyFileOrder = cmd.hasOption("lo"); + config.dontCloseBatches = cmd.hasOption("dcb"); + config.shouldThrowException = cmd.hasOption("te"); + config.normalizeFont = cmd.hasOption("nf"); + config.removeWatermarkText = cmd.getOptionValue("rw"); + config.removeWatermarkOutDir = cmd.getOptionValue("rwo"); + config.regexFileNameFilter = cmd.getOptionValue("rf"); + config.setViewport(cmd.getOptionValue("vs", null)); + config.setMatchSize(cmd.getOptionValue("ms", null)); + config.setPdfTrim(cmd.hasOption("tp") ? cmd.getOptionValue("tp", Config.PDF_TRIM_AUTO) : null); + config.setBatchInfo(cmd.getOptionValue("fb", null), cmd.hasOption("nc")); + config.setIgnoreRegions(cmd.getOptionValue("ir", null)); + config.setContentRegions(cmd.getOptionValue("cr", null)); + config.setLayoutRegions(cmd.getOptionValue("lr", null)); + config.setAccessibilityIgnoreRegions(cmd.getOptionValue("ari", null)); + config.setAccessibilityRegularTextRegions(cmd.getOptionValue("arr", null)); + config.setAccessibilityLargeTextRegions(cmd.getOptionValue("arl", null)); + config.setAccessibilityBoldTextRegions(cmd.getOptionValue("arb", null)); + config.setAccessibilityGraphicsRegions(cmd.getOptionValue("arg", null)); + config.setCaptureRegion(cmd.getOptionValue("rc", null)); + config.setMatchTimeout(cmd.getOptionValue("mt", null)); + config.setProperties(cmd.getOptionValue("pr", null)); + + if (cmd.hasOption("arr") && config.accessibilityRegularTextRegions == null) + config.accessibilityRegularTextFullPage = true; + if (cmd.hasOption("arl") && config.accessibilityLargeTextRegions == null) + config.accessibilityLargeTextFullPage = true; + if (cmd.hasOption("arb") && config.accessibilityBoldTextRegions == null) + config.accessibilityBoldTextFullPage = true; + if (cmd.hasOption("arg") && config.accessibilityGraphicsRegions == null) + config.accessibilityGraphicsFullPage = true; + + config.eyesUtilsConf = new EyesUtilitiesConfig(cmd); + + int threads = Integer.parseInt(cmd.getOptionValue("th", ImageTester.DEFAULT_THREADS)); + return new RunConfig(config, factory, threads); + } +} diff --git a/src/main/java/com/applitools/imagetester/lib/TestExecutor.java b/src/main/java/com/applitools/imagetester/lib/TestExecutor.java index 57d33e7..db33de0 100644 --- a/src/main/java/com/applitools/imagetester/lib/TestExecutor.java +++ b/src/main/java/com/applitools/imagetester/lib/TestExecutor.java @@ -9,9 +9,10 @@ import org.apache.commons.lang3.StringUtils; -import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.*; +import java.util.function.Consumer; +import java.util.function.LongSupplier; public class TestExecutor { private static final long SHUTDOWN_TIMEOUT_MINUTES = 5; @@ -19,8 +20,25 @@ public class TestExecutor { private final Config config_; private ExecutorService executorService_; private ThreadLocal thEyes_; - private Queue> results_ = new LinkedList<>(); + // ConcurrentLinkedQueue: enqueue() runs on the suite thread, cancel() runs on the HTTP thread, + // join() runs on the run thread. Lock-free FIFO keeps add/poll/iterate safe across all three. + private final Queue results_ = new ConcurrentLinkedQueue<>(); + + private static final long HEARTBEAT_GRACE_NANOS = TimeUnit.SECONDS.toNanos(30); + private static final long HEARTBEAT_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(30); + private volatile LongSupplier nanoTimeSource_ = System::nanoTime; + + private static final class Pending { + final String name; + final Future future; + Pending(String name, Future future) { this.name = name; this.future = future; } + } + + void setNanoTimeSource(LongSupplier source) { this.nanoTimeSource_ = source; } private final boolean hasAccessibilityValidation_; + private volatile Consumer completionListener_ = null; + private volatile Consumer startedListener_ = null; + private volatile boolean cancelled_ = false; public TestExecutor(int threads, EyesFactory eyesFactory, Config conf) { this.executorService_ = Executors.newFixedThreadPool(threads); @@ -29,7 +47,39 @@ public TestExecutor(int threads, EyesFactory eyesFactory, Config conf) { this.hasAccessibilityValidation_ = eyesFactory.hasAccessibilityValidation(); } + public void setTestCompletionListener(Consumer listener) { + this.completionListener_ = listener; + } + + public void setTestStartedListener(Consumer listener) { + this.startedListener_ = listener; + } + + /** + * Soft-cancel: stops accepting new work, cancels not-yet-started futures, and frees join() + * to return immediately. Running workers are NOT interrupted — interrupting an Eyes-SDK call + * mid-flight corrupts the shared universal-core session and the *next* run gets + * "For input string: 'null'" when the core returns garbage for parsed fields. We let in-flight + * work finish in the background; the listeners are nulled so it can't leak ghost SSE events. + */ + public void cancel() { + cancelled_ = true; + startedListener_ = null; + completionListener_ = null; + // mayInterruptIfRunning=false: only pending tasks are cancelled; running ones complete. + for (Pending p : results_) p.future.cancel(false); + executorService_.shutdown(); + } + + public boolean isCancelled() { return cancelled_; } + public void enqueue(TestBase test, BatchInfo overrideBatch) { + if (cancelled_) return; + final String name = test.name(); + Consumer sl = startedListener_; + if (sl != null) { + try { sl.accept(name); } catch (Throwable ignored) { /* never let a listener disrupt enqueue */ } + } Future f = executorService_.submit(() -> { long startTime = System.nanoTime(); Eyes eyes = thEyes_.get(); @@ -51,10 +101,15 @@ public void enqueue(TestBase test, BatchInfo overrideBatch) { ((IDisposable) test).dispose(); long endTime = System.nanoTime(); - return new ExecutorResult(result, (endTime - startTime)); + ExecutorResult er = new ExecutorResult(result, (endTime - startTime)); + Consumer listener = completionListener_; + if (listener != null) { + try { listener.accept(er); } catch (Throwable ignored) { /* never let a listener disrupt the worker */ } + } + return er; }); - results_.add(f); + results_.add(new Pending(name, f)); } public void join() { @@ -63,26 +118,46 @@ public void join() { RuntimeException pendingThrow = null; while (!results_.isEmpty()) { + if (cancelled_) return; config_.logger.printProgress(curr++, total); + Pending head = results_.poll(); + if (head == null) break; + long headStartNanos = nanoTimeSource_.getAsLong(); + long lastBeatNanos = headStartNanos; ExecutorResult result = null; - try { - result = results_.remove().get(); - } catch (InterruptedException e) { - config_.logger.reportException(e); - Thread.currentThread().interrupt(); - break; - } catch (ExecutionException e) { - config_.logger.reportException(e); - if (config_.shouldThrowException) { - // Defer throw until in-flight workers drain. Interrupting them mid-RPC - // corrupts the shared universal-core session and breaks subsequent runs. - pendingThrow = new RuntimeException("Eyes has reported a mismatch or test failure. \n" + - "This exception is thrown because the '-te' flag was present, \n" + - "which instructs ImageTester to throw exceptions if a test fails, or a mismatch is detected"); + while (true) { + if (cancelled_) return; + try { + result = head.future.get(250, TimeUnit.MILLISECONDS); + break; + } catch (TimeoutException e) { + long now = nanoTimeSource_.getAsLong(); + if (now - headStartNanos >= HEARTBEAT_GRACE_NANOS + && now - lastBeatNanos >= HEARTBEAT_INTERVAL_NANOS) { + long elapsedSeconds = TimeUnit.NANOSECONDS.toSeconds(now - headStartNanos); + config_.logger.printHeartbeat(head.name, elapsedSeconds); + lastBeatNanos = now; + } + continue; + } catch (CancellationException e) { + break; + } catch (InterruptedException e) { + config_.logger.reportException(e); + Thread.currentThread().interrupt(); + break; + } catch (ExecutionException e) { + config_.logger.reportException(e); + if (config_.shouldThrowException) { + // Defer throw until in-flight workers drain. Interrupting them mid-RPC + // corrupts the shared universal-core session and breaks subsequent runs. + pendingThrow = new RuntimeException("Eyes has reported a mismatch or test failure. \n" + + "This exception is thrown because the '-te' flag was present, \n" + + "which instructs ImageTester to throw exceptions if a test fails, or a mismatch is detected"); + } break; } } - + if (pendingThrow != null) break; if (result != null) { config_.logger.reportResult(result); if (hasAccessibilityValidation_) { @@ -92,7 +167,7 @@ public void join() { } // Cancel only pending (not-yet-started) tasks; let running workers finish their RPCs. - for (Future f : results_) f.cancel(false); + for (Pending p : results_) p.future.cancel(false); results_.clear(); executorService_.shutdown(); diff --git a/src/test/java/com/applitools/imagetester/PdfTrimFlagTest.java b/src/test/java/com/applitools/imagetester/PdfTrimFlagTest.java new file mode 100644 index 0000000..ca59b22 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/PdfTrimFlagTest.java @@ -0,0 +1,45 @@ +package com.applitools.imagetester; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.Options; +import org.junit.Test; + +import com.applitools.imagetester.lib.Config; +import com.applitools.imagetester.lib.Logger; +import com.applitools.imagetester.lib.RunConfigFactory; + +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * The GUI checkbox sends a bare {@code -tp}, which must mean auto-detect; + * an explicit size remains available for files whose marks are rasterized. + */ +public class PdfTrimFlagTest { + + private static Config configFor(String... args) throws Exception { + Method getOptions = ImageTester.class.getDeclaredMethod("getOptions"); + getOptions.setAccessible(true); + Options options = (Options) getOptions.invoke(null); + CommandLine cmd = new DefaultParser().parse(options, args); + return RunConfigFactory.from(cmd, new Logger()).config; + } + + @Test + public void bareTpFlagEnablesAutoTrim() throws Exception { + assertEquals(Config.PDF_TRIM_AUTO, configFor("-tp").pdfTrim); + } + + @Test + public void tpWithSizeKeepsTheExplicitValue() throws Exception { + assertEquals("603x774", configFor("-tp", "603x774").pdfTrim); + } + + @Test + public void noTpFlagLeavesTrimOff() throws Exception { + assertNull(configFor().pdfTrim); + } +} diff --git a/src/test/java/com/applitools/imagetester/PromptNewTestsFlagTest.java b/src/test/java/com/applitools/imagetester/PromptNewTestsFlagTest.java new file mode 100644 index 0000000..33a239e --- /dev/null +++ b/src/test/java/com/applitools/imagetester/PromptNewTestsFlagTest.java @@ -0,0 +1,43 @@ +package com.applitools.imagetester; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.Options; +import org.junit.Test; + +import java.lang.reflect.Method; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Verifies which CLI flag controls saveNewTests. ImageTester wires this as + * {@code saveNewTests(!cmd.hasOption("pt"))}, so {@code -pt} is the flag that + * makes new tests require manual review. {@code -pn} is page numbers and must + * not affect it (regression guard for the v3.9.0 -pn -> -pt rename). + */ +public class PromptNewTestsFlagTest { + + private static boolean saveNewTestsFor(String... args) throws Exception { + Method getOptions = ImageTester.class.getDeclaredMethod("getOptions"); + getOptions.setAccessible(true); + Options options = (Options) getOptions.invoke(null); + CommandLine cmd = new DefaultParser().parse(options, args); + return !cmd.hasOption("pt"); + } + + @Test + public void shouldSaveNewTestsAutomaticallyWhenNoFlagGiven() throws Exception { + assertTrue(saveNewTestsFor()); + } + + @Test + public void shouldRequireReviewWhenPtFlagGiven() throws Exception { + assertFalse(saveNewTestsFor("-pt")); + } + + @Test + public void shouldStillSaveNewTestsAutomaticallyWhenPnFlagGiven() throws Exception { + assertTrue(saveNewTestsFor("-pn")); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/GuiServerBootIT.java b/src/test/java/com/applitools/imagetester/gui/GuiServerBootIT.java new file mode 100644 index 0000000..5fb1f9f --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/GuiServerBootIT.java @@ -0,0 +1,85 @@ +package com.applitools.imagetester.gui; + +import org.junit.After; +import org.junit.Test; + +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.*; + +public class GuiServerBootIT { + + private GuiServer server; + + @After + public void tearDown() throws Exception { + if (server != null) server.stop(); + } + + @Test + public void bootsAndServesStatus() throws Exception { + server = GuiServer.startForTest(); + String url = "http://localhost:" + server.port(); + String token = server.token().value(); + + HttpClient client = HttpClient.newHttpClient(); + HttpResponse resp = client.send( + HttpRequest.newBuilder(URI.create(url + "/api/status")) + .header("Authorization", "Bearer " + token) + .GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + assertTrue("expected idle in body, got: " + resp.body(), resp.body().contains("idle")); + } + + @Test + public void rejectsStatusWithoutToken() throws Exception { + server = GuiServer.startForTest(); + String url = "http://localhost:" + server.port(); + + HttpClient client = HttpClient.newHttpClient(); + HttpResponse resp = client.send( + HttpRequest.newBuilder(URI.create(url + "/api/status")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(401, resp.statusCode()); + } + + @Test + public void runWithInvalidOptionReturns400() throws Exception { + server = GuiServer.startForTest(); + String url = "http://127.0.0.1:" + server.port() + "/api/run"; + String body = "{\"sourcePath\":\".\",\"options\":{\"di\":\"not-a-number\"}}"; + int code = postJson(url, body, server.token().value()); + assertEquals(400, code); + } + + @Test + public void runWithRwoButNoRemoveTextReturns400() throws Exception { + server = GuiServer.startForTest(); + String url = "http://127.0.0.1:" + server.port() + "/api/run"; + String body = "{\"sourcePath\":\".\",\"options\":{\"rwo\":\"/tmp/out\"}}"; + int code = postJson(url, body, server.token().value()); + assertEquals(400, code); + } + + /** Posts a JSON body to the given URL with a Bearer token and returns the HTTP status code. */ + private static int postJson(String url, String body, String token) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Authorization", "Bearer " + token); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setDoOutput(true); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + try (OutputStream os = conn.getOutputStream()) { + os.write(bytes); + } + return conn.getResponseCode(); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/GuiTokenTest.java b/src/test/java/com/applitools/imagetester/gui/GuiTokenTest.java new file mode 100644 index 0000000..3d2b123 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/GuiTokenTest.java @@ -0,0 +1,38 @@ +package com.applitools.imagetester.gui; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class GuiTokenTest { + + @Test + public void generatedTokenIs43OrMoreUrlSafeChars() { + GuiToken t = GuiToken.generate(); + assertTrue(t.value().length() >= 43); + assertTrue(t.value().matches("[A-Za-z0-9_-]+")); + } + + @Test + public void twoGeneratedTokensAreUnequal() { + assertNotEquals(GuiToken.generate().value(), GuiToken.generate().value()); + } + + @Test + public void verifyAcceptsMatchingToken() { + GuiToken t = GuiToken.generate(); + assertTrue(t.verify(t.value())); + } + + @Test + public void verifyRejectsDifferentToken() { + GuiToken t = GuiToken.generate(); + assertFalse(t.verify("not-the-token")); + } + + @Test + public void verifyRejectsNullOrEmpty() { + GuiToken t = GuiToken.generate(); + assertFalse(t.verify(null)); + assertFalse(t.verify("")); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/LogRedactorTest.java b/src/test/java/com/applitools/imagetester/gui/LogRedactorTest.java new file mode 100644 index 0000000..e69c2f0 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/LogRedactorTest.java @@ -0,0 +1,43 @@ +package com.applitools.imagetester.gui; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class LogRedactorTest { + + @Test + public void redactsApiKeySubstring() { + String redacted = LogRedactor.redact("uploading with key sk_live_AbCdEf123456", "sk_live_AbCdEf123456"); + assertEquals("uploading with key ***", redacted); + } + + @Test + public void redactsApiKeyOccurringMultipleTimes() { + String redacted = LogRedactor.redact("key=sk_x sk_x again", "sk_x"); + assertEquals("key=*** *** again", redacted); + } + + @Test + public void redactsProxyUrlCredentials() { + String redacted = LogRedactor.redact("Using proxy https://alice:s3cret@proxy.local:8080", null); + assertEquals("Using proxy https://***:***@proxy.local:8080", redacted); + } + + @Test + public void redactsBothApiKeyAndProxyInSameLine() { + String redacted = LogRedactor.redact("key=sk_x via http://u:p@host", "sk_x"); + assertEquals("key=*** via http://***:***@host", redacted); + } + + @Test + public void returnsLineUnchangedWhenNoSecrets() { + String s = "ordinary log line"; + assertEquals(s, LogRedactor.redact(s, "sk_unused")); + } + + @Test + public void emptyOrNullApiKeyIsHarmless() { + assertEquals("hello", LogRedactor.redact("hello", null)); + assertEquals("hello", LogRedactor.redact("hello", "")); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/LoggerListenerTest.java b/src/test/java/com/applitools/imagetester/gui/LoggerListenerTest.java new file mode 100644 index 0000000..98e4f6c --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/LoggerListenerTest.java @@ -0,0 +1,90 @@ +package com.applitools.imagetester.gui; + +import com.applitools.imagetester.lib.Logger; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class LoggerListenerTest { + + @Test + public void listenerReceivesEveryMessageWrittenToStdout() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + Logger logger = new Logger(new PrintStream(sink), false); + + List received = new ArrayList<>(); + logger.addListener(received::add); + + logger.printMessage("hello"); + logger.printVersion("1.2.3"); + + assertEquals(2, received.size()); + assertEquals("hello", received.get(0)); + assertTrue(received.get(1).contains("ImageTester version 1.2.3")); + assertTrue("stdout still received output", sink.size() > 0); + } + + @Test + public void printProgressBypassesListenersButStillWritesToStdout() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + Logger logger = new Logger(new PrintStream(sink), false); + + List received = new ArrayList<>(); + logger.addListener(received::add); + + logger.printProgress(1, 2); + + assertTrue("printProgress should not reach GUI listeners", received.isEmpty()); + assertTrue("printProgress should still appear on stdout", sink.toString().contains("[1/2]")); + } + + @Test + public void removedListenerNoLongerReceivesMessages() { + Logger logger = new Logger(new PrintStream(new ByteArrayOutputStream()), false); + List received = new ArrayList<>(); + Consumer l = received::add; + + logger.addListener(l); + logger.printMessage("first"); + logger.removeListener(l); + logger.printMessage("second"); + + assertEquals(1, received.size()); + assertEquals("first", received.get(0)); + } + + @Test + public void concurrentLoggingAndListenerMutationDoesNotThrow() throws Exception { + Logger logger = new Logger(new PrintStream(new ByteArrayOutputStream()), false); + CountDownLatch start = new CountDownLatch(1); + + Thread producer = new Thread(() -> { + try { start.await(); } catch (InterruptedException ignored) {} + for (int i = 0; i < 1000; i++) logger.printMessage("msg-" + i); + }); + Thread mutator = new Thread(() -> { + try { start.await(); } catch (InterruptedException ignored) {} + for (int i = 0; i < 1000; i++) { + Consumer l = s -> { }; + logger.addListener(l); + logger.removeListener(l); + } + }); + + producer.start(); + mutator.start(); + start.countDown(); + producer.join(5_000); + mutator.join(5_000); + assertTrue(!producer.isAlive() && !mutator.isAlive()); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/RunControllerTest.java b/src/test/java/com/applitools/imagetester/gui/RunControllerTest.java new file mode 100644 index 0000000..9daddec --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/RunControllerTest.java @@ -0,0 +1,254 @@ +package com.applitools.imagetester.gui; + +import com.applitools.eyes.BatchInfo; +import com.applitools.eyes.TestResults; +import com.applitools.eyes.TestResultsStatus; +import com.applitools.eyes.images.Eyes; +import com.applitools.imagetester.lib.Config; +import com.applitools.imagetester.lib.EyesFactory; +import com.applitools.imagetester.lib.Logger; +import com.applitools.imagetester.lib.RunConfig; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileOutputStream; +import java.util.HashMap; +import java.util.concurrent.*; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class RunControllerTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void initialStatusIsIdle() { + RunController c = newController(passingBuilder()); + assertTrue(c.snapshot() instanceof RunState.Idle); + } + + @Test + public void rejectsRunWithMissingApiKey() throws Exception { + SecretsStore secrets = SecretsStore.inMemoryForTest(); // empty + RunController c = new RunController(secrets, new RunStream(), passingBuilder()); + File folder = tmp.newFolder("pix"); + makeTinyPng(folder); + assertThrows(RunController.MissingApiKeyException.class, + () -> c.start(req(folder, "Strict"))); + } + + @Test + public void rejectsRunWithInvalidSourcePath() { + RunController c = newController(passingBuilder()); + c.setSecretApiKey("sk_test"); + RunRequest bad = new RunRequest(); + bad.sourcePath = "/path/does/not/exist/anywhere"; + bad.options = new HashMap<>(); + assertThrows(SourcePathValidator.InvalidSourceException.class, + () -> c.start(bad)); + } + + @Test + public void happyPathReachesDoneState() throws Exception { + RunController c = newController(passingBuilder()); + c.setSecretApiKey("sk_test"); + File folder = tmp.newFolder("pix"); + makeTinyPng(folder); + + c.start(req(folder, "Strict")); + + long deadline = System.currentTimeMillis() + 10_000; + while (!(c.snapshot() instanceof RunState.Done) && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + assertTrue("did not reach Done within 10s", c.snapshot() instanceof RunState.Done); + } + + @Test + public void secondConcurrentStartReturns409() throws Exception { + // Use a builder that artificially blocks Eyes construction so the first run is in-flight when we issue the second. + RunController c = newController(slowBuilder(500)); + c.setSecretApiKey("sk_test"); + File folder = tmp.newFolder("pix"); + makeTinyPng(folder); + + ExecutorService es = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + // Both futures catch RunInProgressException — either one could be the loser of the race. + Future f1 = es.submit(() -> { + ready.countDown(); + ready.await(); + try { + return (Object) c.start(req(folder, "Strict")); + } catch (RunController.RunInProgressException ex) { + return ex; + } + }); + Future f2 = es.submit(() -> { + ready.countDown(); + ready.await(); + try { + return (Object) c.start(req(folder, "Strict")); + } catch (RunController.RunInProgressException ex) { + return ex; + } + }); + + Object r1 = f1.get(5, TimeUnit.SECONDS); + Object r2 = f2.get(5, TimeUnit.SECONDS); + + // Both results must be one of the two expected types — nothing else is allowed. + assertTrue("r1 unexpected: " + r1, + r1 instanceof RunController.StartResult || r1 instanceof RunController.RunInProgressException); + assertTrue("r2 unexpected: " + r2, + r2 instanceof RunController.StartResult || r2 instanceof RunController.RunInProgressException); + + boolean r1Ok = r1 instanceof RunController.StartResult; + boolean r2Ok = r2 instanceof RunController.StartResult; + + // At least one call must have started a run — both being rejected would mean nothing ran at all. + assertTrue("Both calls were rejected; expected at least one StartResult", r1Ok || r2Ok); + + // Valid outcomes: (Ok, Rejected), (Rejected, Ok), or (Ok, Ok) for the fast-completion edge case + // where the first run finished before the second call arrived. (Ok, Ok) is allowed but rare. + es.shutdownNow(); + } + + @Test + public void startEmitsRunStartedEventWithRunId() throws Exception { + RunStream stream = new RunStream(); + RunController c = new RunController(SecretsStore.inMemoryForTest(), stream, passingBuilder()); + c.setSecretApiKey("sk_test"); + File folder = tmp.newFolder("pix"); + makeTinyPng(folder); + + java.io.StringWriter sink = new java.io.StringWriter(); + CountDownLatch ready = new CountDownLatch(1); + stream.addClient(new java.io.PrintWriter(sink), () -> {}, ready); + ready.await(5, TimeUnit.SECONDS); + + RunController.StartResult result = c.start(req(folder, "Strict")); + + long deadline = System.currentTimeMillis() + 5_000; + while (!sink.toString().contains("\"type\":\"run-started\"") && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + String events = sink.toString(); + assertTrue("run-started not emitted; got: " + events, events.contains("\"type\":\"run-started\"")); + assertTrue("run-started missing runId; got: " + events, events.contains("\"runId\":\"" + result.runId + "\"")); + } + + @Test + public void runPassesGuiApiKeyToEyesFactory() throws Exception { + Eyes stubbedEyes = stubEyes(); + EyesFactory factory = mock(EyesFactory.class); + when(factory.build()).thenReturn(stubbedEyes); + RunController.RunConfigBuilder builder = (req, logger) -> { + Config config = new Config(); + config.logger = logger; + return new RunConfig(config, factory, 2); + }; + RunController c = newController(builder); + c.setSecretApiKey("sk_from_gui"); + File folder = tmp.newFolder("pix"); + makeTinyPng(folder); + + c.start(req(folder, "Strict")); + + long deadline = System.currentTimeMillis() + 10_000; + while (!(c.snapshot() instanceof RunState.Done) && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + verify(factory).apiKey("sk_from_gui"); + } + + @Test + public void dvOptionProducesUsableRunConfigViaProductionBuilder() { + RunRequest r = new RunRequest(); + r.sourcePath = "."; + r.options = new java.util.HashMap<>(); + r.options.put("k", "sk_test"); + r.options.put("dv", Boolean.TRUE); + com.applitools.imagetester.lib.RunConfig rc = RunController.buildRunConfig(r, new com.applitools.imagetester.lib.Logger()); + assertNotNull(rc.factory); + } + + // ---- helpers ---- + + private RunController newController(RunController.RunConfigBuilder builder) { + SecretsStore secrets = SecretsStore.inMemoryForTest(); + return new RunController(secrets, new RunStream(), builder); + } + + private RunRequest req(File folder, String matchLevel) { + RunRequest r = new RunRequest(); + r.sourcePath = folder.getAbsolutePath(); + r.options = new HashMap<>(); + r.options.put("ml", matchLevel); + return r; + } + + private RunController.RunConfigBuilder passingBuilder() { + // Pre-create mock on test thread so background-thread class loading doesn't race cold JVM startup. + // stubEyes() must be called BEFORE mock/when setup to avoid UnfinishedStubbing. + Eyes stubbedEyes = stubEyes(); + EyesFactory factory = mock(EyesFactory.class); + when(factory.build()).thenReturn(stubbedEyes); + return (req, logger) -> { + Config config = new Config(); + config.logger = logger; + return new RunConfig(config, factory, 2); + }; + } + + private RunController.RunConfigBuilder slowBuilder(long delayMs) { + // Pre-create mock on test thread so background-thread class loading doesn't race cold JVM startup. + EyesFactory factory = mock(EyesFactory.class); + when(factory.build()).thenAnswer(inv -> { + Thread.sleep(delayMs); + return stubEyes(); + }); + return (req, logger) -> { + Config config = new Config(); + config.logger = logger; + return new RunConfig(config, factory, 2); + }; + } + + private static Eyes stubEyes() { + Eyes eyes = mock(Eyes.class); + BatchInfo batch = new BatchInfo("t"); + when(eyes.getBatch()).thenReturn(batch); + // NOTE: setBatch returns Configuration (non-void) and abortIfNotClosed returns TestResults (non-void); + // Mockito returns null by default for non-void mocked methods, which is safe here. + when(eyes.getAccessibilityValidation()).thenReturn(null); + when(eyes.getIsOpen()).thenReturn(false); + + TestResults result = mock(TestResults.class); + when(result.getName()).thenReturn("a"); + when(result.isDifferent()).thenReturn(false); + when(result.getStatus()).thenReturn(TestResultsStatus.Passed); + when(result.getUrl()).thenReturn("https://applitools.com/dashboard/r/x"); + + when(eyes.close(false)).thenReturn(result); + when(eyes.close(true)).thenReturn(result); + when(eyes.close()).thenReturn(result); + when(eyes.abortIfNotClosed()).thenReturn(result); + + return eyes; + } + + private static File makeTinyPng(File folder) throws Exception { + File png = new File(folder, "a.png"); + BufferedImage img = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB); + try (FileOutputStream out = new FileOutputStream(png)) { + ImageIO.write(img, "png", out); + } + return png; + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/RunControllerWatermarkTest.java b/src/test/java/com/applitools/imagetester/gui/RunControllerWatermarkTest.java new file mode 100644 index 0000000..d5b2989 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/RunControllerWatermarkTest.java @@ -0,0 +1,62 @@ +package com.applitools.imagetester.gui; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import java.io.File; +import java.util.HashMap; +import static org.junit.Assert.*; + +public class RunControllerWatermarkTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + /** + * Asserts that rwauto=true without rwo routes through the Suite path (upload), not the + * standalone watermark-out path. The run must reach RunState.Done with outputDir == null + * (no WatermarkCleaned event, just a normal RunFinished). + */ + @Test + public void rwautoWithoutRwoReachesDoneWithNullOutputDir() throws Exception { + RunController c = new RunController(SecretsStore.inMemoryForTest(), new RunStream()); + c.setSecretApiKey("sk_test"); + File in = tmp.newFolder("pdfs"); // empty — no PDFs; PdfVectorWatermarkAutoMode no-ops + + RunRequest req = new RunRequest(); + req.sourcePath = in.getAbsolutePath(); + req.options = new HashMap<>(); + req.options.put("rwauto", Boolean.TRUE); + // NOTE: no "rwo" key — must take the auto-clean-then-upload path, not the watermark-out path + + c.start(req); + + long deadline = System.currentTimeMillis() + 10_000; + while (!(c.snapshot() instanceof RunState.Done) && System.currentTimeMillis() < deadline) + Thread.sleep(50); + assertTrue("run did not reach Done within 10s", c.snapshot() instanceof RunState.Done); + assertNull("outputDir must be null — rwauto without rwo must reach the normal Suite done, not WatermarkCleaned", + ((RunState.Done) c.snapshot()).outputDir); + } + + @Test + public void rwoModeReachesDoneWithOutputDir() throws Exception { + RunController c = new RunController(SecretsStore.inMemoryForTest(), new RunStream()); + c.setSecretApiKey("sk_test"); + File in = tmp.newFolder("pdfs"); + File out = tmp.newFolder("out"); + + RunRequest req = new RunRequest(); + req.sourcePath = in.getAbsolutePath(); + req.options = new HashMap<>(); + req.options.put("rw", "CONFIDENTIAL"); + req.options.put("rwo", out.getAbsolutePath()); + + c.start(req); + + long deadline = System.currentTimeMillis() + 5_000; + while (!(c.snapshot() instanceof RunState.Done) && System.currentTimeMillis() < deadline) + Thread.sleep(50); + assertTrue(c.snapshot() instanceof RunState.Done); + assertEquals(out.getAbsolutePath(), ((RunState.Done) c.snapshot()).outputDir); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/RunRequestParityTest.java b/src/test/java/com/applitools/imagetester/gui/RunRequestParityTest.java new file mode 100644 index 0000000..269d3ad --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/RunRequestParityTest.java @@ -0,0 +1,33 @@ +package com.applitools.imagetester.gui; + +import com.applitools.imagetester.ImageTester; +import com.applitools.imagetester.lib.Logger; +import com.applitools.imagetester.lib.RunConfig; +import com.applitools.imagetester.lib.RunConfigFactory; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.junit.Test; +import java.util.HashMap; +import static org.junit.Assert.*; + +public class RunRequestParityTest { + + private RunConfig fromArgs(String... args) throws Exception { + CommandLine cmd = new DefaultParser().parse(ImageTester.getOptions(), args); + return RunConfigFactory.from(cmd, new Logger()); + } + + @Test + public void guiPayloadMatchesCliForDpi() throws Exception { + RunConfig cli = fromArgs("-k", "key", "-f", ".", "-di", "400"); + + RunRequest req = new RunRequest(); + req.sourcePath = "."; + req.options = new HashMap<>(); + req.options.put("k", "key"); + req.options.put("di", "400"); + RunConfig gui = fromArgs(RunRequestTranslator.toArgv(req)); + + assertEquals(cli.config.DocumentConversionDPI, gui.config.DocumentConversionDPI, 0.001f); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/RunRequestTranslatorTest.java b/src/test/java/com/applitools/imagetester/gui/RunRequestTranslatorTest.java new file mode 100644 index 0000000..ccbfd9d --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/RunRequestTranslatorTest.java @@ -0,0 +1,67 @@ +package com.applitools.imagetester.gui; + +import org.junit.Test; +import java.util.*; +import static org.junit.Assert.*; + +public class RunRequestTranslatorTest { + + private List argv(RunRequest r) { + return Arrays.asList(RunRequestTranslator.toArgv(r)); + } + + @Test + public void emitsSourcePathAsFolderFlag() { + RunRequest r = new RunRequest(); + r.sourcePath = "/tmp/pix"; + r.options = new HashMap<>(); + List a = argv(r); + assertEquals("/tmp/pix", a.get(a.indexOf("-f") + 1)); + } + + @Test + public void emitsScalarOptionWithValue() { + RunRequest r = new RunRequest(); + r.sourcePath = "."; + r.options = new HashMap<>(); + r.options.put("di", "300"); + List a = argv(r); + assertEquals("300", a.get(a.indexOf("-di") + 1)); + } + + @Test + public void emitsBooleanFlagWithoutValue() { + RunRequest r = new RunRequest(); + r.sourcePath = "."; + r.options = new HashMap<>(); + r.options.put("nf", Boolean.TRUE); + assertTrue(argv(r).contains("-nf")); + } + + @Test + public void omitsFalseBooleanFlag() { + RunRequest r = new RunRequest(); + r.sourcePath = "."; + r.options = new HashMap<>(); + r.options.put("nf", Boolean.FALSE); + assertFalse(argv(r).contains("-nf")); + } + + @Test + public void neverEmitsThrowExceptionsFlag() { + RunRequest r = new RunRequest(); + r.sourcePath = "."; + r.options = new HashMap<>(); + r.options.put("te", Boolean.TRUE); + assertFalse(argv(r).contains("-te")); + } + + @Test + public void neverEmitsBatchMapperFlag() { + RunRequest r = new RunRequest(); + r.sourcePath = "."; + r.options = new HashMap<>(); + r.options.put("mp", "/some.csv"); + assertFalse(argv(r).contains("-mp")); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/RunStreamTest.java b/src/test/java/com/applitools/imagetester/gui/RunStreamTest.java new file mode 100644 index 0000000..f347d74 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/RunStreamTest.java @@ -0,0 +1,90 @@ +package com.applitools.imagetester.gui; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintWriter; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.*; + +public class RunStreamTest { + + @Test + public void connectedClientReceivesEventsInOrder() throws Exception { + RunStream stream = new RunStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + PrintWriter pw = new PrintWriter(baos, true); + CountDownLatch ready = new CountDownLatch(1); + stream.addClient(pw, () -> {}, ready); + assertTrue(ready.await(1, TimeUnit.SECONDS)); + + stream.emit(new SseEvent.TestStarted("a.png")); + stream.emit(new SseEvent.LogLine("hello")); + stream.emit(new SseEvent.TestFinished("a.png", "pass", 42, null)); + + // give drainer time to flush + Thread.sleep(300); + stream.close(); + + String out = baos.toString(); + assertTrue("test-started missing: " + out, out.contains("\"type\":\"test-started\"")); + assertTrue("log-line text missing: " + out, out.contains("\"text\":\"hello\"")); + assertTrue("test-finished missing: " + out, out.contains("\"type\":\"test-finished\"")); + assertTrue("test-started appears before test-finished", + out.indexOf("test-started") < out.indexOf("test-finished")); + } + + @Test + public void slowClientDropsLogLinesButNeverLifecycleEvents() throws Exception { + RunStream stream = new RunStream(/*queueCapacity*/ 4); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final Object lock = new Object(); + PrintWriter slow = new PrintWriter(baos) { + @Override public void flush() { + synchronized (lock) { + try { lock.wait(); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } + } + super.flush(); + } + }; + CountDownLatch ready = new CountDownLatch(1); + stream.addClient(slow, () -> {}, ready); + assertTrue(ready.await(1, TimeUnit.SECONDS)); + + for (int i = 0; i < 100; i++) stream.emit(new SseEvent.LogLine("L" + i)); + stream.emit(new SseEvent.TestFinished("a", "pass", 1, null)); + + assertTrue("at least one log-line was dropped", stream.droppedLogLineCount() > 0); + synchronized (lock) { lock.notifyAll(); } + Thread.sleep(50); + stream.close(); + } + + @Test + public void disconnectedClientStopsDrainerWithoutAffectingOtherClients() throws Exception { + RunStream stream = new RunStream(); + ByteArrayOutputStream good = new ByteArrayOutputStream(); + PrintWriter goodPw = new PrintWriter(good, true); + + PrintWriter failingPw = new PrintWriter(new ByteArrayOutputStream()) { + @Override public void write(String s) { throw new RuntimeException("disconnected"); } + @Override public void println(String s) { throw new RuntimeException("disconnected"); } + }; + + CountDownLatch r1 = new CountDownLatch(1); + CountDownLatch r2 = new CountDownLatch(1); + stream.addClient(goodPw, () -> {}, r1); + stream.addClient(failingPw, () -> {}, r2); + r1.await(1, TimeUnit.SECONDS); + r2.await(1, TimeUnit.SECONDS); + + stream.emit(new SseEvent.TestStarted("a")); + Thread.sleep(300); + + assertTrue(good.toString().contains("test-started")); + assertEquals(1, stream.activeClientCount()); + stream.close(); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/SecretsStoreTest.java b/src/test/java/com/applitools/imagetester/gui/SecretsStoreTest.java new file mode 100644 index 0000000..c3912b3 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/SecretsStoreTest.java @@ -0,0 +1,26 @@ +package com.applitools.imagetester.gui; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class SecretsStoreTest { + + @Test + public void roundTripPutGetDelete() { + SecretsStore store = SecretsStore.inMemoryForTest(); + assertFalse(store.hasApiKey()); + store.setApiKey("sk_live_xyz"); + assertTrue(store.hasApiKey()); + assertEquals("sk_live_xyz", store.getApiKey()); + store.deleteApiKey(); + assertFalse(store.hasApiKey()); + } + + @Test + public void setOverwrites() { + SecretsStore store = SecretsStore.inMemoryForTest(); + store.setApiKey("a"); + store.setApiKey("b"); + assertEquals("b", store.getApiKey()); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/ShadeServicesSmokeIT.java b/src/test/java/com/applitools/imagetester/gui/ShadeServicesSmokeIT.java new file mode 100644 index 0000000..fef4a34 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/ShadeServicesSmokeIT.java @@ -0,0 +1,30 @@ +package com.applitools.imagetester.gui; + +import org.junit.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertTrue; + +public class ShadeServicesSmokeIT { + + @Test + public void shadedJarStartsCliWithoutClassLoaderErrors() throws Exception { + String jar = System.getProperty("imagetester.jar", "jars/ImageTester_3.10.0.jar"); + Process p = new ProcessBuilder("java", "-jar", jar).redirectErrorStream(true).start(); + List lines = new ArrayList<>(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) lines.add(line); + } + p.waitFor(); + String combined = String.join("\n", lines); + assertTrue("CLI did not print version line:\n" + combined, + combined.contains("ImageTester version")); + assertTrue("CLI surfaced ClassNotFoundException / NoSuchProviderException:\n" + combined, + !combined.contains("NoSuchProviderException") && !combined.contains("Provider not found")); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/SourcePathValidatorTest.java b/src/test/java/com/applitools/imagetester/gui/SourcePathValidatorTest.java new file mode 100644 index 0000000..41733dc --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/SourcePathValidatorTest.java @@ -0,0 +1,52 @@ +package com.applitools.imagetester.gui; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class SourcePathValidatorTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void acceptsExistingReadableFile() throws Exception { + File f = tmp.newFile("ok.png"); + Path resolved = SourcePathValidator.validate(f.getAbsolutePath()); + assertEquals(f.toPath().toRealPath(), resolved); + } + + @Test + public void acceptsExistingReadableFolder() throws Exception { + File d = tmp.newFolder("ok"); + Path resolved = SourcePathValidator.validate(d.getAbsolutePath()); + assertEquals(d.toPath().toRealPath(), resolved); + } + + @Test + public void rejectsMissingPath() { + File missing = new File(tmp.getRoot(), "does-not-exist"); + assertThrows(SourcePathValidator.InvalidSourceException.class, + () -> SourcePathValidator.validate(missing.getAbsolutePath())); + } + + @Test + public void rejectsBlankPath() { + assertThrows(SourcePathValidator.InvalidSourceException.class, + () -> SourcePathValidator.validate("")); + assertThrows(SourcePathValidator.InvalidSourceException.class, + () -> SourcePathValidator.validate(null)); + } + + @Test + public void resolvesAndAcceptsRelativePath() throws Exception { + File f = tmp.newFile("rel.png"); + Path resolved = SourcePathValidator.validate(f.getPath()); + assertEquals(f.toPath().toRealPath(), resolved); + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/TestExecutorListenerTest.java b/src/test/java/com/applitools/imagetester/gui/TestExecutorListenerTest.java new file mode 100644 index 0000000..af28a29 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/TestExecutorListenerTest.java @@ -0,0 +1,169 @@ +package com.applitools.imagetester.gui; + +import com.applitools.eyes.TestResults; +import com.applitools.eyes.TestResultsStatus; +import com.applitools.imagetester.TestObjects.TestBase; +import com.applitools.imagetester.lib.Config; +import com.applitools.imagetester.lib.EyesFactory; +import com.applitools.imagetester.lib.ExecutorResult; +import com.applitools.imagetester.lib.TestExecutor; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TestExecutorListenerTest { + + @Test + public void listenerFiresOncePerEnqueuedTest() throws Exception { + Config config = new Config(); + config.shouldThrowException = false; + + EyesFactory factory = mock(EyesFactory.class); + com.applitools.eyes.images.Eyes eyes = mock(com.applitools.eyes.images.Eyes.class); + when(factory.build()).thenReturn(eyes); + when(eyes.getBatch()).thenReturn(new com.applitools.eyes.BatchInfo("t")); + + TestBase t1 = mock(TestBase.class); + TestBase t2 = mock(TestBase.class); + TestResults r1 = mock(TestResults.class); + TestResults r2 = mock(TestResults.class); + when(r1.getStatus()).thenReturn(TestResultsStatus.Passed); + when(r2.getStatus()).thenReturn(TestResultsStatus.Passed); + when(t1.runSafe(any())).thenReturn(r1); + when(t2.runSafe(any())).thenReturn(r2); + + TestExecutor executor = new TestExecutor(1, factory, config); + List received = new ArrayList<>(); + CountDownLatch latch = new CountDownLatch(2); + executor.setTestCompletionListener(result -> { received.add(result); latch.countDown(); }); + + executor.enqueue(t1, null); + executor.enqueue(t2, null); + executor.join(); + + assertTrue("listener did not fire for both tests", latch.await(5, TimeUnit.SECONDS)); + assertEquals(2, received.size()); + } + + @Test + public void startedListenerFiresSynchronouslyBeforeWorkerRuns() throws Exception { + Config config = new Config(); + config.shouldThrowException = false; + + EyesFactory factory = mock(EyesFactory.class); + com.applitools.eyes.images.Eyes eyes = mock(com.applitools.eyes.images.Eyes.class); + when(factory.build()).thenReturn(eyes); + when(eyes.getBatch()).thenReturn(new com.applitools.eyes.BatchInfo("t")); + + CountDownLatch gate = new CountDownLatch(1); + TestBase t = mock(TestBase.class); + TestResults r = mock(TestResults.class); + when(r.getStatus()).thenReturn(TestResultsStatus.Passed); + when(t.name()).thenReturn("foo.png"); + // Worker blocks until we release the gate; if started-listener didn't fire pre-submit, the assertion below would fail. + when(t.runSafe(any())).thenAnswer(inv -> { gate.await(); return r; }); + + TestExecutor executor = new TestExecutor(1, factory, config); + List started = new ArrayList<>(); + executor.setTestStartedListener(started::add); + + executor.enqueue(t, null); + assertEquals(1, started.size()); + assertEquals("foo.png", started.get(0)); + + gate.countDown(); + executor.join(); + } + + @Test + public void startedListenerThatThrowsDoesNotPreventEnqueue() throws Exception { + Config config = new Config(); + config.shouldThrowException = false; + + EyesFactory factory = mock(EyesFactory.class); + com.applitools.eyes.images.Eyes eyes = mock(com.applitools.eyes.images.Eyes.class); + when(factory.build()).thenReturn(eyes); + when(eyes.getBatch()).thenReturn(new com.applitools.eyes.BatchInfo("t")); + + TestBase t = mock(TestBase.class); + TestResults r = mock(TestResults.class); + when(r.getStatus()).thenReturn(TestResultsStatus.Passed); + when(t.name()).thenReturn("foo.png"); + when(t.runSafe(any())).thenReturn(r); + + TestExecutor executor = new TestExecutor(1, factory, config); + executor.setTestStartedListener(name -> { throw new RuntimeException("boom"); }); + executor.enqueue(t, null); + executor.join(); + } + + @Test + public void cancelReturnsJoinEvenWhileWorkerIsStillRunning() throws Exception { + Config config = new Config(); + config.shouldThrowException = false; + + EyesFactory factory = mock(EyesFactory.class); + com.applitools.eyes.images.Eyes eyes = mock(com.applitools.eyes.images.Eyes.class); + when(factory.build()).thenReturn(eyes); + when(eyes.getBatch()).thenReturn(new com.applitools.eyes.BatchInfo("t")); + + CountDownLatch workerEntered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + TestBase t = mock(TestBase.class); + TestResults r = mock(TestResults.class); + when(r.getStatus()).thenReturn(TestResultsStatus.Passed); + when(t.name()).thenReturn("hangs.png"); + // Soft-cancel must NOT interrupt this worker; it should keep running until we release it. + when(t.runSafe(any())).thenAnswer(inv -> { + workerEntered.countDown(); + release.await(); + return r; + }); + + TestExecutor executor = new TestExecutor(1, factory, config); + executor.enqueue(t, null); + + Thread joiner = new Thread(executor::join, "joiner"); + joiner.setDaemon(true); + joiner.start(); + + assertTrue("worker never started", workerEntered.await(5, TimeUnit.SECONDS)); + executor.cancel(); + joiner.join(5_000); + assertTrue("join did not return after cancel", !joiner.isAlive()); + assertTrue(executor.isCancelled()); + + release.countDown(); // let the background worker finish naturally so the test thread doesn't leak it + } + + @Test + public void listenerThatThrowsDoesNotKillTheWorker() throws Exception { + Config config = new Config(); + config.shouldThrowException = false; + + EyesFactory factory = mock(EyesFactory.class); + com.applitools.eyes.images.Eyes eyes = mock(com.applitools.eyes.images.Eyes.class); + when(factory.build()).thenReturn(eyes); + when(eyes.getBatch()).thenReturn(new com.applitools.eyes.BatchInfo("t")); + + TestBase t = mock(TestBase.class); + TestResults r = mock(TestResults.class); + when(r.getStatus()).thenReturn(TestResultsStatus.Passed); + when(t.runSafe(any())).thenReturn(r); + + TestExecutor executor = new TestExecutor(1, factory, config); + executor.setTestCompletionListener(er -> { throw new RuntimeException("boom"); }); + executor.enqueue(t, null); + executor.join(); + // If we got here, the throwing listener was caught and the run completed. + } +} diff --git a/src/test/java/com/applitools/imagetester/gui/TokenAuthFilterTest.java b/src/test/java/com/applitools/imagetester/gui/TokenAuthFilterTest.java new file mode 100644 index 0000000..faf7468 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/gui/TokenAuthFilterTest.java @@ -0,0 +1,92 @@ +package com.applitools.imagetester.gui; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.Before; +import org.junit.Test; + +import static org.mockito.Mockito.*; + +public class TokenAuthFilterTest { + + private GuiToken token; + private TokenAuthFilter filter; + private HttpServletRequest req; + private HttpServletResponse resp; + private FilterChain chain; + private int port; + + @Before + public void setUp() { + token = GuiToken.generate(); + port = 12345; + filter = new TokenAuthFilter(token, port); + req = mock(HttpServletRequest.class); + resp = mock(HttpServletResponse.class); + chain = mock(FilterChain.class); + when(req.getServerPort()).thenReturn(port); + when(req.getHeader("Host")).thenReturn("localhost:" + port); + } + + @Test + public void rejectsApiRequestMissingToken() throws Exception { + when(req.getRequestURI()).thenReturn("/api/status"); + when(req.getHeader("Authorization")).thenReturn(null); + filter.doFilter(req, resp, chain); + verify(resp).setStatus(401); + verify(chain, never()).doFilter(req, resp); + } + + @Test + public void rejectsApiRequestWithWrongToken() throws Exception { + when(req.getRequestURI()).thenReturn("/api/status"); + when(req.getHeader("Authorization")).thenReturn("Bearer not-the-token"); + filter.doFilter(req, resp, chain); + verify(resp).setStatus(401); + verify(chain, never()).doFilter(req, resp); + } + + @Test + public void acceptsApiRequestWithCorrectToken() throws Exception { + when(req.getRequestURI()).thenReturn("/api/status"); + when(req.getHeader("Authorization")).thenReturn("Bearer " + token.value()); + filter.doFilter(req, resp, chain); + verify(chain).doFilter(req, resp); + } + + @Test + public void rejectsApiRequestWithWrongHost() throws Exception { + when(req.getRequestURI()).thenReturn("/api/status"); + when(req.getHeader("Authorization")).thenReturn("Bearer " + token.value()); + when(req.getHeader("Host")).thenReturn("evil.example.com:" + port); + filter.doFilter(req, resp, chain); + verify(resp).setStatus(403); + verify(chain, never()).doFilter(req, resp); + } + + @Test + public void rejectsApiRequestWithWrongOrigin() throws Exception { + when(req.getRequestURI()).thenReturn("/api/status"); + when(req.getHeader("Authorization")).thenReturn("Bearer " + token.value()); + when(req.getHeader("Origin")).thenReturn("http://evil.example.com"); + filter.doFilter(req, resp, chain); + verify(resp).setStatus(403); + verify(chain, never()).doFilter(req, resp); + } + + @Test + public void allowsNonApiPathsWithoutToken() throws Exception { + when(req.getRequestURI()).thenReturn("/index.html"); + filter.doFilter(req, resp, chain); + verify(chain).doFilter(req, resp); + } + + @Test + public void sseEndpointAcceptsTokenInQuery() throws Exception { + when(req.getRequestURI()).thenReturn("/api/events"); + when(req.getParameter("token")).thenReturn(token.value()); + filter.doFilter(req, resp, chain); + verify(chain).doFilter(req, resp); + } +} diff --git a/src/test/java/com/applitools/imagetester/lib/ConfigTest.java b/src/test/java/com/applitools/imagetester/lib/ConfigTest.java index cc39425..a760629 100644 --- a/src/test/java/com/applitools/imagetester/lib/ConfigTest.java +++ b/src/test/java/com/applitools/imagetester/lib/ConfigTest.java @@ -227,6 +227,34 @@ public void setBatchInfo_setsNotifyOnComplete() { assertTrue(config.notifyOnComplete); } + @Test + public void setPdfTrim_auto() { + config.setPdfTrim("auto"); + assertEquals("auto", config.pdfTrim); + } + + @Test + public void setPdfTrim_dimensions() { + config.setPdfTrim("603x774"); + assertEquals("603x774", config.pdfTrim); + } + + @Test + public void setPdfTrim_null_isNoOp() { + config.setPdfTrim(null); + assertNull(config.pdfTrim); + } + + @Test(expected = RuntimeException.class) + public void setPdfTrim_malformed_throws() { + config.setPdfTrim("abc"); + } + + @Test(expected = RuntimeException.class) + public void setPdfTrim_nonPositiveDimension_throws() { + config.setPdfTrim("0x774"); + } + @Test public void setMatchTimeout_storesValue() { config.setMatchTimeout("2000"); diff --git a/src/test/java/com/applitools/imagetester/lib/CropMarkDetectorTest.java b/src/test/java/com/applitools/imagetester/lib/CropMarkDetectorTest.java new file mode 100644 index 0000000..136669c --- /dev/null +++ b/src/test/java/com/applitools/imagetester/lib/CropMarkDetectorTest.java @@ -0,0 +1,162 @@ +package com.applitools.imagetester.lib; + +import static org.junit.Assert.*; + +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.junit.Test; + +public class CropMarkDetectorTest { + + // Geometry mirrors the black_card print files: 673.2x846 page, 603x774 trim, centered. + private static final float PAGE_WIDTH = 673.2f; + private static final float PAGE_HEIGHT = 846f; + private static final float TRIM_LEFT = 35.1f; + private static final float TRIM_BOTTOM = 36f; + private static final float TRIM_RIGHT = TRIM_LEFT + 603f; + private static final float TRIM_TOP = TRIM_BOTTOM + 774f; + private static final float MARK_LENGTH = 18f; + private static final float MARK_GAP = 4f; + private static final float TOLERANCE = 0.5f; + + @Test + public void detect_findsTrimBoxFromCropMarks() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = newPage(doc); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + drawCropMarks(cs); + } + + PDRectangle box = CropMarkDetector.detect(page); + + assertTrimBox(box); + } + } + + @Test + public void detect_returnsNullWhenPageHasNoMarks() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = newPage(doc); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(PDType1Font.HELVETICA, 12f); + cs.newLineAtOffset(100, 400); + cs.showText("No printer marks here"); + cs.endText(); + } + + assertNull(CropMarkDetector.detect(page)); + } + } + + @Test + public void detect_ignoresLongRulesAndContentStrokes() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = newPage(doc); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + drawCropMarks(cs); + // long separator rule inside the artwork + drawLine(cs, 100, 400, 500, 400); + // short underline inside the artwork (mark-like length, inside trim) + drawLine(cs, 200, 300, 220, 300); + } + + PDRectangle box = CropMarkDetector.detect(page); + + assertTrimBox(box); + } + } + + @Test + public void detect_prefersTrimMarksOverOuterBleedMarks() throws IOException { + // Real print files draw crop marks on the trim lines AND bleed marks further out, + // and the trim box is not necessarily centered (duplex front/back offset). + float left = 36f, right = 639f, bottom = 36f, top = 810f, bleed = 9f; + try (PDDocument doc = new PDDocument()) { + PDPage page = newPage(doc); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + drawMarkSet(cs, left, right, bottom, top, bleed); + drawMarkSet(cs, left - bleed, right + bleed, bottom - bleed, top + bleed, 3f); + } + + PDRectangle box = CropMarkDetector.detect(page); + + assertNotNull("Expected a detected trim box", box); + assertEquals("Left edge", left, box.getLowerLeftX(), TOLERANCE); + assertEquals("Bottom edge", bottom, box.getLowerLeftY(), TOLERANCE); + assertEquals("Width", right - left, box.getWidth(), TOLERANCE); + assertEquals("Height", top - bottom, box.getHeight(), TOLERANCE); + } + } + + @Test + public void detect_returnsNullWhenOneEdgeHasNoMarks() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = newPage(doc); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + drawCropMarksMissingRightEdge(cs); + } + + assertNull(CropMarkDetector.detect(page)); + } + } + + private PDPage newPage(PDDocument doc) { + PDPage page = new PDPage(new PDRectangle(PAGE_WIDTH, PAGE_HEIGHT)); + doc.addPage(page); + return page; + } + + /** Standard 8-mark layout: one vertical + one horizontal hairline per corner, on the trim lines. */ + private void drawCropMarks(PDPageContentStream cs) throws IOException { + drawCropMarksMissingRightEdge(cs); + // right-edge vertical marks + drawLine(cs, TRIM_RIGHT, TRIM_TOP + MARK_GAP, TRIM_RIGHT, TRIM_TOP + MARK_GAP + MARK_LENGTH); + drawLine(cs, TRIM_RIGHT, TRIM_BOTTOM - MARK_GAP, TRIM_RIGHT, TRIM_BOTTOM - MARK_GAP - MARK_LENGTH); + } + + private void drawCropMarksMissingRightEdge(PDPageContentStream cs) throws IOException { + // left-edge vertical marks + drawLine(cs, TRIM_LEFT, TRIM_TOP + MARK_GAP, TRIM_LEFT, TRIM_TOP + MARK_GAP + MARK_LENGTH); + drawLine(cs, TRIM_LEFT, TRIM_BOTTOM - MARK_GAP, TRIM_LEFT, TRIM_BOTTOM - MARK_GAP - MARK_LENGTH); + // top-edge horizontal marks + drawLine(cs, TRIM_LEFT - MARK_GAP, TRIM_TOP, TRIM_LEFT - MARK_GAP - MARK_LENGTH, TRIM_TOP); + drawLine(cs, TRIM_RIGHT + MARK_GAP, TRIM_TOP, TRIM_RIGHT + MARK_GAP + MARK_LENGTH, TRIM_TOP); + // bottom-edge horizontal marks + drawLine(cs, TRIM_LEFT - MARK_GAP, TRIM_BOTTOM, TRIM_LEFT - MARK_GAP - MARK_LENGTH, TRIM_BOTTOM); + drawLine(cs, TRIM_RIGHT + MARK_GAP, TRIM_BOTTOM, TRIM_RIGHT + MARK_GAP + MARK_LENGTH, TRIM_BOTTOM); + } + + /** One full set of 8 marks whose lines sit on the given edges, offset outward by the gap. */ + private void drawMarkSet(PDPageContentStream cs, float left, float right, float bottom, float top, + float gap) throws IOException { + for (float x : new float[] { left, right }) { + drawLine(cs, x, top + gap, x, top + gap + MARK_LENGTH); + drawLine(cs, x, bottom - gap, x, bottom - gap - MARK_LENGTH); + } + for (float y : new float[] { bottom, top }) { + drawLine(cs, left - gap, y, left - gap - MARK_LENGTH, y); + drawLine(cs, right + gap, y, right + gap + MARK_LENGTH, y); + } + } + + private void drawLine(PDPageContentStream cs, float x1, float y1, float x2, float y2) throws IOException { + cs.setLineWidth(0.3f); + cs.moveTo(x1, y1); + cs.lineTo(x2, y2); + cs.stroke(); + } + + private void assertTrimBox(PDRectangle box) { + assertNotNull("Expected a detected trim box", box); + assertEquals("Left edge", TRIM_LEFT, box.getLowerLeftX(), TOLERANCE); + assertEquals("Bottom edge", TRIM_BOTTOM, box.getLowerLeftY(), TOLERANCE); + assertEquals("Width", 603f, box.getWidth(), TOLERANCE); + assertEquals("Height", 774f, box.getHeight(), TOLERANCE); + } +} diff --git a/src/test/java/com/applitools/imagetester/lib/LoggerHeartbeatTest.java b/src/test/java/com/applitools/imagetester/lib/LoggerHeartbeatTest.java new file mode 100644 index 0000000..373c43c --- /dev/null +++ b/src/test/java/com/applitools/imagetester/lib/LoggerHeartbeatTest.java @@ -0,0 +1,22 @@ +package com.applitools.imagetester.lib; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class LoggerHeartbeatTest { + + @Test + public void heartbeatLineContainsNameAndElapsedSeconds() { + Logger logger = new Logger(); + List captured = new ArrayList<>(); + logger.addListener(captured::add); + + logger.printHeartbeat("lorem_20.pdf", 60); + + assertEquals("Still running... lorem_20.pdf - 60s elapsed \n", captured.get(0)); + } +} diff --git a/src/test/java/com/applitools/imagetester/lib/MatchSizeResizerTest.java b/src/test/java/com/applitools/imagetester/lib/MatchSizeResizerTest.java new file mode 100644 index 0000000..fc0c51c --- /dev/null +++ b/src/test/java/com/applitools/imagetester/lib/MatchSizeResizerTest.java @@ -0,0 +1,50 @@ +package com.applitools.imagetester.lib; + +import static org.junit.Assert.*; + +import java.awt.image.BufferedImage; + +import org.junit.Test; + +public class MatchSizeResizerTest { + + private static final int SOURCE_WIDTH = 200; + private static final int SOURCE_HEIGHT = 100; + + @Test + public void resize_toExactSizeWhenBothDimensionsGiven() { + BufferedImage result = MatchSizeResizer.resize(sourceImage(), configWithMatchSize("50x80")); + assertEquals("Width mismatch", 50, result.getWidth()); + assertEquals("Height mismatch", 80, result.getHeight()); + } + + @Test + public void resize_scalesByWidthPreservingRatioWhenOnlyWidthGiven() { + BufferedImage result = MatchSizeResizer.resize(sourceImage(), configWithMatchSize("100x")); + assertEquals("Width mismatch", 100, result.getWidth()); + assertEquals("Height mismatch", 50, result.getHeight()); + } + + @Test + public void resize_scalesByHeightPreservingRatioWhenOnlyHeightGiven() { + BufferedImage result = MatchSizeResizer.resize(sourceImage(), configWithMatchSize("x50")); + assertEquals("Width mismatch", 100, result.getWidth()); + assertEquals("Height mismatch", 50, result.getHeight()); + } + + @Test + public void resize_returnsSameImageWhenMatchSizeNotConfigured() { + BufferedImage source = sourceImage(); + assertSame(source, MatchSizeResizer.resize(source, new Config())); + } + + private BufferedImage sourceImage() { + return new BufferedImage(SOURCE_WIDTH, SOURCE_HEIGHT, BufferedImage.TYPE_INT_RGB); + } + + private Config configWithMatchSize(String size) { + Config config = new Config(); + config.setMatchSize(size); + return config; + } +} diff --git a/src/test/java/com/applitools/imagetester/lib/PdfPageRendererTest.java b/src/test/java/com/applitools/imagetester/lib/PdfPageRendererTest.java index 979af99..70cb085 100644 --- a/src/test/java/com/applitools/imagetester/lib/PdfPageRendererTest.java +++ b/src/test/java/com/applitools/imagetester/lib/PdfPageRendererTest.java @@ -72,6 +72,69 @@ public void with_rw_and_nf_set_composes_both_transforms() throws IOException { } } + @Test + public void with_ms_set_resizes_rendered_page_to_exact_dimensions() throws IOException { + File pdf = createSinglePagePdf("Hello World", PDType1Font.HELVETICA, 12f); + try (PDDocument doc = PDDocument.load(pdf)) { + PDFRenderer fallback = new PDFRenderer(doc); + Config config = baseConfig(); + config.setMatchSize("300x400"); + + BufferedImage rendered = PdfPageRenderer.render(doc.getPage(0), 0, fallback, config); + + assertEquals("Width mismatch", 300, rendered.getWidth()); + assertEquals("Height mismatch", 400, rendered.getHeight()); + } + } + + @Test + public void with_pt_manual_set_crops_rendered_page() throws IOException { + File pdf = createSinglePagePdf("Hello World", PDType1Font.HELVETICA, 12f); + try (PDDocument doc = PDDocument.load(pdf)) { + PDFRenderer fallback = new PDFRenderer(doc); + Config config = baseConfig(); + config.setPdfTrim("306x396"); + + BufferedImage rendered = PdfPageRenderer.render(doc.getPage(0), 0, fallback, config); + + assertEquals("Width mismatch", 306, rendered.getWidth()); + assertEquals("Height mismatch", 396, rendered.getHeight()); + } + } + + @Test + public void with_pt_auto_set_crops_to_trimbox_metadata() throws IOException { + File pdf = createSinglePagePdf("Hello World", PDType1Font.HELVETICA, 12f); + try (PDDocument doc = PDDocument.load(pdf)) { + doc.getPage(0).setTrimBox(new PDRectangle(100f, 146f, 400f, 500f)); + PDFRenderer fallback = new PDFRenderer(doc); + Config config = baseConfig(); + config.setPdfTrim("auto"); + + BufferedImage rendered = PdfPageRenderer.render(doc.getPage(0), 0, fallback, config); + + assertEquals("Width mismatch", 400, rendered.getWidth()); + assertEquals("Height mismatch", 500, rendered.getHeight()); + } + } + + @Test + public void with_pt_and_ms_set_crops_before_resizing() throws IOException { + File pdf = createSinglePagePdf("Hello World", PDType1Font.HELVETICA, 12f); + try (PDDocument doc = PDDocument.load(pdf)) { + PDFRenderer fallback = new PDFRenderer(doc); + Config config = baseConfig(); + // Crop changes the aspect ratio; proportional match-size then proves crop ran first. + config.setPdfTrim("306x792"); + config.setMatchSize("153x"); + + BufferedImage rendered = PdfPageRenderer.render(doc.getPage(0), 0, fallback, config); + + assertEquals("Width mismatch", 153, rendered.getWidth()); + assertEquals("Height mismatch", 396, rendered.getHeight()); + } + } + private Config baseConfig() { Config config = new Config(); config.DocumentConversionDPI = TEST_DPI; diff --git a/src/test/java/com/applitools/imagetester/lib/PdfPageTrimmerTest.java b/src/test/java/com/applitools/imagetester/lib/PdfPageTrimmerTest.java new file mode 100644 index 0000000..34d7e52 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/lib/PdfPageTrimmerTest.java @@ -0,0 +1,128 @@ +package com.applitools.imagetester.lib; + +import static org.junit.Assert.*; + +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.junit.Test; + +public class PdfPageTrimmerTest { + + private static final float PAGE_WIDTH = 673.2f; + private static final float PAGE_HEIGHT = 846f; + private static final PDRectangle TRIM = new PDRectangle(35.1f, 36f, 603f, 774f); + private static final float TOLERANCE = 0.5f; + + @Test + public void resolveCropBox_returnsNullWhenTrimNotConfigured() throws IOException { + try (PDDocument doc = new PDDocument()) { + assertNull(PdfPageTrimmer.resolveCropBox(newPage(doc), new Config())); + } + } + + @Test + public void resolveCropBox_autoPrefersTrimBoxMetadata() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = newPage(doc); + page.setTrimBox(TRIM); + + PDRectangle box = PdfPageTrimmer.resolveCropBox(page, autoConfig()); + + assertBoxEquals(TRIM, box); + } + } + + @Test + public void resolveCropBox_autoDetectsCropMarksWhenTrimBoxAbsent() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = newPage(doc); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + drawCropMarks(cs); + } + + PDRectangle box = PdfPageTrimmer.resolveCropBox(page, autoConfig()); + + assertBoxEquals(TRIM, box); + } + } + + @Test + public void resolveCropBox_autoReturnsNullWhenNoTrimSignal() throws IOException { + try (PDDocument doc = new PDDocument()) { + assertNull(PdfPageTrimmer.resolveCropBox(newPage(doc), autoConfig())); + } + } + + @Test + public void resolveCropBox_manualCentersRequestedBox() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(new PDRectangle(200f, 300f)); + doc.addPage(page); + + PDRectangle box = PdfPageTrimmer.resolveCropBox(page, trimConfig("100x150")); + + assertBoxEquals(new PDRectangle(50f, 75f, 100f, 150f), box); + } + } + + @Test + public void resolveCropBox_manualOversizeClampsToMediaBox() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(new PDRectangle(200f, 300f)); + doc.addPage(page); + + PDRectangle box = PdfPageTrimmer.resolveCropBox(page, trimConfig("999x999")); + + assertBoxEquals(new PDRectangle(0f, 0f, 200f, 300f), box); + } + } + + private PDPage newPage(PDDocument doc) { + PDPage page = new PDPage(new PDRectangle(PAGE_WIDTH, PAGE_HEIGHT)); + doc.addPage(page); + return page; + } + + private Config autoConfig() { + return trimConfig("auto"); + } + + private Config trimConfig(String value) { + Config config = new Config(); + config.setPdfTrim(value); + return config; + } + + private void drawCropMarks(PDPageContentStream cs) throws IOException { + float left = TRIM.getLowerLeftX(), right = TRIM.getUpperRightX(); + float bottom = TRIM.getLowerLeftY(), top = TRIM.getUpperRightY(); + float gap = 4f, len = 18f; + for (float x : new float[] { left, right }) { + drawLine(cs, x, top + gap, x, top + gap + len); + drawLine(cs, x, bottom - gap, x, bottom - gap - len); + } + for (float y : new float[] { bottom, top }) { + drawLine(cs, left - gap, y, left - gap - len, y); + drawLine(cs, right + gap, y, right + gap + len, y); + } + } + + private void drawLine(PDPageContentStream cs, float x1, float y1, float x2, float y2) throws IOException { + cs.setLineWidth(0.3f); + cs.moveTo(x1, y1); + cs.lineTo(x2, y2); + cs.stroke(); + } + + private void assertBoxEquals(PDRectangle expected, PDRectangle actual) { + assertNotNull("Expected a crop box", actual); + assertEquals("Left edge", expected.getLowerLeftX(), actual.getLowerLeftX(), TOLERANCE); + assertEquals("Bottom edge", expected.getLowerLeftY(), actual.getLowerLeftY(), TOLERANCE); + assertEquals("Width", expected.getWidth(), actual.getWidth(), TOLERANCE); + assertEquals("Height", expected.getHeight(), actual.getHeight(), TOLERANCE); + } +} diff --git a/src/test/java/com/applitools/imagetester/lib/RunConfigFactoryTest.java b/src/test/java/com/applitools/imagetester/lib/RunConfigFactoryTest.java new file mode 100644 index 0000000..65a35bb --- /dev/null +++ b/src/test/java/com/applitools/imagetester/lib/RunConfigFactoryTest.java @@ -0,0 +1,30 @@ +package com.applitools.imagetester.lib; + +import com.applitools.imagetester.ImageTester; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.Options; +import org.junit.Test; +import static org.junit.Assert.*; + +public class RunConfigFactoryTest { + + private CommandLine parse(String... args) throws Exception { + Options options = ImageTester.getOptions(); + return new DefaultParser().parse(options, args); + } + + @Test + public void mapsThreadsFromArg() throws Exception { + CommandLine cmd = parse("-k", "key", "-f", ".", "-th", "7"); + RunConfig rc = RunConfigFactory.from(cmd, new Logger()); + assertEquals(7, rc.threads); + } + + @Test + public void mapsAppNameFromArg() throws Exception { + CommandLine cmd = parse("-k", "key", "-f", ".", "-a", "MyApp"); + RunConfig rc = RunConfigFactory.from(cmd, new Logger()); + assertEquals("MyApp", rc.config.appName); + } +} diff --git a/src/test/java/com/applitools/imagetester/lib/TestExecutorHeartbeatTest.java b/src/test/java/com/applitools/imagetester/lib/TestExecutorHeartbeatTest.java new file mode 100644 index 0000000..9dbfa23 --- /dev/null +++ b/src/test/java/com/applitools/imagetester/lib/TestExecutorHeartbeatTest.java @@ -0,0 +1,92 @@ +package com.applitools.imagetester.lib; + +import com.applitools.eyes.BatchInfo; +import com.applitools.eyes.TestResults; +import com.applitools.eyes.TestResultsStatus; +import com.applitools.eyes.images.Eyes; +import com.applitools.imagetester.TestObjects.TestBase; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TestExecutorHeartbeatTest { + + @Test + public void emitsHeartbeatWhenTestRunsPastGracePeriod() throws Exception { + Config config = new Config(); + config.shouldThrowException = false; + CountDownLatch heartbeatSeen = new CountDownLatch(1); + config.logger.addListener(line -> { + if (line.startsWith("Still running...") && line.contains("lorem_20.pdf")) heartbeatSeen.countDown(); + }); + + EyesFactory factory = mock(EyesFactory.class); + Eyes eyes = mock(Eyes.class); + when(factory.build()).thenReturn(eyes); + when(eyes.getBatch()).thenReturn(new BatchInfo("t")); + + CountDownLatch workerEntered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + TestBase t = mock(TestBase.class); + TestResults r = mock(TestResults.class); + when(r.getStatus()).thenReturn(TestResultsStatus.Passed); + when(t.name()).thenReturn("lorem_20.pdf"); + when(t.runSafe(any())).thenAnswer(inv -> { workerEntered.countDown(); release.await(); return r; }); + + TestExecutor executor = new TestExecutor(1, factory, config); + AtomicLong fakeNanos = new AtomicLong(0); + executor.setNanoTimeSource(fakeNanos::get); + + executor.enqueue(t, null); + Thread joiner = new Thread(executor::join, "joiner"); + joiner.setDaemon(true); + joiner.start(); + + assertTrue("worker never started", workerEntered.await(5, TimeUnit.SECONDS)); + fakeNanos.set(TimeUnit.SECONDS.toNanos(31)); // cross the 30s grace; join's 250ms poll will observe it + + boolean sawHeartbeat = heartbeatSeen.await(5, TimeUnit.SECONDS); + release.countDown(); + joiner.join(5_000); + assertTrue("expected a heartbeat for the in-flight test", sawHeartbeat); + } + + @Test + public void noHeartbeatWhenTestCompletesWithinGrace() throws Exception { + Config config = new Config(); + config.shouldThrowException = false; + List log = new CopyOnWriteArrayList<>(); + config.logger.addListener(log::add); + + EyesFactory factory = mock(EyesFactory.class); + Eyes eyes = mock(Eyes.class); + when(factory.build()).thenReturn(eyes); + when(eyes.getBatch()).thenReturn(new BatchInfo("t")); + + TestBase t = mock(TestBase.class); + TestResults r = mock(TestResults.class); + when(r.getStatus()).thenReturn(TestResultsStatus.Passed); + when(t.name()).thenReturn("fast.png"); + when(t.runSafe(any())).thenReturn(r); // returns immediately + + TestExecutor executor = new TestExecutor(1, factory, config); + AtomicLong fakeNanos = new AtomicLong(0); // never advances past grace + executor.setNanoTimeSource(fakeNanos::get); + + executor.enqueue(t, null); + executor.join(); + + assertFalse("no heartbeat expected for a sub-grace test", + log.stream().anyMatch(s -> s.startsWith("Still running..."))); + } +} diff --git a/src/test/java/com/applitools/imagetester/lib/converters/XlsxWatermarkStamperTest.java b/src/test/java/com/applitools/imagetester/lib/converters/XlsxWatermarkStamperTest.java index 6c1a581..0b2f534 100644 --- a/src/test/java/com/applitools/imagetester/lib/converters/XlsxWatermarkStamperTest.java +++ b/src/test/java/com/applitools/imagetester/lib/converters/XlsxWatermarkStamperTest.java @@ -26,16 +26,19 @@ public class XlsxWatermarkStamperTest { @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); - private static final Path PERM_WAT = Paths.get("TestData", "PermWat.xlsx"); + // Sanitized copy of a customer file: same structure, watermark image replaced + // with a generated one. The original (TestData/PermWat.xlsx) contains customer + // PII and must stay untracked. + private static final Path WATERMARKED_XLSX = Paths.get("TestData", "xlsx-header-watermark.xlsx"); private static final int EXPECTED_WATERMARK_WIDTH = 750; private static final int EXPECTED_WATERMARK_HEIGHT = 600; @Test - public void extractsHeaderFooterWatermarkFromPermWat() throws Exception { - assertTrue("Test fixture missing: " + PERM_WAT, Files.isRegularFile(PERM_WAT)); + public void extractsHeaderFooterWatermarkFromXlsx() throws Exception { + assertTrue("Test fixture missing: " + WATERMARKED_XLSX, Files.isRegularFile(WATERMARKED_XLSX)); Optional wm = - new XlsxWatermarkStamper().extractWatermark(PERM_WAT.toFile()); + new XlsxWatermarkStamper().extractWatermark(WATERMARKED_XLSX.toFile()); assertTrue("expected to find a header/footer watermark", wm.isPresent()); assertNotNull(wm.get().imageBytes); @@ -67,11 +70,11 @@ public void stampIfPresentPassesThroughWhenNoWatermark() throws Exception { @Test public void stampsWatermarkOnEveryPageOfConvertedPdf() throws Exception { - assertTrue("Test fixture missing: " + PERM_WAT, Files.isRegularFile(PERM_WAT)); + assertTrue("Test fixture missing: " + WATERMARKED_XLSX, Files.isRegularFile(WATERMARKED_XLSX)); File source = writeMultiPagePdf(tempFolder.newFile("converted.pdf"), 6); File result = new XlsxWatermarkStamper() - .stampIfPresent(PERM_WAT.toFile(), source, tempFolder.getRoot().toPath()); + .stampIfPresent(WATERMARKED_XLSX.toFile(), source, tempFolder.getRoot().toPath()); assertNotEquals("stamped pdf should be distinct from input", source, result); try (PDDocument doc = PDDocument.load(result)) { diff --git a/src/test/resources/watermark/PDF_with_watermark.pdf b/src/test/resources/watermark/PDF_with_watermark.pdf new file mode 100644 index 0000000..36e0fea Binary files /dev/null and b/src/test/resources/watermark/PDF_with_watermark.pdf differ diff --git a/src/test/resources/watermark/PDF_without_watermark.pdf b/src/test/resources/watermark/PDF_without_watermark.pdf new file mode 100644 index 0000000..ee9a7ac Binary files /dev/null and b/src/test/resources/watermark/PDF_without_watermark.pdf differ