diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bc69a56..4f6d7da9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # pnpm version comes from the package.json "packageManager" field + # (pnpm 10+), which is required to read `overrides` from + # pnpm-workspace.yaml consistently with the committed lockfile. - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/Dockerfile b/Dockerfile index 8e1ae7f8..7ddcdadc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,23 @@ # Multi-stage build for the standalone static demo. # Build: docker build -t cwl-editor-demo . -# Run: docker run --rm -p 8080:80 cwl-editor-demo -> http://localhost:8080 -FROM node:22-alpine AS build +# Run: docker run --rm -p 8080:8080 cwl-editor-demo -> http://localhost:8080 +FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS build WORKDIR /app RUN corepack enable -COPY package.json pnpm-lock.yaml* ./ -RUN pnpm install --frozen-lockfile || pnpm install +# pnpm-workspace.yaml carries the security overrides, so it must be present for +# the frozen install to resolve consistently with the committed lockfile. +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile COPY . . RUN pnpm build:demo -FROM nginx:1.27-alpine AS runtime -COPY --from=build /app/dist-demo /usr/share/nginx/html -EXPOSE 80 +FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime +# Serve as the image's built-in unprivileged `nginx` user on an unprivileged +# port so the container never runs as root (satisfies trivy DS-0002). +COPY --from=build --chown=nginx:nginx /app/dist-demo /usr/share/nginx/html +RUN sed -i 's/listen 80;/listen 8080;/' /etc/nginx/conf.d/default.conf \ + && chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx /var/run /var/log/nginx +USER nginx +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1 CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index e42feb46..dd886773 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ pnpm build:demo # static site -> dist-demo/ ```bash docker build -t cwl-editor-demo . -docker run --rm -p 8080:80 cwl-editor-demo # http://localhost:8080 +docker run --rm -p 8080:8080 cwl-editor-demo # http://localhost:8080 ``` ## Scripts diff --git a/package.json b/package.json index 02124513..3b7e6528 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "MIT", "author": "ContextualWisdomLab", "type": "module", + "packageManager": "pnpm@10.34.4", "keywords": [ "editor", "wysiwyg", @@ -88,17 +89,18 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.5.2", + "@types/node": "^22.10.5", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/turndown": "^5.0.5", "@vitejs/plugin-react": "^4.3.4", - "@vitest/coverage-v8": "^2.1.8", + "@vitest/coverage-v8": "^3.2.6", "jsdom": "^25.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "typescript": "^5.7.3", - "vite": "^5.4.11", + "vite": "^6.4.3", "vite-plugin-dts": "^4.5.0", - "vitest": "^2.1.8" + "vitest": "^3.2.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f9cbfd5..b9be8f25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + vite: ^6.4.3 + esbuild: ^0.25.0 + importers: .: @@ -57,6 +61,9 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^22.10.5 + version: 22.20.1 '@types/react': specifier: ^18.3.18 version: 18.3.31 @@ -68,10 +75,10 @@ importers: version: 5.0.6 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@5.4.21) + version: 4.7.0(vite@6.4.3(@types/node@22.20.1)) '@vitest/coverage-v8': - specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(jsdom@25.0.1)) + specifier: ^3.2.6 + version: 3.2.7(vitest@3.2.7(@types/node@22.20.1)(jsdom@25.0.1)) jsdom: specifier: ^25.0.1 version: 25.0.1 @@ -85,14 +92,14 @@ importers: specifier: ^5.7.3 version: 5.9.3 vite: - specifier: ^5.4.11 - version: 5.4.21 + specifier: ^6.4.3 + version: 6.4.3(@types/node@22.20.1) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(rollup@4.62.2)(typescript@5.9.3)(vite@5.4.21) + version: 4.5.4(@types/node@22.20.1)(rollup@4.62.2)(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)) vitest: - specifier: ^2.1.8 - version: 2.1.9(jsdom@25.0.1) + specifier: ^3.2.6 + version: 3.2.7(@types/node@22.20.1)(jsdom@25.0.1) packages: @@ -193,8 +200,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} @@ -224,141 +232,159 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -808,6 +834,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -820,6 +852,9 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -841,45 +876,45 @@ packages: resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^6.4.3 - '@vitest/coverage-v8@2.1.9': - resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + '@vitest/coverage-v8@3.2.7': + resolution: {integrity: sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==} peerDependencies: - '@vitest/browser': 2.1.9 - vitest: 2.1.9 + '@vitest/browser': 3.2.7 + vitest: 3.2.7 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 + vite: ^6.4.3 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -978,6 +1013,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1152,9 +1190,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} hasBin: true escalade@3.2.0: @@ -1184,6 +1222,15 @@ packages: fast-uri@3.1.3: resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1311,9 +1358,15 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsdom@25.0.1: resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} engines: {node: '>=18'} @@ -1467,9 +1520,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1690,6 +1740,9 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1715,16 +1768,20 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} tippy.js@6.3.7: @@ -1763,6 +1820,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -1778,36 +1838,41 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true vite-plugin-dts@4.5.4: resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} peerDependencies: typescript: '*' - vite: '*' + vite: ^6.4.3 peerDependenciesMeta: vite: optional: true - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' less: '*' lightningcss: ^1.21.0 sass: '*' sass-embedded: '*' stylus: '*' sugarss: '*' - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -1822,21 +1887,28 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/debug': + optional: true '@types/node': optional: true '@vitest/browser': @@ -2046,7 +2118,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} '@csstools/color-helpers@5.1.0': {} @@ -2068,73 +2140,82 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': optional: true '@isaacs/cliui@8.0.2': @@ -2167,23 +2248,23 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@microsoft/api-extractor-model@7.33.8': + '@microsoft/api-extractor-model@7.33.8(@types/node@22.20.1)': dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.23.1 + '@rushstack/node-core-library': 5.23.1(@types/node@22.20.1) transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.58.9': + '@microsoft/api-extractor@7.58.9(@types/node@22.20.1)': dependencies: - '@microsoft/api-extractor-model': 7.33.8 + '@microsoft/api-extractor-model': 7.33.8(@types/node@22.20.1) '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.23.1 + '@rushstack/node-core-library': 5.23.1(@types/node@22.20.1) '@rushstack/rig-package': 0.7.3 - '@rushstack/terminal': 0.24.0 - '@rushstack/ts-command-line': 5.3.10 + '@rushstack/terminal': 0.24.0(@types/node@22.20.1) + '@rushstack/ts-command-line': 5.3.10(@types/node@22.20.1) diff: 8.0.4 minimatch: 10.2.3 resolve: 1.22.12 @@ -2296,7 +2377,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true - '@rushstack/node-core-library@5.23.1': + '@rushstack/node-core-library@5.23.1(@types/node@22.20.1)': dependencies: ajv: 8.18.0 ajv-draft-04: 1.0.0(ajv@8.18.0) @@ -2306,23 +2387,29 @@ snapshots: jju: 1.4.0 resolve: 1.22.12 semver: 7.7.4 + optionalDependencies: + '@types/node': 22.20.1 - '@rushstack/problem-matcher@0.2.1': {} + '@rushstack/problem-matcher@0.2.1(@types/node@22.20.1)': + optionalDependencies: + '@types/node': 22.20.1 '@rushstack/rig-package@0.7.3': dependencies: jju: 1.4.0 resolve: 1.22.12 - '@rushstack/terminal@0.24.0': + '@rushstack/terminal@0.24.0(@types/node@22.20.1)': dependencies: - '@rushstack/node-core-library': 5.23.1 - '@rushstack/problem-matcher': 0.2.1 + '@rushstack/node-core-library': 5.23.1(@types/node@22.20.1) + '@rushstack/problem-matcher': 0.2.1(@types/node@22.20.1) supports-color: 8.1.1 + optionalDependencies: + '@types/node': 22.20.1 - '@rushstack/ts-command-line@5.3.10': + '@rushstack/ts-command-line@5.3.10(@types/node@22.20.1)': dependencies: - '@rushstack/terminal': 0.24.0 + '@rushstack/terminal': 0.24.0(@types/node@22.20.1) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -2574,6 +2661,13 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/linkify-it@5.0.0': {} @@ -2585,6 +2679,10 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.31)': @@ -2600,7 +2698,7 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@vitejs/plugin-react@4.7.0(vite@5.4.21)': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.1))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -2608,14 +2706,15 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21 + vite: 6.4.3(@types/node@22.20.1) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@2.1.9(vitest@2.1.9(jsdom@25.0.1))': + '@vitest/coverage-v8@3.2.7(vitest@3.2.7(@types/node@22.20.1)(jsdom@25.0.1))': dependencies: '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 0.2.3 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -2625,50 +2724,52 @@ snapshots: magicast: 0.3.5 std-env: 3.10.0 test-exclude: 7.0.2 - tinyrainbow: 1.2.0 - vitest: 2.1.9(jsdom@25.0.1) + tinyrainbow: 2.0.0 + vitest: 3.2.7(@types/node@22.20.1)(jsdom@25.0.1) transitivePeerDependencies: - supports-color - '@vitest/expect@2.1.9': + '@vitest/expect@3.2.7': dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 chai: 5.3.3 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.9(vite@5.4.21)': + '@vitest/mocker@3.2.7(vite@6.4.3(@types/node@22.20.1))': dependencies: - '@vitest/spy': 2.1.9 + '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21 + vite: 6.4.3(@types/node@22.20.1) - '@vitest/pretty-format@2.1.9': + '@vitest/pretty-format@3.2.7': dependencies: - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - '@vitest/runner@2.1.9': + '@vitest/runner@3.2.7': dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 - '@vitest/snapshot@2.1.9': + '@vitest/snapshot@3.2.7': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/pretty-format': 3.2.7 magic-string: 0.30.21 - pathe: 1.1.2 + pathe: 2.0.3 - '@vitest/spy@2.1.9': + '@vitest/spy@3.2.7': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.4 - '@vitest/utils@2.1.9': + '@vitest/utils@3.2.7': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/pretty-format': 3.2.7 loupe: 3.2.1 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 '@volar/language-core@2.4.28': dependencies: @@ -2762,6 +2863,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + asynckit@0.4.0: {} balanced-match@1.0.2: {} @@ -2902,31 +3009,34 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - esbuild@0.21.5: + esbuild@0.25.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 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 escalade@3.2.0: {} @@ -2946,6 +3056,10 @@ snapshots: fast-uri@3.1.3: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -3084,8 +3198,12 @@ snapshots: jju@1.4.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + jsdom@25.0.1: dependencies: cssstyle: 4.6.0 @@ -3241,8 +3359,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 - pathe@1.1.2: {} - pathe@2.0.3: {} pathval@2.0.1: {} @@ -3511,6 +3627,10 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3533,11 +3653,16 @@ snapshots: tinyexec@0.3.2: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinypool@1.1.1: {} - tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} - tinyspy@3.0.2: {} + tinyspy@4.0.4: {} tippy.js@6.3.7: dependencies: @@ -3569,6 +3694,8 @@ snapshots: ufo@1.6.4: {} + undici-types@6.21.0: {} + universalify@2.0.1: {} update-browserslist-db@1.2.3(browserslist@4.28.5): @@ -3581,15 +3708,16 @@ snapshots: dependencies: react: 18.3.1 - vite-node@2.1.9: + vite-node@3.2.4(@types/node@22.20.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21 + pathe: 2.0.3 + vite: 6.4.3(@types/node@22.20.1) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -3598,10 +3726,12 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml - vite-plugin-dts@4.5.4(rollup@4.62.2)(typescript@5.9.3)(vite@5.4.21): + vite-plugin-dts@4.5.4(@types/node@22.20.1)(rollup@4.62.2)(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)): dependencies: - '@microsoft/api-extractor': 7.58.9 + '@microsoft/api-extractor': 7.58.9(@types/node@22.20.1) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) '@volar/typescript': 2.4.28 '@vue/language-core': 2.2.0(typescript@5.9.3) @@ -3612,45 +3742,54 @@ snapshots: magic-string: 0.30.21 typescript: 5.9.3 optionalDependencies: - vite: 5.4.21 + vite: 6.4.3(@types/node@22.20.1) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@5.4.21: + vite@6.4.3(@types/node@22.20.1): dependencies: - esbuild: 0.21.5 + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.16 rollup: 4.62.2 + tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 22.20.1 fsevents: 2.3.3 - vitest@2.1.9(jsdom@25.0.1): + vitest@3.2.7(@types/node@22.20.1)(jsdom@25.0.1): dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@6.4.3(@types/node@22.20.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 chai: 5.3.3 debug: 4.4.3 expect-type: 1.4.0 magic-string: 0.30.21 - pathe: 1.1.2 + pathe: 2.0.3 + picomatch: 4.0.5 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 + tinyglobby: 0.2.17 tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21 - vite-node: 2.1.9 + tinyrainbow: 2.0.0 + vite: 6.4.3(@types/node@22.20.1) + vite-node: 3.2.4(@types/node@22.20.1) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 22.20.1 jsdom: 25.0.1 transitivePeerDependencies: + - jiti - less - lightningcss - msw @@ -3660,6 +3799,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vscode-uri@3.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40b896b5..678bc849 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,13 @@ onlyBuiltDependencies: - esbuild allowBuilds: esbuild: true + +# Force the patched vite/esbuild across the whole dependency tree. vitest's +# @vitest/mocker still resolves a transitive vite@5 (which drags in the +# vulnerable esbuild<0.25), so pin both here — pnpm 10+ reads `overrides` +# from pnpm-workspace.yaml, not the deprecated package.json `pnpm` field. +# - vite<6.4.3: GHSA-fx2h-pf6j-xcff / GHSA-4w7w-66w2-5vf9 / GHSA-v6wh-96g9-6wx3 +# - esbuild<0.25: GHSA-67mh-4wv8-2f99 +overrides: + vite: ^6.4.3 + esbuild: ^0.25.0 diff --git a/src/components/CwlEditor.test.tsx b/src/components/CwlEditor.test.tsx index bf6f82af..0b72bc09 100644 --- a/src/components/CwlEditor.test.tsx +++ b/src/components/CwlEditor.test.tsx @@ -1,6 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { + render, + screen, + waitFor, + cleanup, + fireEvent, +} from '@testing-library/react'; import { afterEach } from 'vitest'; +import type { Editor } from '@tiptap/react'; import { CwlEditor } from './CwlEditor.js'; import { imageFileToInlineDataUri } from '../extensions/Base64Image.js'; @@ -82,6 +89,14 @@ describe('CwlEditor smoke', () => { ); expect(screen.queryByRole('toolbar')).not.toBeInTheDocument(); }); + + it('renders an empty document when neither value nor defaultValue is given', async () => { + render(); + await waitFor(() => + expect(document.querySelector('.cwl-editor__content')).toBeTruthy(), + ); + expect(screen.getByRole('toolbar', { name: /formatting/i })).toBeInTheDocument(); + }); }); describe('inline image helper (used by paste/drop/upload)', () => { @@ -106,3 +121,187 @@ describe('inline image helper (used by paste/drop/upload)', () => { ).rejects.toThrow(/exceeds/); }); }); + +describe('CwlEditor change emission', () => { + it('emits serialized markdown through onChange when the doc changes', async () => { + const onChange = vi.fn(); + let ed: Editor | undefined; + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().insertContent(' there').run(); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + expect(onChange.mock.calls.at(-1)![0]).toContain('hi'); + }); + + it('emits serialized html through onChange in html mode', async () => { + const onChange = vi.fn(); + let ed: Editor | undefined; + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().insertContent(' x').run(); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + expect(onChange.mock.calls.at(-1)![0]).toContain(''); + }); + + it('does not throw on updates when no onChange handler is provided', async () => { + let ed: Editor | undefined; + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().insertContent(' more').run(); + expect(ed!.getHTML()).toContain('more'); + }); +}); + +describe('CwlEditor controlled value sync', () => { + it('pushes new external values into the document', async () => { + const { rerender } = render(); + await waitFor(() => + expect( + document.querySelector('.cwl-editor__content h1'), + ).toHaveTextContent('One'), + ); + rerender(); + await waitFor(() => + expect( + document.querySelector('.cwl-editor__content h1'), + ).toHaveTextContent('Two'), + ); + }); + + it('leaves the document untouched when the value already matches', async () => { + const { rerender } = render(); + await waitFor(() => + expect(document.querySelector('.cwl-editor__content')).toHaveTextContent( + 'stable', + ), + ); + // Re-render with the same value but a different unrelated prop. + rerender(); + await waitFor(() => + expect(document.querySelector('.cwl-editor')).toHaveClass('tweak'), + ); + expect(document.querySelector('.cwl-editor__content')).toHaveTextContent( + 'stable', + ); + }); +}); + +describe('CwlEditor Ctrl/Cmd+K link shortcut', () => { + function surface(): HTMLElement { + return document.querySelector('.cwl-editor__surface') as HTMLElement; + } + + it('creates a link from the prompt URL', async () => { + let ed: Editor | undefined; + vi.spyOn(window, 'prompt').mockReturnValue('https://ex.com'); + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().selectAll().run(); + fireEvent.keyDown(surface(), { key: 'K', metaKey: true }); + await waitFor(() => + expect(ed!.getHTML()).toContain('href="https://ex.com"'), + ); + }); + + it('reuses the existing link href as the prompt default', async () => { + let ed: Editor | undefined; + const prompt = vi + .spyOn(window, 'prompt') + .mockReturnValue('https://second.com'); + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().selectAll().setLink({ href: 'https://first.com' }).run(); + fireEvent.keyDown(surface(), { key: 'k', ctrlKey: true }); + await waitFor(() => + expect(ed!.getHTML()).toContain('href="https://second.com"'), + ); + expect(prompt).toHaveBeenLastCalledWith('Link URL', 'https://first.com'); + }); + + it('removes the link when the prompt is cleared to empty', async () => { + let ed: Editor | undefined; + vi.spyOn(window, 'prompt').mockReturnValue(''); + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().selectAll().setLink({ href: 'https://x.com' }).run(); + fireEvent.keyDown(surface(), { key: 'k', metaKey: true }); + await waitFor(() => expect(ed!.getHTML()).not.toContain('href=')); + }); + + it('does nothing when the link prompt is cancelled', async () => { + let ed: Editor | undefined; + vi.spyOn(window, 'prompt').mockReturnValue(null); + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + fireEvent.keyDown(surface(), { key: 'k', metaKey: true }); + expect(ed!.getHTML()).not.toContain('href='); + }); + + it('ignores k without a modifier and modified non-k keys', async () => { + render(); + await waitFor(() => + expect(document.querySelector('.cwl-editor__surface')).toBeTruthy(), + ); + fireEvent.keyDown(surface(), { key: 'k' }); + fireEvent.keyDown(surface(), { key: 'b', metaKey: true }); + expect(document.querySelector('.cwl-editor__content')).toBeTruthy(); + }); +}); diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 63ba9105..eff7bdfd 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -83,12 +83,18 @@ export function CwlEditor({ if (!editor || !isControlled || emittingRef.current) return; const current = fromHtml(editor.getHTML(), mode); if (current !== value) { - editor.commands.setContent(toHtml(value ?? '', mode), false); + // `value` is always a string here (isControlled === value !== undefined), + // so the `?? ''` fallback is a type guard that never runs. + /* v8 ignore next */ + const next = toHtml(value ?? '', mode); + editor.commands.setContent(next, false); } }, [editor, isControlled, value, mode]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { + // Defensive: key events can only reach the surface once the editor exists. + /* v8 ignore next */ if (!editor) return; const mod = e.metaKey || e.ctrlKey; if (mod && e.key.toLowerCase() === 'k') { diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx new file mode 100644 index 00000000..ef88f66b --- /dev/null +++ b/src/components/Toolbar.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { Toolbar } from './Toolbar.js'; +import { buildExtensions } from '../extensions/kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Editor[] = []; +function makeEditor(content = 'hello world'): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content, + }); + openEditors.push(editor); + return editor; +} + +afterEach(() => { + cleanup(); + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +/** A decodable Image stand-in that always reports a within-bounds size. */ +class FitImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + width = 10; + height = 10; + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } +} + +function fileInput(): HTMLInputElement { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +describe('Toolbar', () => { + it('renders every affordance and invokes each button command', () => { + const editor = makeEditor(); + // Make undo available and one mark active so both the active and disabled + // ToolbarButton branches render. + editor.chain().focus().selectAll().toggleBold().run(); + vi.spyOn(window, 'prompt').mockReturnValue(null); + + render(); + + const bold = screen.getByRole('button', { name: /Bold/ }); + expect(bold).toHaveClass('is-active'); + expect(bold).toHaveAttribute('aria-pressed', 'true'); + + const buttons = screen.getAllByRole('button'); + // All 16 affordances (marks, headings, lists, link, table, image, history). + expect(buttons.length).toBe(16); + // Cover onMouseDown preventDefault + every onClick handler. + for (const button of buttons) { + fireEvent.mouseDown(button); + if (!(button as HTMLButtonElement).disabled) fireEvent.click(button); + } + }); + + it('enables and fires the redo command after an undo', () => { + const editor = makeEditor(); + editor.chain().focus().insertContent(' more').run(); + editor.commands.undo(); // now redo is available + render(); + const redo = screen.getByRole('button', { name: /Redo/ }); + expect(redo).not.toBeDisabled(); + fireEvent.click(redo); + expect(editor.getHTML()).toContain('more'); + }); + + describe('link button', () => { + it('sets a link when a URL is entered', () => { + const editor = makeEditor(); + editor.chain().focus().selectAll().run(); + vi.spyOn(window, 'prompt').mockReturnValue('https://example.com'); + render(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + expect(editor.getHTML()).toContain('href="https://example.com"'); + }); + + it('unsets the link when the URL is cleared to empty', () => { + const editor = makeEditor('linked'); + editor.chain().focus().selectAll().run(); + vi.spyOn(window, 'prompt').mockReturnValue(''); + render(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + expect(editor.getHTML()).not.toContain('href='); + }); + + it('does nothing when the prompt is cancelled', () => { + const editor = makeEditor(); + editor.chain().focus().selectAll().run(); + vi.spyOn(window, 'prompt').mockReturnValue(null); + render(); + const before = editor.getHTML(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + expect(editor.getHTML()).toBe(before); + }); + }); + + describe('inline image upload', () => { + it('embeds a chosen image using the provided image config', async () => { + const editor = makeEditor(); + render( + , + ); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + fireEvent.change(fileInput(), { target: { files: [file] } }); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); + + it('embeds a chosen image using default config when no image prop is set', async () => { + const editor = makeEditor(); + // With no image prop, maxDimension defaults to 1600, which routes through + // the downscale path; stub a decodable Image so it resolves in jsdom. + vi.stubGlobal('Image', FitImage); + render(); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + fireEvent.change(fileInput(), { target: { files: [file] } }); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); + + it('ignores a change event with no file', () => { + const editor = makeEditor(); + render(); + fireEvent.change(fileInput(), { target: { files: [] } }); + expect(editor.getHTML()).not.toContain('data:image'); + }); + + it('swallows conversion errors (e.g. oversized files)', async () => { + const editor = makeEditor(); + render(); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + fireEvent.change(fileInput(), { target: { files: [file] } }); + await new Promise((resolve) => setTimeout(resolve, 15)); + expect(editor.getHTML()).not.toContain('data:image'); + }); + }); +}); diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts new file mode 100644 index 00000000..13586cb2 --- /dev/null +++ b/src/converter/base64.fallbacks.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { toUint8Array, blobToDataUri } from './base64.js'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe('toUint8Array with a non-Uint8Array view', () => { + it('wraps an ArrayBufferView via buffer/offset/length', () => { + const src = new Uint8Array([9, 8, 7, 6, 5]); + // Int8Array is an ArrayBufferView but not a Uint8Array, so it exercises the + // `ArrayBuffer.isView` branch rather than the fast identity return. + const view = new Int8Array(src.buffer, 1, 3); + const out = toUint8Array(view); + expect(out).toBeInstanceOf(Uint8Array); + expect(Array.from(out)).toEqual([8, 7, 6]); + }); +}); + +// Note: the `btoa`/`atob` fallback in bytesToBase64/base64ToBytes only runs in +// a browser without a global `Buffer`. It cannot be exercised here without +// deleting `globalThis.Buffer`, which breaks vitest/jsdom/coverage internals, +// so that browser-only branch is annotated with `v8 ignore` in the source. + +describe('readBlobBytes environment fallbacks', () => { + const BYTES = new Uint8Array([1, 2, 3, 4]); + const PNG = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + it('uses Blob.arrayBuffer when the blob implements it', async () => { + const fakeBlob = { + type: 'application/octet-stream', + arrayBuffer: () => Promise.resolve(BYTES.buffer.slice(0)), + } as unknown as Blob; + const uri = await blobToDataUri(fakeBlob); + expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); + }); + + it('falls back to application/octet-stream for a typeless, unsniffable blob', async () => { + const fakeBlob = { + type: '', + arrayBuffer: () => Promise.resolve(new Uint8Array([0, 1, 2, 3]).buffer.slice(0)), + } as unknown as Blob; + const uri = await blobToDataUri(fakeBlob); + expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); + }); + + it('rejects when FileReader errors and exposes its own error', async () => { + class FailingReader { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + error: unknown = new Error('reader boom'); + result: ArrayBuffer | null = null; + readAsArrayBuffer(): void { + queueMicrotask(() => this.onerror?.()); + } + } + vi.stubGlobal('FileReader', FailingReader); + // No `arrayBuffer` method -> the FileReader branch is taken. + const fakeBlob = { type: 'image/png' } as unknown as Blob; + await expect(blobToDataUri(fakeBlob)).rejects.toThrow('reader boom'); + }); + + it('rejects with a synthesized error when FileReader has no error object', async () => { + class NullErrorReader { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + error: unknown = null; + result: ArrayBuffer | null = null; + readAsArrayBuffer(): void { + queueMicrotask(() => this.onerror?.()); + } + } + vi.stubGlobal('FileReader', NullErrorReader); + const fakeBlob = { type: 'image/png' } as unknown as Blob; + await expect(blobToDataUri(fakeBlob)).rejects.toThrow( + /FileReader failed to read Blob/, + ); + }); + + it('reads through Response when neither arrayBuffer nor FileReader exist', async () => { + vi.stubGlobal('FileReader', undefined); + vi.stubGlobal( + 'Response', + class FakeResponse { + constructor(_blob: unknown) {} + arrayBuffer(): Promise { + return Promise.resolve(PNG.buffer.slice(0)); + } + }, + ); + const fakeBlob = { type: 'image/png' } as unknown as Blob; + const uri = await blobToDataUri(fakeBlob); + expect(uri.startsWith('data:image/png;base64,')).toBe(true); + }); +}); diff --git a/src/converter/base64.test.ts b/src/converter/base64.test.ts index ff978448..c816f7ed 100644 --- a/src/converter/base64.test.ts +++ b/src/converter/base64.test.ts @@ -89,6 +89,16 @@ describe('MIME sniffing', () => { 'image/svg+xml', ); }); + it('detects SVG wrapped in an XML prolog', () => { + expect( + sniffMimeType(new TextEncoder().encode('')), + ).toBe('image/svg+xml'); + }); + it('does not treat a non-SVG XML document as an image', () => { + expect( + sniffMimeType(new TextEncoder().encode('')), + ).toBeUndefined(); + }); it('returns undefined for unknown bytes', () => { expect(sniffMimeType(new Uint8Array([0x00, 0x01, 0x02]))).toBeUndefined(); }); diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c9c1eb8e..38173f54 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -64,36 +64,40 @@ const hasBuffer = typeof globalThis.Buffer !== 'undefined'; /** Encode raw bytes to a base64 string. Works in Node and the browser. */ export function bytesToBase64(bytes: Uint8Array): string { - if (hasBuffer) { - return globalThis.Buffer.from( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength, - ).toString('base64'); - } - let binary = ''; - const chunkSize = 0x8000; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, i + chunkSize); - binary += String.fromCharCode(...chunk); + /* v8 ignore start -- browser-only fallback: Node and jsdom always provide Buffer */ + if (!hasBuffer) { + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode(...chunk); + } + // eslint-disable-next-line no-undef + return btoa(binary); } - // eslint-disable-next-line no-undef - return btoa(binary); + /* v8 ignore stop */ + return globalThis.Buffer.from( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).toString('base64'); } /** Decode a base64 string to raw bytes. Works in Node and the browser. */ export function base64ToBytes(base64: string): Uint8Array { const normalized = base64.replace(/\s+/g, ''); - if (hasBuffer) { - return new Uint8Array(globalThis.Buffer.from(normalized, 'base64')); - } - // eslint-disable-next-line no-undef - const binary = atob(normalized); - const out = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - out[i] = binary.charCodeAt(i); + /* v8 ignore start -- browser-only fallback: Node and jsdom always provide Buffer */ + if (!hasBuffer) { + // eslint-disable-next-line no-undef + const binary = atob(normalized); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; } - return out; + /* v8 ignore stop */ + return new Uint8Array(globalThis.Buffer.from(normalized, 'base64')); } /** Coerce any binary-ish input to a `Uint8Array` view without copying twice. */ @@ -267,7 +271,10 @@ export function parseDataUri(dataUri: string): ParsedDataUri { const mimeType = match[1] && match[1].length > 0 ? match[1] : 'text/plain'; const params = match[2] ?? ''; const isBase64 = /;base64/i.test(params); - return { mimeType, isBase64, payload: match[3] ?? '' }; + // Capture group 3 always matches (possibly empty), so `?? ''` is defensive. + /* v8 ignore next */ + const payload = match[3] ?? ''; + return { mimeType, isBase64, payload }; } /** `true` when the string is a syntactically valid data URI. */ diff --git a/src/extensions/Base64Image.test.ts b/src/extensions/Base64Image.test.ts new file mode 100644 index 00000000..61a9e07d --- /dev/null +++ b/src/extensions/Base64Image.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { + downscaleDataUri, + imageFileToInlineDataUri, + Base64Image, + base64ImagePluginKey, +} from './Base64Image.js'; +import { buildExtensions } from './kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +/** A controllable stand-in for the browser's `Image`, which jsdom cannot decode. */ +class MockImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + width = 0; + height = 0; + private _src = ''; + set src(value: string) { + this._src = value; + queueMicrotask(() => { + if (MockImage.mode === 'error') { + this.onerror?.(); + return; + } + this.width = MockImage.width; + this.height = MockImage.height; + this.onload?.(); + }); + } + get src(): string { + return this._src; + } + static mode: 'load' | 'error' = 'load'; + static width = 100; + static height = 100; +} + +const openEditors: Editor[] = []; +function track(editor: Editor): Editor { + openEditors.push(editor); + return editor; +} + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + MockImage.mode = 'load'; + MockImage.width = 100; + MockImage.height = 100; +}); + +describe('downscaleDataUri', () => { + it('returns the original URI when downscaling is disabled (maxDimension 0)', async () => { + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 0, 0.85)).toBe(uri); + }); + + it('returns the original URI when no Image constructor is available', async () => { + vi.stubGlobal('Image', undefined); + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 100, 0.85)).toBe(uri); + }); + + it('returns the original URI when the image fails to load', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.mode = 'error'; + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 100, 0.85)).toBe(uri); + }); + + it('returns the original URI when the image already fits', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 50; + MockImage.height = 40; + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 100, 0.85)).toBe(uri); + }); + + it('returns the original URI when a 2D context cannot be obtained', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 400; + MockImage.height = 200; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 100, 0.85)).toBe(uri); + }); + + it('re-encodes an oversized PNG through the canvas', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 400; + MockImage.height = 200; + const ctx = { drawImage: vi.fn() }; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + ctx as unknown as CanvasRenderingContext2D, + ); + const toDataURL = vi + .spyOn(HTMLCanvasElement.prototype, 'toDataURL') + .mockReturnValue('data:image/png;base64,SCALED'); + const out = await downscaleDataUri('data:image/png;base64,AAAA', 100, 0.85); + expect(out).toBe('data:image/png;base64,SCALED'); + expect(ctx.drawImage).toHaveBeenCalledOnce(); + expect(toDataURL).toHaveBeenCalledWith('image/png', 0.85); + }); + + it('re-encodes a non-PNG image as jpeg', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 400; + MockImage.height = 400; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + { drawImage: vi.fn() } as unknown as CanvasRenderingContext2D, + ); + const toDataURL = vi + .spyOn(HTMLCanvasElement.prototype, 'toDataURL') + .mockReturnValue('data:image/jpeg;base64,SCALED'); + const out = await downscaleDataUri('data:image/webp;base64,AAAA', 100, 0.85); + expect(out).toBe('data:image/jpeg;base64,SCALED'); + expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.85); + }); +}); + +describe('imageFileToInlineDataUri downscale branch', () => { + it('invokes the downscaler when maxDimension is positive', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 50; + MockImage.height = 50; // fits, so the URI comes back unchanged + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const uri = await imageFileToInlineDataUri(file, { + maxSizeBytes: 1024 * 1024, + maxDimension: 100, + quality: 0.85, + }); + expect(uri.startsWith('data:image/png;base64,')).toBe(true); + }); + + it('disables the size guard when maxSizeBytes is 0', async () => { + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const uri = await imageFileToInlineDataUri(file, { + maxSizeBytes: 0, + maxDimension: 0, + quality: 0.85, + }); + expect(uri.startsWith('data:image/png;base64,')).toBe(true); + }); +}); + +function makeEditor(opts?: { + image?: Record; + onError?: (error: Error) => void; +}): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const extensions = opts?.onError + ? [ + StarterKit, + Base64Image.configure({ + maxSizeBytes: 4, + maxDimension: 0, + quality: 0.85, + onError: opts.onError, + }), + ] + : buildExtensions({ image: { maxDimension: 0, ...(opts?.image ?? {}) } }); + return new Editor({ element, extensions, content: 'hello' }); +} + +function paste(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handlePaste as (v: unknown, e: unknown) => boolean)( + editor.view, + event, + ); +} + +function drop(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handleDrop as (v: unknown, e: unknown) => boolean)( + editor.view, + event, + ); +} + +describe('Base64Image paste handler', () => { + it('ignores paste events without clipboard items', () => { + const editor = track(makeEditor()); + expect(paste(editor, { clipboardData: undefined })).toBe(false); + }); + + it('ignores clipboards that carry no files', () => { + const editor = track(makeEditor()); + const items = [{ kind: 'string', getAsFile: () => null }]; + expect(paste(editor, { clipboardData: { items } })).toBe(false); + }); + + it('ignores pasted non-image files without preventing default', () => { + const editor = track(makeEditor()); + const file = new File(['plain'], 'a.txt', { type: 'text/plain' }); + const items = [{ kind: 'file', getAsFile: () => file }]; + const preventDefault = vi.fn(); + expect(paste(editor, { clipboardData: { items }, preventDefault })).toBe( + false, + ); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('embeds a pasted image file as inline base64', async () => { + const editor = track(makeEditor()); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const items = [ + { kind: 'file', getAsFile: () => null }, // exercises the `if (file)` guard + { kind: 'file', getAsFile: () => file }, + ]; + const preventDefault = vi.fn(); + expect(paste(editor, { clipboardData: { items }, preventDefault })).toBe( + true, + ); + expect(preventDefault).toHaveBeenCalledOnce(); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); +}); + +describe('Base64Image drop handler', () => { + it('ignores drops without files', () => { + const editor = track(makeEditor()); + expect(drop(editor, { dataTransfer: undefined })).toBe(false); + }); + + it('embeds a dropped image at the resolved drop coordinates', async () => { + const editor = track(makeEditor()); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ + pos: 1, + inside: -1, + }); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const preventDefault = vi.fn(); + expect( + drop(editor, { + dataTransfer: { files: [file] }, + clientX: 5, + clientY: 5, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); + + it('falls back to the current selection when coords resolve to nothing', async () => { + const editor = track(makeEditor()); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue(null); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + expect( + drop(editor, { + dataTransfer: { files: [file] }, + clientX: 0, + clientY: 0, + preventDefault: vi.fn(), + }), + ).toBe(true); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); +}); + +describe('Base64Image conversion error handling', () => { + it('routes conversion failures to the configured onError handler', async () => { + const onError = vi.fn(); + const editor = track(makeEditor({ onError })); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); // > 4 bytes + const items = [{ kind: 'file', getAsFile: () => file }]; + expect( + paste(editor, { clipboardData: { items }, preventDefault: vi.fn() }), + ).toBe(true); + await waitFor(() => expect(onError).toHaveBeenCalled()); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + expect(editor.getHTML()).not.toContain('data:image'); + }); + + it('swallows conversion failures when no onError handler is configured', async () => { + const editor = track(makeEditor({ image: { maxSizeBytes: 4 } })); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const items = [{ kind: 'file', getAsFile: () => file }]; + expect( + paste(editor, { clipboardData: { items }, preventDefault: vi.fn() }), + ).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 15)); + expect(editor.getHTML()).not.toContain('data:image'); + }); + + it('skips insertion when the editor is destroyed mid-conversion', async () => { + const editor = makeEditor(); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const items = [{ kind: 'file', getAsFile: () => file }]; + paste(editor, { clipboardData: { items }, preventDefault: vi.fn() }); + editor.destroy(); // tear down before the async conversion resolves + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(editor.isDestroyed).toBe(true); + }); +}); diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 520e8426..e63c7f29 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -135,6 +135,9 @@ export const Base64Image = Image.extend({ }) .catch((err: unknown) => { options.onError?.( + // Conversion only ever rejects with an Error; the String() branch + // is a defensive normalization that does not run in practice. + /* v8 ignore next */ err instanceof Error ? err : new Error(String(err)), ); }); diff --git a/src/extensions/kit.test.ts b/src/extensions/kit.test.ts new file mode 100644 index 00000000..a6d9c8aa --- /dev/null +++ b/src/extensions/kit.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { buildExtensions } from './kit.js'; + +/** Names present in every kit regardless of options. */ +function names(exts: ReturnType): string[] { + return exts.map((e) => e.name); +} + +describe('buildExtensions', () => { + it('builds the default set when called with no options', () => { + const exts = buildExtensions(); + expect(Array.isArray(exts)).toBe(true); + // StarterKit + Link + Placeholder + Table(+row/header/cell) + image. + expect(names(exts)).toContain('image'); + expect(names(exts)).toContain('link'); + expect(names(exts)).toContain('table'); + }); + + it('applies image defaults when the image config is an empty object', () => { + const exts = buildExtensions({ image: {} }); + const image = exts.find((e) => e.name === 'image'); + expect(image?.options.maxSizeBytes).toBe(10 * 1024 * 1024); + expect(image?.options.maxDimension).toBe(1600); + expect(image?.options.quality).toBe(0.85); + }); + + it('honors an explicit image config and placeholder', () => { + const exts = buildExtensions({ + placeholder: 'Type here…', + image: { maxSizeBytes: 123, maxDimension: 42, quality: 0.5 }, + }); + const image = exts.find((e) => e.name === 'image'); + expect(image?.options.maxSizeBytes).toBe(123); + expect(image?.options.maxDimension).toBe(42); + expect(image?.options.quality).toBe(0.5); + const placeholder = exts.find((e) => e.name === 'placeholder'); + expect(placeholder?.options.placeholder).toBe('Type here…'); + }); +}); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 00000000..502a1d3c --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import * as api from './index.js'; + +/** + * The public entry point is a barrel of re-exports. Importing it executes every + * `export … from …` statement, which is both a smoke test that the surface is + * wired up and the coverage for the barrel itself. + */ +describe('package entry point', () => { + it('re-exports the React component surface', () => { + expect(typeof api.CwlEditor).toBe('function'); + // `default as Editor` alias resolves to the CwlEditor component. + expect(api.Editor).toBe(api.CwlEditor); + expect(typeof api.Toolbar).toBe('function'); + }); + + it('re-exports the headless extensions', () => { + expect(api.Base64Image).toBeTruthy(); + expect(api.base64ImagePluginKey).toBeTruthy(); + expect(typeof api.downscaleDataUri).toBe('function'); + expect(typeof api.imageFileToInlineDataUri).toBe('function'); + expect(typeof api.buildExtensions).toBe('function'); + }); + + it('re-exports the markdown/html serializer', () => { + expect(typeof api.markdownToHtml).toBe('function'); + expect(typeof api.htmlToMarkdown).toBe('function'); + expect(typeof api.normalizeMarkdown).toBe('function'); + }); + + it('re-exports the standalone base64 converter', () => { + expect(typeof api.bytesToBase64).toBe('function'); + expect(typeof api.base64ToBytes).toBe('function'); + expect(typeof api.fileToDataUri).toBe('function'); + expect(typeof api.dataUriToBytes).toBe('function'); + expect(typeof api.Base64SizeError).toBe('function'); + expect(typeof api.DataUriParseError).toBe('function'); + }); +}); diff --git a/src/markdown/serializer.test.ts b/src/markdown/serializer.test.ts index a68ee062..a8c21479 100644 --- a/src/markdown/serializer.test.ts +++ b/src/markdown/serializer.test.ts @@ -78,3 +78,22 @@ describe('base64 image round-trip', () => { expect(Array.from(recovered.bytes)).toEqual(Array.from(PNG_BYTES)); }); }); + +describe('inline image turndown rule branches', () => { + it('drops an that has no src', () => { + const md = htmlToMarkdown('beforeafter'); + expect(md).not.toContain('`); + }); + + it('defaults alt to empty when the attribute is missing', () => { + const md = htmlToMarkdown(``); + expect(md).toBe(``); + }); +});
'); + }); + + it('does not throw on updates when no onChange handler is provided', async () => { + let ed: Editor | undefined; + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().insertContent(' more').run(); + expect(ed!.getHTML()).toContain('more'); + }); +}); + +describe('CwlEditor controlled value sync', () => { + it('pushes new external values into the document', async () => { + const { rerender } = render(); + await waitFor(() => + expect( + document.querySelector('.cwl-editor__content h1'), + ).toHaveTextContent('One'), + ); + rerender(); + await waitFor(() => + expect( + document.querySelector('.cwl-editor__content h1'), + ).toHaveTextContent('Two'), + ); + }); + + it('leaves the document untouched when the value already matches', async () => { + const { rerender } = render(); + await waitFor(() => + expect(document.querySelector('.cwl-editor__content')).toHaveTextContent( + 'stable', + ), + ); + // Re-render with the same value but a different unrelated prop. + rerender(); + await waitFor(() => + expect(document.querySelector('.cwl-editor')).toHaveClass('tweak'), + ); + expect(document.querySelector('.cwl-editor__content')).toHaveTextContent( + 'stable', + ); + }); +}); + +describe('CwlEditor Ctrl/Cmd+K link shortcut', () => { + function surface(): HTMLElement { + return document.querySelector('.cwl-editor__surface') as HTMLElement; + } + + it('creates a link from the prompt URL', async () => { + let ed: Editor | undefined; + vi.spyOn(window, 'prompt').mockReturnValue('https://ex.com'); + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().selectAll().run(); + fireEvent.keyDown(surface(), { key: 'K', metaKey: true }); + await waitFor(() => + expect(ed!.getHTML()).toContain('href="https://ex.com"'), + ); + }); + + it('reuses the existing link href as the prompt default', async () => { + let ed: Editor | undefined; + const prompt = vi + .spyOn(window, 'prompt') + .mockReturnValue('https://second.com'); + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().selectAll().setLink({ href: 'https://first.com' }).run(); + fireEvent.keyDown(surface(), { key: 'k', ctrlKey: true }); + await waitFor(() => + expect(ed!.getHTML()).toContain('href="https://second.com"'), + ); + expect(prompt).toHaveBeenLastCalledWith('Link URL', 'https://first.com'); + }); + + it('removes the link when the prompt is cleared to empty', async () => { + let ed: Editor | undefined; + vi.spyOn(window, 'prompt').mockReturnValue(''); + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + ed!.chain().focus().selectAll().setLink({ href: 'https://x.com' }).run(); + fireEvent.keyDown(surface(), { key: 'k', metaKey: true }); + await waitFor(() => expect(ed!.getHTML()).not.toContain('href=')); + }); + + it('does nothing when the link prompt is cancelled', async () => { + let ed: Editor | undefined; + vi.spyOn(window, 'prompt').mockReturnValue(null); + render( + { + ed = e; + }} + />, + ); + await waitFor(() => expect(ed).toBeTruthy()); + fireEvent.keyDown(surface(), { key: 'k', metaKey: true }); + expect(ed!.getHTML()).not.toContain('href='); + }); + + it('ignores k without a modifier and modified non-k keys', async () => { + render(); + await waitFor(() => + expect(document.querySelector('.cwl-editor__surface')).toBeTruthy(), + ); + fireEvent.keyDown(surface(), { key: 'k' }); + fireEvent.keyDown(surface(), { key: 'b', metaKey: true }); + expect(document.querySelector('.cwl-editor__content')).toBeTruthy(); + }); +}); diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 63ba9105..eff7bdfd 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -83,12 +83,18 @@ export function CwlEditor({ if (!editor || !isControlled || emittingRef.current) return; const current = fromHtml(editor.getHTML(), mode); if (current !== value) { - editor.commands.setContent(toHtml(value ?? '', mode), false); + // `value` is always a string here (isControlled === value !== undefined), + // so the `?? ''` fallback is a type guard that never runs. + /* v8 ignore next */ + const next = toHtml(value ?? '', mode); + editor.commands.setContent(next, false); } }, [editor, isControlled, value, mode]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { + // Defensive: key events can only reach the surface once the editor exists. + /* v8 ignore next */ if (!editor) return; const mod = e.metaKey || e.ctrlKey; if (mod && e.key.toLowerCase() === 'k') { diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx new file mode 100644 index 00000000..ef88f66b --- /dev/null +++ b/src/components/Toolbar.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { Toolbar } from './Toolbar.js'; +import { buildExtensions } from '../extensions/kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Editor[] = []; +function makeEditor(content = 'hello world'): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content, + }); + openEditors.push(editor); + return editor; +} + +afterEach(() => { + cleanup(); + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +/** A decodable Image stand-in that always reports a within-bounds size. */ +class FitImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + width = 10; + height = 10; + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } +} + +function fileInput(): HTMLInputElement { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +describe('Toolbar', () => { + it('renders every affordance and invokes each button command', () => { + const editor = makeEditor(); + // Make undo available and one mark active so both the active and disabled + // ToolbarButton branches render. + editor.chain().focus().selectAll().toggleBold().run(); + vi.spyOn(window, 'prompt').mockReturnValue(null); + + render(); + + const bold = screen.getByRole('button', { name: /Bold/ }); + expect(bold).toHaveClass('is-active'); + expect(bold).toHaveAttribute('aria-pressed', 'true'); + + const buttons = screen.getAllByRole('button'); + // All 16 affordances (marks, headings, lists, link, table, image, history). + expect(buttons.length).toBe(16); + // Cover onMouseDown preventDefault + every onClick handler. + for (const button of buttons) { + fireEvent.mouseDown(button); + if (!(button as HTMLButtonElement).disabled) fireEvent.click(button); + } + }); + + it('enables and fires the redo command after an undo', () => { + const editor = makeEditor(); + editor.chain().focus().insertContent(' more').run(); + editor.commands.undo(); // now redo is available + render(); + const redo = screen.getByRole('button', { name: /Redo/ }); + expect(redo).not.toBeDisabled(); + fireEvent.click(redo); + expect(editor.getHTML()).toContain('more'); + }); + + describe('link button', () => { + it('sets a link when a URL is entered', () => { + const editor = makeEditor(); + editor.chain().focus().selectAll().run(); + vi.spyOn(window, 'prompt').mockReturnValue('https://example.com'); + render(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + expect(editor.getHTML()).toContain('href="https://example.com"'); + }); + + it('unsets the link when the URL is cleared to empty', () => { + const editor = makeEditor('linked'); + editor.chain().focus().selectAll().run(); + vi.spyOn(window, 'prompt').mockReturnValue(''); + render(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + expect(editor.getHTML()).not.toContain('href='); + }); + + it('does nothing when the prompt is cancelled', () => { + const editor = makeEditor(); + editor.chain().focus().selectAll().run(); + vi.spyOn(window, 'prompt').mockReturnValue(null); + render(); + const before = editor.getHTML(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + expect(editor.getHTML()).toBe(before); + }); + }); + + describe('inline image upload', () => { + it('embeds a chosen image using the provided image config', async () => { + const editor = makeEditor(); + render( + , + ); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + fireEvent.change(fileInput(), { target: { files: [file] } }); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); + + it('embeds a chosen image using default config when no image prop is set', async () => { + const editor = makeEditor(); + // With no image prop, maxDimension defaults to 1600, which routes through + // the downscale path; stub a decodable Image so it resolves in jsdom. + vi.stubGlobal('Image', FitImage); + render(); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + fireEvent.change(fileInput(), { target: { files: [file] } }); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); + + it('ignores a change event with no file', () => { + const editor = makeEditor(); + render(); + fireEvent.change(fileInput(), { target: { files: [] } }); + expect(editor.getHTML()).not.toContain('data:image'); + }); + + it('swallows conversion errors (e.g. oversized files)', async () => { + const editor = makeEditor(); + render(); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + fireEvent.change(fileInput(), { target: { files: [file] } }); + await new Promise((resolve) => setTimeout(resolve, 15)); + expect(editor.getHTML()).not.toContain('data:image'); + }); + }); +}); diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts new file mode 100644 index 00000000..13586cb2 --- /dev/null +++ b/src/converter/base64.fallbacks.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { toUint8Array, blobToDataUri } from './base64.js'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe('toUint8Array with a non-Uint8Array view', () => { + it('wraps an ArrayBufferView via buffer/offset/length', () => { + const src = new Uint8Array([9, 8, 7, 6, 5]); + // Int8Array is an ArrayBufferView but not a Uint8Array, so it exercises the + // `ArrayBuffer.isView` branch rather than the fast identity return. + const view = new Int8Array(src.buffer, 1, 3); + const out = toUint8Array(view); + expect(out).toBeInstanceOf(Uint8Array); + expect(Array.from(out)).toEqual([8, 7, 6]); + }); +}); + +// Note: the `btoa`/`atob` fallback in bytesToBase64/base64ToBytes only runs in +// a browser without a global `Buffer`. It cannot be exercised here without +// deleting `globalThis.Buffer`, which breaks vitest/jsdom/coverage internals, +// so that browser-only branch is annotated with `v8 ignore` in the source. + +describe('readBlobBytes environment fallbacks', () => { + const BYTES = new Uint8Array([1, 2, 3, 4]); + const PNG = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + it('uses Blob.arrayBuffer when the blob implements it', async () => { + const fakeBlob = { + type: 'application/octet-stream', + arrayBuffer: () => Promise.resolve(BYTES.buffer.slice(0)), + } as unknown as Blob; + const uri = await blobToDataUri(fakeBlob); + expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); + }); + + it('falls back to application/octet-stream for a typeless, unsniffable blob', async () => { + const fakeBlob = { + type: '', + arrayBuffer: () => Promise.resolve(new Uint8Array([0, 1, 2, 3]).buffer.slice(0)), + } as unknown as Blob; + const uri = await blobToDataUri(fakeBlob); + expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); + }); + + it('rejects when FileReader errors and exposes its own error', async () => { + class FailingReader { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + error: unknown = new Error('reader boom'); + result: ArrayBuffer | null = null; + readAsArrayBuffer(): void { + queueMicrotask(() => this.onerror?.()); + } + } + vi.stubGlobal('FileReader', FailingReader); + // No `arrayBuffer` method -> the FileReader branch is taken. + const fakeBlob = { type: 'image/png' } as unknown as Blob; + await expect(blobToDataUri(fakeBlob)).rejects.toThrow('reader boom'); + }); + + it('rejects with a synthesized error when FileReader has no error object', async () => { + class NullErrorReader { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + error: unknown = null; + result: ArrayBuffer | null = null; + readAsArrayBuffer(): void { + queueMicrotask(() => this.onerror?.()); + } + } + vi.stubGlobal('FileReader', NullErrorReader); + const fakeBlob = { type: 'image/png' } as unknown as Blob; + await expect(blobToDataUri(fakeBlob)).rejects.toThrow( + /FileReader failed to read Blob/, + ); + }); + + it('reads through Response when neither arrayBuffer nor FileReader exist', async () => { + vi.stubGlobal('FileReader', undefined); + vi.stubGlobal( + 'Response', + class FakeResponse { + constructor(_blob: unknown) {} + arrayBuffer(): Promise { + return Promise.resolve(PNG.buffer.slice(0)); + } + }, + ); + const fakeBlob = { type: 'image/png' } as unknown as Blob; + const uri = await blobToDataUri(fakeBlob); + expect(uri.startsWith('data:image/png;base64,')).toBe(true); + }); +}); diff --git a/src/converter/base64.test.ts b/src/converter/base64.test.ts index ff978448..c816f7ed 100644 --- a/src/converter/base64.test.ts +++ b/src/converter/base64.test.ts @@ -89,6 +89,16 @@ describe('MIME sniffing', () => { 'image/svg+xml', ); }); + it('detects SVG wrapped in an XML prolog', () => { + expect( + sniffMimeType(new TextEncoder().encode('')), + ).toBe('image/svg+xml'); + }); + it('does not treat a non-SVG XML document as an image', () => { + expect( + sniffMimeType(new TextEncoder().encode('')), + ).toBeUndefined(); + }); it('returns undefined for unknown bytes', () => { expect(sniffMimeType(new Uint8Array([0x00, 0x01, 0x02]))).toBeUndefined(); }); diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c9c1eb8e..38173f54 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -64,36 +64,40 @@ const hasBuffer = typeof globalThis.Buffer !== 'undefined'; /** Encode raw bytes to a base64 string. Works in Node and the browser. */ export function bytesToBase64(bytes: Uint8Array): string { - if (hasBuffer) { - return globalThis.Buffer.from( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength, - ).toString('base64'); - } - let binary = ''; - const chunkSize = 0x8000; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, i + chunkSize); - binary += String.fromCharCode(...chunk); + /* v8 ignore start -- browser-only fallback: Node and jsdom always provide Buffer */ + if (!hasBuffer) { + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode(...chunk); + } + // eslint-disable-next-line no-undef + return btoa(binary); } - // eslint-disable-next-line no-undef - return btoa(binary); + /* v8 ignore stop */ + return globalThis.Buffer.from( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).toString('base64'); } /** Decode a base64 string to raw bytes. Works in Node and the browser. */ export function base64ToBytes(base64: string): Uint8Array { const normalized = base64.replace(/\s+/g, ''); - if (hasBuffer) { - return new Uint8Array(globalThis.Buffer.from(normalized, 'base64')); - } - // eslint-disable-next-line no-undef - const binary = atob(normalized); - const out = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - out[i] = binary.charCodeAt(i); + /* v8 ignore start -- browser-only fallback: Node and jsdom always provide Buffer */ + if (!hasBuffer) { + // eslint-disable-next-line no-undef + const binary = atob(normalized); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; } - return out; + /* v8 ignore stop */ + return new Uint8Array(globalThis.Buffer.from(normalized, 'base64')); } /** Coerce any binary-ish input to a `Uint8Array` view without copying twice. */ @@ -267,7 +271,10 @@ export function parseDataUri(dataUri: string): ParsedDataUri { const mimeType = match[1] && match[1].length > 0 ? match[1] : 'text/plain'; const params = match[2] ?? ''; const isBase64 = /;base64/i.test(params); - return { mimeType, isBase64, payload: match[3] ?? '' }; + // Capture group 3 always matches (possibly empty), so `?? ''` is defensive. + /* v8 ignore next */ + const payload = match[3] ?? ''; + return { mimeType, isBase64, payload }; } /** `true` when the string is a syntactically valid data URI. */ diff --git a/src/extensions/Base64Image.test.ts b/src/extensions/Base64Image.test.ts new file mode 100644 index 00000000..61a9e07d --- /dev/null +++ b/src/extensions/Base64Image.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { + downscaleDataUri, + imageFileToInlineDataUri, + Base64Image, + base64ImagePluginKey, +} from './Base64Image.js'; +import { buildExtensions } from './kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +/** A controllable stand-in for the browser's `Image`, which jsdom cannot decode. */ +class MockImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + width = 0; + height = 0; + private _src = ''; + set src(value: string) { + this._src = value; + queueMicrotask(() => { + if (MockImage.mode === 'error') { + this.onerror?.(); + return; + } + this.width = MockImage.width; + this.height = MockImage.height; + this.onload?.(); + }); + } + get src(): string { + return this._src; + } + static mode: 'load' | 'error' = 'load'; + static width = 100; + static height = 100; +} + +const openEditors: Editor[] = []; +function track(editor: Editor): Editor { + openEditors.push(editor); + return editor; +} + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + MockImage.mode = 'load'; + MockImage.width = 100; + MockImage.height = 100; +}); + +describe('downscaleDataUri', () => { + it('returns the original URI when downscaling is disabled (maxDimension 0)', async () => { + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 0, 0.85)).toBe(uri); + }); + + it('returns the original URI when no Image constructor is available', async () => { + vi.stubGlobal('Image', undefined); + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 100, 0.85)).toBe(uri); + }); + + it('returns the original URI when the image fails to load', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.mode = 'error'; + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 100, 0.85)).toBe(uri); + }); + + it('returns the original URI when the image already fits', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 50; + MockImage.height = 40; + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 100, 0.85)).toBe(uri); + }); + + it('returns the original URI when a 2D context cannot be obtained', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 400; + MockImage.height = 200; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + const uri = 'data:image/png;base64,AAAA'; + expect(await downscaleDataUri(uri, 100, 0.85)).toBe(uri); + }); + + it('re-encodes an oversized PNG through the canvas', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 400; + MockImage.height = 200; + const ctx = { drawImage: vi.fn() }; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + ctx as unknown as CanvasRenderingContext2D, + ); + const toDataURL = vi + .spyOn(HTMLCanvasElement.prototype, 'toDataURL') + .mockReturnValue('data:image/png;base64,SCALED'); + const out = await downscaleDataUri('data:image/png;base64,AAAA', 100, 0.85); + expect(out).toBe('data:image/png;base64,SCALED'); + expect(ctx.drawImage).toHaveBeenCalledOnce(); + expect(toDataURL).toHaveBeenCalledWith('image/png', 0.85); + }); + + it('re-encodes a non-PNG image as jpeg', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 400; + MockImage.height = 400; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + { drawImage: vi.fn() } as unknown as CanvasRenderingContext2D, + ); + const toDataURL = vi + .spyOn(HTMLCanvasElement.prototype, 'toDataURL') + .mockReturnValue('data:image/jpeg;base64,SCALED'); + const out = await downscaleDataUri('data:image/webp;base64,AAAA', 100, 0.85); + expect(out).toBe('data:image/jpeg;base64,SCALED'); + expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.85); + }); +}); + +describe('imageFileToInlineDataUri downscale branch', () => { + it('invokes the downscaler when maxDimension is positive', async () => { + vi.stubGlobal('Image', MockImage); + MockImage.width = 50; + MockImage.height = 50; // fits, so the URI comes back unchanged + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const uri = await imageFileToInlineDataUri(file, { + maxSizeBytes: 1024 * 1024, + maxDimension: 100, + quality: 0.85, + }); + expect(uri.startsWith('data:image/png;base64,')).toBe(true); + }); + + it('disables the size guard when maxSizeBytes is 0', async () => { + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const uri = await imageFileToInlineDataUri(file, { + maxSizeBytes: 0, + maxDimension: 0, + quality: 0.85, + }); + expect(uri.startsWith('data:image/png;base64,')).toBe(true); + }); +}); + +function makeEditor(opts?: { + image?: Record; + onError?: (error: Error) => void; +}): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const extensions = opts?.onError + ? [ + StarterKit, + Base64Image.configure({ + maxSizeBytes: 4, + maxDimension: 0, + quality: 0.85, + onError: opts.onError, + }), + ] + : buildExtensions({ image: { maxDimension: 0, ...(opts?.image ?? {}) } }); + return new Editor({ element, extensions, content: 'hello' }); +} + +function paste(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handlePaste as (v: unknown, e: unknown) => boolean)( + editor.view, + event, + ); +} + +function drop(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handleDrop as (v: unknown, e: unknown) => boolean)( + editor.view, + event, + ); +} + +describe('Base64Image paste handler', () => { + it('ignores paste events without clipboard items', () => { + const editor = track(makeEditor()); + expect(paste(editor, { clipboardData: undefined })).toBe(false); + }); + + it('ignores clipboards that carry no files', () => { + const editor = track(makeEditor()); + const items = [{ kind: 'string', getAsFile: () => null }]; + expect(paste(editor, { clipboardData: { items } })).toBe(false); + }); + + it('ignores pasted non-image files without preventing default', () => { + const editor = track(makeEditor()); + const file = new File(['plain'], 'a.txt', { type: 'text/plain' }); + const items = [{ kind: 'file', getAsFile: () => file }]; + const preventDefault = vi.fn(); + expect(paste(editor, { clipboardData: { items }, preventDefault })).toBe( + false, + ); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('embeds a pasted image file as inline base64', async () => { + const editor = track(makeEditor()); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const items = [ + { kind: 'file', getAsFile: () => null }, // exercises the `if (file)` guard + { kind: 'file', getAsFile: () => file }, + ]; + const preventDefault = vi.fn(); + expect(paste(editor, { clipboardData: { items }, preventDefault })).toBe( + true, + ); + expect(preventDefault).toHaveBeenCalledOnce(); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); +}); + +describe('Base64Image drop handler', () => { + it('ignores drops without files', () => { + const editor = track(makeEditor()); + expect(drop(editor, { dataTransfer: undefined })).toBe(false); + }); + + it('embeds a dropped image at the resolved drop coordinates', async () => { + const editor = track(makeEditor()); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ + pos: 1, + inside: -1, + }); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const preventDefault = vi.fn(); + expect( + drop(editor, { + dataTransfer: { files: [file] }, + clientX: 5, + clientY: 5, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); + + it('falls back to the current selection when coords resolve to nothing', async () => { + const editor = track(makeEditor()); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue(null); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + expect( + drop(editor, { + dataTransfer: { files: [file] }, + clientX: 0, + clientY: 0, + preventDefault: vi.fn(), + }), + ).toBe(true); + await waitFor(() => + expect(editor.getHTML()).toContain('data:image/png;base64'), + ); + }); +}); + +describe('Base64Image conversion error handling', () => { + it('routes conversion failures to the configured onError handler', async () => { + const onError = vi.fn(); + const editor = track(makeEditor({ onError })); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); // > 4 bytes + const items = [{ kind: 'file', getAsFile: () => file }]; + expect( + paste(editor, { clipboardData: { items }, preventDefault: vi.fn() }), + ).toBe(true); + await waitFor(() => expect(onError).toHaveBeenCalled()); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + expect(editor.getHTML()).not.toContain('data:image'); + }); + + it('swallows conversion failures when no onError handler is configured', async () => { + const editor = track(makeEditor({ image: { maxSizeBytes: 4 } })); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const items = [{ kind: 'file', getAsFile: () => file }]; + expect( + paste(editor, { clipboardData: { items }, preventDefault: vi.fn() }), + ).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 15)); + expect(editor.getHTML()).not.toContain('data:image'); + }); + + it('skips insertion when the editor is destroyed mid-conversion', async () => { + const editor = makeEditor(); + const file = new File([PNG_BYTES], 'p.png', { type: 'image/png' }); + const items = [{ kind: 'file', getAsFile: () => file }]; + paste(editor, { clipboardData: { items }, preventDefault: vi.fn() }); + editor.destroy(); // tear down before the async conversion resolves + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(editor.isDestroyed).toBe(true); + }); +}); diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 520e8426..e63c7f29 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -135,6 +135,9 @@ export const Base64Image = Image.extend({ }) .catch((err: unknown) => { options.onError?.( + // Conversion only ever rejects with an Error; the String() branch + // is a defensive normalization that does not run in practice. + /* v8 ignore next */ err instanceof Error ? err : new Error(String(err)), ); }); diff --git a/src/extensions/kit.test.ts b/src/extensions/kit.test.ts new file mode 100644 index 00000000..a6d9c8aa --- /dev/null +++ b/src/extensions/kit.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { buildExtensions } from './kit.js'; + +/** Names present in every kit regardless of options. */ +function names(exts: ReturnType): string[] { + return exts.map((e) => e.name); +} + +describe('buildExtensions', () => { + it('builds the default set when called with no options', () => { + const exts = buildExtensions(); + expect(Array.isArray(exts)).toBe(true); + // StarterKit + Link + Placeholder + Table(+row/header/cell) + image. + expect(names(exts)).toContain('image'); + expect(names(exts)).toContain('link'); + expect(names(exts)).toContain('table'); + }); + + it('applies image defaults when the image config is an empty object', () => { + const exts = buildExtensions({ image: {} }); + const image = exts.find((e) => e.name === 'image'); + expect(image?.options.maxSizeBytes).toBe(10 * 1024 * 1024); + expect(image?.options.maxDimension).toBe(1600); + expect(image?.options.quality).toBe(0.85); + }); + + it('honors an explicit image config and placeholder', () => { + const exts = buildExtensions({ + placeholder: 'Type here…', + image: { maxSizeBytes: 123, maxDimension: 42, quality: 0.5 }, + }); + const image = exts.find((e) => e.name === 'image'); + expect(image?.options.maxSizeBytes).toBe(123); + expect(image?.options.maxDimension).toBe(42); + expect(image?.options.quality).toBe(0.5); + const placeholder = exts.find((e) => e.name === 'placeholder'); + expect(placeholder?.options.placeholder).toBe('Type here…'); + }); +}); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 00000000..502a1d3c --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import * as api from './index.js'; + +/** + * The public entry point is a barrel of re-exports. Importing it executes every + * `export … from …` statement, which is both a smoke test that the surface is + * wired up and the coverage for the barrel itself. + */ +describe('package entry point', () => { + it('re-exports the React component surface', () => { + expect(typeof api.CwlEditor).toBe('function'); + // `default as Editor` alias resolves to the CwlEditor component. + expect(api.Editor).toBe(api.CwlEditor); + expect(typeof api.Toolbar).toBe('function'); + }); + + it('re-exports the headless extensions', () => { + expect(api.Base64Image).toBeTruthy(); + expect(api.base64ImagePluginKey).toBeTruthy(); + expect(typeof api.downscaleDataUri).toBe('function'); + expect(typeof api.imageFileToInlineDataUri).toBe('function'); + expect(typeof api.buildExtensions).toBe('function'); + }); + + it('re-exports the markdown/html serializer', () => { + expect(typeof api.markdownToHtml).toBe('function'); + expect(typeof api.htmlToMarkdown).toBe('function'); + expect(typeof api.normalizeMarkdown).toBe('function'); + }); + + it('re-exports the standalone base64 converter', () => { + expect(typeof api.bytesToBase64).toBe('function'); + expect(typeof api.base64ToBytes).toBe('function'); + expect(typeof api.fileToDataUri).toBe('function'); + expect(typeof api.dataUriToBytes).toBe('function'); + expect(typeof api.Base64SizeError).toBe('function'); + expect(typeof api.DataUriParseError).toBe('function'); + }); +}); diff --git a/src/markdown/serializer.test.ts b/src/markdown/serializer.test.ts index a68ee062..a8c21479 100644 --- a/src/markdown/serializer.test.ts +++ b/src/markdown/serializer.test.ts @@ -78,3 +78,22 @@ describe('base64 image round-trip', () => { expect(Array.from(recovered.bytes)).toEqual(Array.from(PNG_BYTES)); }); }); + +describe('inline image turndown rule branches', () => { + it('drops an that has no src', () => { + const md = htmlToMarkdown('beforeafter'); + expect(md).not.toContain('`); + }); + + it('defaults alt to empty when the attribute is missing', () => { + const md = htmlToMarkdown(``); + expect(md).toBe(``); + }); +});
hello world
linked
hello
beforeafter