diff --git a/.github/workflows/visual-baseline.yml b/.github/workflows/visual-baseline.yml new file mode 100644 index 0000000000..acbd002a87 --- /dev/null +++ b/.github/workflows/visual-baseline.yml @@ -0,0 +1,78 @@ +name: Visual Baselines + +permissions: + contents: read + +on: + workflow_dispatch: + workflow_call: + +concurrency: + group: visual-baseline + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-24.04 + env: + SD_VISUAL_TESTING_R2_ACCOUNT_ID: ${{ secrets.SD_VISUAL_TESTING_R2_ACCOUNT_ID }} + SD_VISUAL_TESTING_R2_ACCESS_KEY_ID: ${{ secrets.SD_VISUAL_TESTING_R2_ACCESS_KEY_ID }} + SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY: ${{ secrets.SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY }} + SD_VISUAL_TESTING_R2_BUCKET: ${{ secrets.SD_VISUAL_TESTING_R2_BUCKET }} + steps: + - uses: actions/checkout@v6 + with: + ref: stable + + - name: Get visual test infrastructure from main + run: | + git fetch origin main --depth=1 + git checkout FETCH_HEAD -- tests/visual + sed -i '/^packages:/a\ - tests/visual' pnpm-workspace.yaml + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --ignore-scripts --no-frozen-lockfile + + - name: Build SuperDoc + run: pnpm build + + - name: Get Playwright version + id: pw + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT + working-directory: tests/visual + + - name: Cache Playwright browsers + uses: actions/cache@v5 + id: pw-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} + + - name: Install Playwright browsers + if: steps.pw-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium firefox webkit + working-directory: tests/visual + + - name: Install Playwright system deps + if: steps.pw-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium firefox webkit + working-directory: tests/visual + + - name: Download test documents from R2 + run: pnpm docs:download + working-directory: tests/visual + + - name: Generate baselines + run: pnpm test:update + working-directory: tests/visual + + - name: Upload baselines to R2 + run: pnpm baseline:upload + working-directory: tests/visual diff --git a/.github/workflows/visual-test.yml b/.github/workflows/visual-test.yml new file mode 100644 index 0000000000..863210aaec --- /dev/null +++ b/.github/workflows/visual-test.yml @@ -0,0 +1,79 @@ +name: Visual Tests + +permissions: + contents: read + +on: + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: visual-test-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + visual: + runs-on: ubuntu-latest + env: + SD_VISUAL_TESTING_R2_ACCOUNT_ID: ${{ secrets.SD_VISUAL_TESTING_R2_ACCOUNT_ID }} + SD_VISUAL_TESTING_R2_ACCESS_KEY_ID: ${{ secrets.SD_VISUAL_TESTING_R2_ACCESS_KEY_ID }} + SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY: ${{ secrets.SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY }} + SD_VISUAL_TESTING_R2_BUCKET: ${{ secrets.SD_VISUAL_TESTING_R2_BUCKET }} + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --ignore-scripts + + - name: Build SuperDoc + run: pnpm build + + - name: Get Playwright version + id: pw + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT + working-directory: tests/visual + + - name: Cache Playwright browsers + uses: actions/cache@v5 + id: pw-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} + + - name: Install Playwright browsers + if: steps.pw-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium firefox webkit + working-directory: tests/visual + + - name: Install Playwright system deps + if: steps.pw-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium firefox webkit + working-directory: tests/visual + + - name: Download baselines from R2 + run: pnpm baseline:download + working-directory: tests/visual + + - name: Download test documents from R2 + run: pnpm docs:download + working-directory: tests/visual + + - name: Run visual tests + run: pnpm test + working-directory: tests/visual + + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual-test-report + path: tests/visual/playwright-report/ + retention-days: 14 diff --git a/CLAUDE.md b/CLAUDE.md index e70c7cef26..7972865b45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ packages/ collaboration-yjs/ Collaboration server shared/ Internal utilities e2e-tests/ Playwright tests +tests/visual/ Visual regression tests (Playwright + R2 baselines) ``` ## Where to Look @@ -62,6 +63,7 @@ e2e-tests/ Playwright tests | DOCX import/export | `super-editor/src/core/super-converter/` | | Style resolution | `layout-engine/style-engine/` | | Main entry point (Vue) | `superdoc/src/SuperDoc.vue` | +| Visual regression tests | `tests/visual/` (see its CLAUDE.md) | ## Style Resolution Boundary diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cd136fc65..985eccda3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -428,7 +428,7 @@ importers: version: 14.0.3 mintlify: specifier: ^4.2.331 - version: 4.2.331(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) + version: 4.2.331(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.8)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) remark-mdx: specifier: ^3.1.1 version: 3.1.1 @@ -1347,6 +1347,28 @@ importers: shared/url-validation: {} + tests/visual: + dependencies: + superdoc: + specifier: workspace:* + version: link:../../packages/superdoc + devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.988.0 + version: 3.988.0 + '@playwright/test': + specifier: 'catalog:' + version: 1.58.1 + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + tsx: + specifier: 'catalog:' + version: 4.21.0 + vite: + specifier: 'catalog:' + version: 7.3.1(@types/node@22.19.8)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + packages: '@acemir/cssom@0.9.31': @@ -1397,6 +1419,169 @@ packages: '@asyncapi/specs@6.8.1': resolution: {integrity: sha512-czHoAk3PeXTLR+X8IUaD+IpT+g+zUvkcgMDJVothBsan+oHN3jfcFcFUNdOPAAFoUCQN1hXF1dWuphWy05THlA==} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.988.0': + resolution: {integrity: sha512-mt7AdkieJJ5hEKeCxH4sdTTd679shUjo/cUvNY0fUHgQIPZa1jRuekTXnRytRrEwdrZWJDx56n1S8ism2uX7jg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-sso@3.988.0': + resolution: {integrity: sha512-ThqQ7aF1k0Zz4yJRwegHw+T1rM3a7ZPvvEUSEdvn5Z8zTeWgJAbtqW/6ejPsMLmFOlHgNcwDQN/e69OvtEOoIQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.8': + resolution: {integrity: sha512-WeYJ2sfvRLbbUIrjGMUXcEHGu5SJk53jz3K9F8vFP42zWyROzPJ2NB6lMu9vWl5hnMwzwabX7pJc9Euh3JyMGw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.0': + resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.6': + resolution: {integrity: sha512-+dYEBWgTqkQQHFUllvBL8SLyXyLKWdxLMD1LmKJRvmb0NMJuaJFG/qg78C+LE67eeGbipYcE+gJ48VlLBGHlMw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.8': + resolution: {integrity: sha512-z3QkozMV8kOFisN2pgRag/f0zPDrw96mY+ejAM0xssV/+YQ2kklbylRNI/TcTQUDnGg0yPxNjyV6F2EM2zPTwg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.6': + resolution: {integrity: sha512-6tkIYFv3sZH1XsjQq+veOmx8XWRnyqTZ5zx/sMtdu/xFRIzrJM1Y2wAXeCJL1rhYSB7uJSZ1PgALI2WVTj78ow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.6': + resolution: {integrity: sha512-LXsoBoaTSGHdRCQXlWSA0CHHh05KWncb592h9ElklnPus++8kYn1Ic6acBR4LKFQ0RjjMVgwe5ypUpmTSUOjPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.7': + resolution: {integrity: sha512-PuJ1IkISG7ZDpBFYpGotaay6dYtmriBYuHJ/Oko4VHxh8YN5vfoWnMNYFEWuzOfyLmP7o9kDVW0BlYIpb3skvw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.6': + resolution: {integrity: sha512-Yf34cjIZJHVnD92jnVYy3tNjM+Q4WJtffLK2Ehn0nKpZfqd1m7SI0ra22Lym4C53ED76oZENVSS2wimoXJtChQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.6': + resolution: {integrity: sha512-2+5UVwUYdD4BBOkLpKJ11MQ8wQeyJGDVMDRH5eWOULAh9d6HJq07R69M/mNNMC9NTjr3mB1T0KGDn4qyQh5jzg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.6': + resolution: {integrity: sha512-pdJzwKtlDxBnvZ04pWMqttijmkUIlwOsS0GcxCjzEVyUMpARysl0S0ks74+gs2Pdev3Ujz+BTAjOc1tQgAxGqA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.3': + resolution: {integrity: sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.3': + resolution: {integrity: sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.972.6': + resolution: {integrity: sha512-g5DadWO58IgQKuq+uLL3pLohOwLiA67gB49xj8694BW+LpHLNu/tjCqwLfIaWvZyABbv0LXeNiiTuTnjdgkZWw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.3': + resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.3': + resolution: {integrity: sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.3': + resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.3': + resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.8': + resolution: {integrity: sha512-/yJdahpN/q3Dc88qXBTQVZfnXryLnxfCoP4hGClbKjuF0VCMxrz3il7sj0GhIkEQt5OM5+lA88XrvbjjuwSxIg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.3': + resolution: {integrity: sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.8': + resolution: {integrity: sha512-3PGL+Kvh1PhB0EeJeqNqOWQgipdqFheO4OUKc6aYiFwEpM5t9AyE5hjjxZ5X6iSj8JiduWFZLPwASzF6wQRgFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.988.0': + resolution: {integrity: sha512-OgYV9k1oBCQ6dOM+wWAMNNehXA8L4iwr7ydFV+JDHyuuu0Ko7tDXnLEtEmeQGYRcAFU3MGasmlBkMB8vf4POrg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.3': + resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.988.0': + resolution: {integrity: sha512-SXwhbe2v0Jno7QLIBmZWAL2eVzGmXkfLLy0WkM6ZJVhE0SFUcnymDwMUA1oMDUvyArzvKBiU8khQ2ImheCKOHQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.988.0': + resolution: {integrity: sha512-xvXVlRVKHnF2h6fgWBm64aPP5J+58aJyGfRrQa/uFh8a9mcK68mLfJOYq+ZSxQy/UN3McafJ2ILAy7IWzT9kRw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.1': + resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.2': + resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.988.0': + resolution: {integrity: sha512-HuXu4boeUWU0DQiLslbgdvuQ4ZMCo4Lsk97w8BIUokql2o9MvjE5dwqI5pzGt0K7afO1FybjidUQVTMLuZNTOA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.4': + resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.3': + resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + + '@aws-sdk/util-user-agent-node@3.972.6': + resolution: {integrity: sha512-966xH8TPqkqOXP7EwnEThcKKz0SNP9kVJBKd9M8bNXE4GSqVouMKKnFBwYnzbWVKuLXubzX5seokcX4a0JLJIA==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.4': + resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@azure/abort-controller@2.1.2': resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} engines: {node: '>=18.0.0'} @@ -2055,111 +2240,248 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.33.5': resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.0.4': resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.0.4': resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.0.4': resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.33.5': resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.33.5': resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -3154,6 +3476,222 @@ packages: resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} engines: {node: '>=12'} + '@smithy/abort-controller@4.2.8': + resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader-native@4.2.1': + resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.0': + resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.6': + resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.0': + resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.8': + resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.8': + resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.8': + resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.8': + resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.8': + resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.8': + resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.9': + resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.9': + resolution: {integrity: sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.8': + resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.8': + resolution: {integrity: sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.8': + resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.8': + resolution: {integrity: sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.8': + resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.14': + resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.31': + resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.9': + resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.8': + resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.8': + resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.10': + resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.8': + resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.8': + resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.8': + resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.8': + resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.8': + resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.3': + resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.8': + resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.11.3': + resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.12.0': + resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.8': + resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.30': + resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.33': + resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.8': + resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.8': + resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.8': + resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.12': + resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.8': + resolution: {integrity: sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -4259,6 +4797,9 @@ packages: bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + boxen@7.0.0: resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} engines: {node: '>=14.16'} @@ -5518,6 +6059,10 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.3.4: + resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -9114,6 +9659,10 @@ packages: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -9411,6 +9960,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + strtok3@6.3.0: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} engines: {node: '>=10'} @@ -9486,7 +10038,7 @@ packages: tar@6.1.15: resolution: {integrity: sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} @@ -10576,6 +11128,491 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.988.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.8 + '@aws-sdk/credential-provider-node': 3.972.7 + '@aws-sdk/middleware-bucket-endpoint': 3.972.3 + '@aws-sdk/middleware-expect-continue': 3.972.3 + '@aws-sdk/middleware-flexible-checksums': 3.972.6 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-location-constraint': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-sdk-s3': 3.972.8 + '@aws-sdk/middleware-ssec': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.8 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/signature-v4-multi-region': 3.988.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.988.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.6 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/eventstream-serde-browser': 4.2.8 + '@smithy/eventstream-serde-config-resolver': 4.3.8 + '@smithy/eventstream-serde-node': 4.2.8 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-blob-browser': 4.2.9 + '@smithy/hash-node': 4.2.8 + '@smithy/hash-stream-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/md5-js': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.988.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.8 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.8 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.988.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.6 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.8': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/xml-builder': 3.972.4 + '@smithy/core': 3.23.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.0': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.6': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/types': 3.973.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.10 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.12 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.6': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/credential-provider-env': 3.972.6 + '@aws-sdk/credential-provider-http': 3.972.8 + '@aws-sdk/credential-provider-login': 3.972.6 + '@aws-sdk/credential-provider-process': 3.972.6 + '@aws-sdk/credential-provider-sso': 3.972.6 + '@aws-sdk/credential-provider-web-identity': 3.972.6 + '@aws-sdk/nested-clients': 3.988.0 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.6': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/nested-clients': 3.988.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.7': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.6 + '@aws-sdk/credential-provider-http': 3.972.8 + '@aws-sdk/credential-provider-ini': 3.972.6 + '@aws-sdk/credential-provider-process': 3.972.6 + '@aws-sdk/credential-provider-sso': 3.972.6 + '@aws-sdk/credential-provider-web-identity': 3.972.6 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.6': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.6': + dependencies: + '@aws-sdk/client-sso': 3.988.0 + '@aws-sdk/core': 3.973.8 + '@aws-sdk/token-providers': 3.988.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.6': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/nested-clients': 3.988.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.972.6': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.973.8 + '@aws-sdk/crc64-nvme': 3.972.0 + '@aws-sdk/types': 3.973.1 + '@smithy/is-array-buffer': 4.2.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/core': 3.23.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.988.0 + '@smithy/core': 3.23.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.988.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.8 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.8 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.988.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.6 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.988.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.8 + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.988.0': + dependencies: + '@aws-sdk/core': 3.973.8 + '@aws-sdk/nested-clients': 3.988.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.1': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.2': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.988.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.4': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.972.6': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.8 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.4': + dependencies: + '@smithy/types': 4.12.0 + fast-xml-parser: 5.3.4 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.3': {} + '@azure/abort-controller@2.1.2': dependencies: tslib: 2.8.1 @@ -11224,205 +12261,301 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.0.0': {} + '@img/sharp-darwin-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.0.4 optional: true - '@img/sharp-darwin-x64@0.33.5': + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true '@img/sharp-libvips-darwin-arm64@1.0.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + '@img/sharp-libvips-darwin-x64@1.0.4': optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + '@img/sharp-libvips-linux-arm64@1.0.4': optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + '@img/sharp-libvips-linux-arm@1.0.5': optional: true + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + '@img/sharp-libvips-linux-s390x@1.0.4': optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + '@img/sharp-libvips-linux-x64@1.0.4': optional: true + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.0.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + '@img/sharp-linux-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.0.4 optional: true + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + '@img/sharp-linux-arm@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.0.5 optional: true + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + '@img/sharp-linux-s390x@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.0.4 optional: true + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + '@img/sharp-linux-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.0.4 optional: true + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + '@img/sharp-linuxmusl-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + '@img/sharp-linuxmusl-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.0.4 optional: true + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + '@img/sharp-wasm32@0.33.5': dependencies: '@emnapi/runtime': 1.8.1 optional: true + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + '@img/sharp-win32-ia32@0.33.5': optional: true + '@img/sharp-win32-ia32@0.34.5': + optional: true + '@img/sharp-win32-x64@0.33.5': optional: true + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@22.19.2)': + '@inquirer/checkbox@4.3.2(@types/node@22.19.8)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/confirm@5.1.21(@types/node@22.19.2)': + '@inquirer/confirm@5.1.21(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/core@10.3.2(@types/node@22.19.2)': + '@inquirer/core@10.3.2(@types/node@22.19.8)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.8) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/editor@4.2.23(@types/node@22.19.2)': + '@inquirer/editor@4.2.23(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/external-editor': 1.0.3(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/expand@4.0.23(@types/node@22.19.2)': + '@inquirer/expand@4.0.23(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/external-editor@1.0.3(@types/node@22.19.2)': + '@inquirer/external-editor@1.0.3(@types/node@22.19.8)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@22.19.2)': + '@inquirer/input@4.3.1(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/number@3.0.23(@types/node@22.19.2)': + '@inquirer/number@3.0.23(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/password@4.0.23(@types/node@22.19.2)': + '@inquirer/password@4.0.23(@types/node@22.19.8)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/prompts@7.9.0(@types/node@22.19.2)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) + '@inquirer/prompts@7.9.0(@types/node@22.19.8)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.19.8) + '@inquirer/confirm': 5.1.21(@types/node@22.19.8) + '@inquirer/editor': 4.2.23(@types/node@22.19.8) + '@inquirer/expand': 4.0.23(@types/node@22.19.8) + '@inquirer/input': 4.3.1(@types/node@22.19.8) + '@inquirer/number': 3.0.23(@types/node@22.19.8) + '@inquirer/password': 4.0.23(@types/node@22.19.8) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.8) + '@inquirer/search': 3.2.2(@types/node@22.19.8) + '@inquirer/select': 4.4.2(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/rawlist@4.1.11(@types/node@22.19.2)': + '@inquirer/rawlist@4.1.11(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/search@3.2.2(@types/node@22.19.2)': + '@inquirer/search@3.2.2(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/select@4.4.2(@types/node@22.19.2)': + '@inquirer/select@4.4.2(@types/node@22.19.8)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/type@3.0.10(@types/node@22.19.2)': + '@inquirer/type@3.0.10(@types/node@22.19.8)': optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 '@isaacs/balanced-match@4.0.1': {} @@ -11774,9 +12907,9 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@mintlify/cli@4.0.935(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3)': + '@mintlify/cli@4.0.935(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.8)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) + '@inquirer/prompts': 7.9.0(@types/node@22.19.8) '@mintlify/common': 1.0.713(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/link-rot': 3.0.872(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/models': 0.0.268 @@ -11790,7 +12923,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.11)(react@19.2.3) - inquirer: 12.3.0(@types/node@22.19.2) + inquirer: 12.3.0(@types/node@22.19.8) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 react: 19.2.3 @@ -12849,6 +13982,344 @@ snapshots: dependencies: escape-string-regexp: 5.0.0 + '@smithy/abort-controller@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@4.2.1': + dependencies: + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.6': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/core@3.23.0': + dependencies: + '@smithy/middleware-serde': 4.2.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.8': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.8': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.8': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.8': + dependencies: + '@smithy/eventstream-codec': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.9': + dependencies: + '@smithy/chunked-blob-reader': 5.2.0 + '@smithy/chunked-blob-reader-native': 4.2.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.8': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.14': + dependencies: + '@smithy/core': 3.23.0 + '@smithy/middleware-serde': 4.2.9 + '@smithy/node-config-provider': 4.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.31': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/service-error-classification': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.8': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.10': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + + '@smithy/shared-ini-file-loader@4.4.3': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.8': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.11.3': + dependencies: + '@smithy/core': 3.23.0 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-stack': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.12 + tslib: 2.8.1 + + '@smithy/types@4.12.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.8': + dependencies: + '@smithy/querystring-parser': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.30': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.33': + dependencies: + '@smithy/config-resolver': 4.4.6 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.8': + dependencies: + '@smithy/service-error-classification': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.12': + dependencies: + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.10 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.8': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} '@stoplight/better-ajv-errors@1.0.3(ajv@8.17.1)': @@ -14258,6 +15729,8 @@ snapshots: bottleneck@2.19.5: {} + bowser@2.14.1: {} + boxen@7.0.0: dependencies: ansi-align: 3.0.1 @@ -15864,6 +17337,10 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-parser@5.3.4: + dependencies: + strnum: 2.1.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -16862,12 +18339,12 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@12.3.0(@types/node@22.19.2): + inquirer@12.3.0(@types/node@22.19.8): dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - '@types/node': 22.19.2 + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/prompts': 7.9.0(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) + '@types/node': 22.19.8 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -18583,9 +20060,9 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 - mintlify@4.2.331(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3): + mintlify@4.2.331(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.8)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3): dependencies: - '@mintlify/cli': 4.0.935(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) + '@mintlify/cli': 4.0.935(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.8)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -20457,7 +21934,7 @@ snapshots: dependencies: decode-ico: 0.4.1 ico-endec: 0.1.6 - sharp: 0.33.5 + sharp: 0.34.5 sharp@0.33.5: dependencies: @@ -20485,6 +21962,37 @@ snapshots: '@img/sharp-win32-ia32': 0.33.5 '@img/sharp-win32-x64': 0.33.5 + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -20846,6 +22354,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.1.2: {} + strtok3@6.3.0: dependencies: '@tokenizer/token': 0.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 224eacec5b..018619d2af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - e2e-tests - e2e-tests/templates/vue + - tests/visual - apps/* - packages/**/* - shared/* diff --git a/tests/visual/.env.example b/tests/visual/.env.example new file mode 100644 index 0000000000..6c5254a5db --- /dev/null +++ b/tests/visual/.env.example @@ -0,0 +1,7 @@ +# R2 credentials for visual testing +# Copy this file to .env and fill in the values + +SD_VISUAL_TESTING_R2_ACCOUNT_ID= +SD_VISUAL_TESTING_R2_ACCESS_KEY_ID= +SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY= +SD_VISUAL_TESTING_R2_BUCKET= diff --git a/tests/visual/.gitignore b/tests/visual/.gitignore new file mode 100644 index 0000000000..3059b60d85 --- /dev/null +++ b/tests/visual/.gitignore @@ -0,0 +1,6 @@ +*-snapshots/ +screenshots/ +playwright-report/ +test-results/ +test-data/ +.baselines-version diff --git a/tests/visual/AGENTS.md b/tests/visual/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/tests/visual/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/tests/visual/CLAUDE.md b/tests/visual/CLAUDE.md new file mode 100644 index 0000000000..7f8748a98d --- /dev/null +++ b/tests/visual/CLAUDE.md @@ -0,0 +1,159 @@ +# Visual Testing + +Playwright visual regression tests for SuperDoc. Screenshots and test documents are stored in R2. + +## When to Add Visual Tests + +Add a **behavior test** when you: +- Fix a bug that affects rendering or user interaction +- Add or change an editing feature (formatting, commands, toolbar) +- Modify comments, track changes, or collaboration UI + +Add a **rendering test** when you: +- Fix a DOCX import/export rendering issue +- Change the layout engine or style resolution + +## Test Structure + +``` +tests/ + behavior/ Simulate user actions, screenshot result + basic-commands/ Typing, undo/redo, tables, select-all, toolbar + formatting/ Bold/italic, hyperlinks, clear format, fonts + comments-tcs/ Comments, track changes, nested comments + lists/ List creation, indentation, markers + field-annotations/ Field annotation types and formatting + headers/ Header/footer editing + search/ Search and navigation + importing/ Document import edge cases + structured-content/ SDT lock modes + rendering/ Load .docx files, screenshot each page + fixtures/superdoc.ts Shared fixture with helpers +test-data/ Downloaded from R2 (gitignored), mirrors R2 documents/ prefix +scripts/ + download-test-docs.ts Auto-discover and download all documents from R2 + upload-test-doc.ts Upload a document to R2 + download-baselines.ts Download screenshot baselines from R2 + upload-baselines.ts Upload screenshot baselines to R2 +``` + +## R2 Storage + +Single bucket with two prefixes. Local `test-data/` mirrors the `documents/` prefix exactly: + +``` +superdoc-visual-testing/ + documents/ → downloads to test-data/ + behavior/ + comments-tcs/doc.docx → test-data/behavior/comments-tcs/doc.docx + formatting/doc.docx → test-data/behavior/formatting/doc.docx + rendering/doc.docx → test-data/rendering/doc.docx + baselines/ → downloads to tests/ (snapshot dirs) +``` + +## Writing a Behavior Test + +```ts +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior description of what it tests', async ({ superdoc }) => { + // 1. Set up state (type, execute commands, load doc) + await superdoc.type('Hello world'); + await superdoc.bold(); + + // 2. Screenshot the result + await superdoc.screenshot('my-test-name'); +}); +``` + +Place the file in the matching category folder. Use `@behavior` tag in the test name. + +## Loading Test Documents + +Test documents are stored in R2 (`documents/` prefix). Download with `pnpm docs:download`. Upload new ones with `pnpm docs:upload `. + +```ts +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/comments-tcs/tracked-changes.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available'); + +test('@behavior my doc test', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.screenshot('my-test'); +}); +``` + +Document paths mirror the test folder structure. A test in `tests/behavior/comments-tcs/` uses documents from `test-data/behavior/comments-tcs/`. + +## Writing a Rendering Test + +```ts +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../test-data/rendering'); + +test('@rendering my-doc renders correctly', async ({ superdoc }) => { + await superdoc.loadDocument(path.join(DOCS_DIR, 'my-doc.docx')); + await superdoc.screenshotPages('rendering/my-doc'); +}); +``` + +Use `@rendering` tag. + +## Fixture Helpers + +| Method | What it does | +|--------|-------------| +| `type(text)` | Type text (30ms delay per char) | +| `press(key)` | Press key (`'Enter'`, `'Shift+Tab'`) | +| `newLine()` | Press Enter | +| `shortcut(key)` | Cmd/Ctrl + key | +| `bold()` / `italic()` / `underline()` | Toggle formatting | +| `undo()` / `redo()` | Undo/redo | +| `selectAll()` | Cmd/Ctrl+A | +| `tripleClickLine(index)` | Select line by index (uses `.superdoc-line`) | +| `executeCommand(name, args?)` | Run editor command via `window.editor.commands` | +| `setDocumentMode(mode)` | Set editing/suggesting/viewing mode | +| `setTextSelection(from, to?)` | Set cursor position via ProseMirror position | +| `clickOnLine(index, xOffset?)` | Single click on a line | +| `clickOnCommentedText(text)` | Click on comment highlight containing text | +| `pressTimes(key, count)` | Press a key multiple times | +| `waitForStable(ms?)` | Wait for layout to settle (default 500ms) | +| `screenshot(name)` | Full-page screenshot with baseline comparison | +| `loadDocument(path)` | Load a .docx file into the editor | +| `screenshotPages(baseName)` | Screenshot each rendered page | + +## Config Overrides + +```ts +test.use({ + config: { + layout: true, // layout engine (default: true) + toolbar: 'full', // 'none' | 'minimal' | 'full' + comments: 'on', // 'off' | 'on' | 'panel' | 'readonly' + trackChanges: true, + hideSelection: false, // show selection in screenshots + hideCaret: false, // show caret in screenshots + }, +}); +``` + +Defaults: `layout: true`, `hideCaret: true`, `hideSelection: true`. Override before tests that need visible selection/caret. + +## Important Notes + +- **DOM selectors**: SuperDoc uses DomPainter, not ProseMirror DOM. Use `.superdoc-line`, `.superdoc-page`, not `.ProseMirror p`. +- **Editor commands**: Available via `executeCommand()` — waits for `window.editor.commands` automatically. +- **Document mode**: Use `setDocumentMode('suggesting')` fixture helper or `superdoc.page.evaluate(() => window.superdoc.setDocumentMode('suggesting'))`. +- **Baselines & documents**: Never committed to git. Stored in R2 in a single bucket, generated from the `stable` branch. +- **Running locally**: `cd tests/visual && pnpm docs:download && pnpm test`. diff --git a/tests/visual/README.md b/tests/visual/README.md new file mode 100644 index 0000000000..040fa7071d --- /dev/null +++ b/tests/visual/README.md @@ -0,0 +1,201 @@ +# Visual Testing + +Playwright-based visual regression tests for SuperDoc. Everything lives in a single R2 bucket (`superdoc-visual-testing`) with two prefixes: `documents/` for test files and `baselines/` for screenshots. + +## Quick Start + +```bash +cd tests/visual + +# Download test documents from R2 (first time only) +pnpm docs:download + +# Run all tests +pnpm test + +# Run a specific category +pnpm exec playwright test tests/behavior/formatting/ + +# Run a single test +pnpm exec playwright test tests/behavior/basic-commands/undo-redo.spec.ts + +# Run one browser only +pnpm exec playwright test --project=chromium + +# Update local snapshots +pnpm test:update + +# View the HTML report +pnpm report +``` + +## Test Types + +**Behavior** (`tests/behavior/`) — Simulate user interactions (typing, formatting, commands) and screenshot the result. Organized by category: + +- `basic-commands/` — typing, undo/redo, tables, select-all, toolbar, drag selection +- `formatting/` — bold/italic, hyperlinks, clear format, style inheritance, fonts +- `comments-tcs/` — comments, track changes, nested comments +- `lists/` — list creation, indentation, markers +- `field-annotations/` — field annotation types and formatting +- `headers/` — header/footer editing +- `search/` — search and navigation +- `importing/` — document import edge cases +- `structured-content/` — SDT lock modes + +**Rendering** (`tests/rendering/`) — Load `.docx` documents and screenshot each page. Tagged with `@rendering` for baseline filtering. + +## Adding a Test + +### Behavior test (no document needed) + +```ts +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior description of what it tests', async ({ superdoc }) => { + await superdoc.type('Hello'); + await superdoc.bold(); + await superdoc.type(' world'); + await superdoc.screenshot('my-test-name'); +}); +``` + +### Behavior test with a document + +```bash +# 1. Upload your document to R2 (path mirrors the test folder) +pnpm docs:upload ~/Downloads/my-bug-repro.docx behavior/comments-tcs +``` + +```ts +// 2. Reference it in your test — path matches the category +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/comments-tcs/my-bug-repro.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available'); + +test('@behavior my document test', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.screenshot('my-test'); +}); +``` + +### Rendering test + +```bash +# 1. Upload your document +pnpm docs:upload ~/Downloads/my-doc.docx rendering +``` + +```ts +// 2. Write the test +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../test-data/rendering'); + +test('@rendering my-doc renders correctly', async ({ superdoc }) => { + await superdoc.loadDocument(path.join(DOCS_DIR, 'my-doc.docx')); + await superdoc.screenshotPages('rendering/my-doc'); +}); +``` + +## R2 Storage + +Everything lives in one bucket. The folder structure mirrors the test structure: + +``` +superdoc-visual-testing/ + documents/ Test .docx files + behavior/ + comments-tcs/ Documents for comments-tcs tests + formatting/ Documents for formatting tests + ... + rendering/ Documents for rendering tests + baselines/ Screenshot baselines (auto-generated) + behavior/ + basic-commands/ + type-basic-text.spec.ts-snapshots/ + chromium/ + firefox/ + webkit/ + ... + rendering/ + ... +``` + +| Command | What it does | +|---------|-------------| +| `pnpm docs:download` | Download all documents from R2 → `test-data/` | +| `pnpm docs:upload ` | Upload a document to R2 | +| `pnpm baseline:download` | Download baselines from R2 | +| `pnpm baseline:upload` | Upload baselines to R2 | + +## Fixture Helpers + +| Method | Description | +|--------|-------------| +| `type(text)` | Type text into the editor | +| `press(key)` | Press a key (e.g. `'Enter'`, `'Shift+Tab'`) | +| `newLine()` | Press Enter | +| `shortcut(key)` | Cmd/Ctrl + key | +| `bold()` / `italic()` / `underline()` | Toggle formatting | +| `undo()` / `redo()` | Undo/redo | +| `selectAll()` | Select all content | +| `tripleClickLine(index)` | Select a line by index | +| `executeCommand(name, args?)` | Run an editor command | +| `setDocumentMode(mode)` | Set editing/suggesting/viewing mode | +| `setTextSelection(from, to?)` | Set cursor position | +| `clickOnLine(index, xOffset?)` | Single click on a line | +| `clickOnCommentedText(text)` | Click on comment highlight | +| `pressTimes(key, count)` | Press a key multiple times | +| `waitForStable(ms?)` | Wait for layout to settle | +| `screenshot(name)` | Full-page screenshot | +| `loadDocument(path)` | Load a .docx file | +| `screenshotPages(baseName)` | Screenshot each rendered page | + +## Fixture Config + +Override defaults with `test.use()`: + +```ts +test.use({ + config: { + layout: true, // layout engine (default: true) + toolbar: 'full', // 'none' | 'minimal' | 'full' + comments: 'on', // 'off' | 'on' | 'panel' | 'readonly' + trackChanges: true, + hideSelection: false, // show selection overlay in screenshots + hideCaret: false, // show caret in screenshots + }, +}); +``` + +## Baselines & CI + +- **PR validation**: `visual-test.yml` downloads baselines + documents from R2, runs tests +- **Baseline update**: `visual-baseline.yml` (manual trigger) builds from `stable`, generates new baselines, uploads to R2 +- Baselines and test documents are never committed to git + +## Local Setup + +```bash +# Install deps (auto-installs Playwright browsers via postinstall) +pnpm install + +# Copy .env for R2 access +cp .env.example .env +# Fill in: SD_VISUAL_TESTING_R2_ACCOUNT_ID, SD_VISUAL_TESTING_R2_ACCESS_KEY_ID, +# SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY, SD_VISUAL_TESTING_R2_BUCKET + +# Download test documents +pnpm docs:download +``` diff --git a/tests/visual/docs/.gitkeep b/tests/visual/docs/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/visual/harness/index.html b/tests/visual/harness/index.html new file mode 100644 index 0000000000..2ae45f6177 --- /dev/null +++ b/tests/visual/harness/index.html @@ -0,0 +1,14 @@ + + + + + + SuperDoc Test Harness + + + +
+
+ + + diff --git a/tests/visual/harness/main.ts b/tests/visual/harness/main.ts new file mode 100644 index 0000000000..4e0e4215d9 --- /dev/null +++ b/tests/visual/harness/main.ts @@ -0,0 +1,76 @@ +import 'superdoc/style.css'; +import { SuperDoc } from 'superdoc'; + +const params = new URLSearchParams(location.search); +const layout = params.get('layout') !== '0'; +const hideCaret = params.get('hideCaret') !== '0'; +const hideSelection = params.get('hideSelection') !== '0'; +const toolbar = params.get('toolbar'); +const comments = params.get('comments'); +const trackChanges = params.get('trackChanges') === '1'; + +if (hideCaret) { + document.documentElement.style.setProperty('caret-color', 'transparent', 'important'); +} + +let instance: any = null; + +function init(file?: File) { + if (instance) { + instance.destroy(); + instance = null; + } + + (window as any).superdocReady = false; + + const config: any = { + selector: '#editor', + useLayoutEngine: layout, + onReady: ({ superdoc }: any) => { + (window as any).superdoc = superdoc; + superdoc.activeEditor.on('create', ({ editor }: any) => { + (window as any).editor = editor; + }); + (window as any).superdocReady = true; + }, + }; + + if (file) { + config.document = file; + } + + // Toolbar + if (toolbar && toolbar !== 'none') { + config.toolbar = document.getElementById('toolbar'); + } + + // Comments + if (comments === 'on' || comments === 'panel') { + config.comments = { visible: true }; + } else if (comments === 'readonly') { + config.comments = { visible: true, readOnly: true }; + } + + // Track changes + if (trackChanges) { + config.trackChanges = { visible: true }; + } + + instance = new SuperDoc(config); + + if (hideSelection) { + const style = document.createElement('style'); + style.textContent = ` + .superdoc-selection-overlay, + .superdoc-caret { display: none !important; } + `; + document.head.appendChild(style); + } +} + +document.querySelector('input[type="file"]')!.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) init(file); +}); + +init(); diff --git a/tests/visual/harness/vite.config.ts b/tests/visual/harness/vite.config.ts new file mode 100644 index 0000000000..1a4a1e4873 --- /dev/null +++ b/tests/visual/harness/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 9989, + strictPort: true, + }, + optimizeDeps: { + exclude: ['superdoc'], + }, +}); diff --git a/tests/visual/package.json b/tests/visual/package.json new file mode 100644 index 0000000000..bed716707c --- /dev/null +++ b/tests/visual/package.json @@ -0,0 +1,27 @@ +{ + "name": "@superdoc-testing/visual", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:update": "playwright test --update-snapshots", + "test:baseline": "playwright test --update-snapshots --grep @rendering", + "baseline:upload": "tsx scripts/upload-baselines.ts", + "baseline:download": "tsx scripts/download-baselines.ts", + "docs:download": "tsx scripts/download-test-docs.ts", + "docs:upload": "tsx scripts/upload-test-doc.ts", + "report": "playwright show-report", + "harness": "vite --config harness/vite.config.ts harness/", + "postinstall": "playwright install --with-deps chromium firefox webkit" + }, + "dependencies": { + "superdoc": "workspace:*" + }, + "devDependencies": { + "@aws-sdk/client-s3": "^3.988.0", + "@playwright/test": "catalog:", + "dotenv": "^16.4.7", + "tsx": "catalog:", + "vite": "catalog:" + } +} diff --git a/tests/visual/playwright.config.ts b/tests/visual/playwright.config.ts new file mode 100644 index 0000000000..59f495a5d3 --- /dev/null +++ b/tests/visual/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + workers: 8, + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + reporter: [['html', { open: 'never' }]], + + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.01, + animations: 'disabled', + caret: 'hide', + stylePath: './screenshot.css', + }, + }, + + use: { + viewport: { width: 1600, height: 1200 }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + webServer: { + command: 'npx vite --config harness/vite.config.ts harness/', + port: 9989, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests/visual/screenshot.css b/tests/visual/screenshot.css new file mode 100644 index 0000000000..0d0e76fdc8 --- /dev/null +++ b/tests/visual/screenshot.css @@ -0,0 +1,24 @@ +/* Injected during visual screenshots to eliminate dynamic elements */ + +/* Hide text caret */ +* { + caret-color: transparent !important; +} + +/* Disable all animations and transitions */ +*, +*::before, +*::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; +} + +/* Hide scrollbars */ +::-webkit-scrollbar { + display: none !important; +} +* { + scrollbar-width: none !important; +} diff --git a/tests/visual/scripts/download-baselines.ts b/tests/visual/scripts/download-baselines.ts new file mode 100644 index 0000000000..236e24d793 --- /dev/null +++ b/tests/visual/scripts/download-baselines.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3'; +import { createR2Client, BASELINES_PREFIX } from './r2.js'; + +const TESTS_DIR = path.resolve(import.meta.dirname, '../tests'); + +async function listObjects(client: any, bucket: string) { + const keys: string[] = []; + let continuationToken: string | undefined; + + do { + const response = await client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: `${BASELINES_PREFIX}/`, + ContinuationToken: continuationToken, + }), + ); + + for (const item of response.Contents ?? []) { + if (item.Key) keys.push(item.Key); + } + + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + } while (continuationToken); + + return keys; +} + +async function downloadFile(client: any, bucket: string, key: string, dest: string) { + const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + + const bytes = await response.Body!.transformToByteArray(); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, bytes); +} + +async function main() { + const { client, bucket } = createR2Client(); + + console.log('Listing baselines in R2...'); + const keys = await listObjects(client, bucket); + + if (keys.length === 0) { + console.log('No baselines found in R2. Run upload-baselines first.'); + process.exit(1); + } + + console.log(`Downloading ${keys.length} snapshots...`); + + const CONCURRENCY = 10; + let downloaded = 0; + + const items = keys.map((key) => ({ + key, + relative: key.slice(`${BASELINES_PREFIX}/`.length), + dest: path.join(TESTS_DIR, key.slice(`${BASELINES_PREFIX}/`.length)), + })); + + for (let i = 0; i < items.length; i += CONCURRENCY) { + const batch = items.slice(i, i + CONCURRENCY); + await Promise.all( + batch.map(async ({ key, relative, dest }) => { + await downloadFile(client, bucket, key, dest); + downloaded++; + console.log(` ✓ ${relative}`); + }), + ); + } + + console.log(`\nDone. Downloaded: ${downloaded} snapshots.`); + client.destroy(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/visual/scripts/download-test-docs.ts b/tests/visual/scripts/download-test-docs.ts new file mode 100644 index 0000000000..4e723a4764 --- /dev/null +++ b/tests/visual/scripts/download-test-docs.ts @@ -0,0 +1,101 @@ +/** + * Downloads all test documents from R2. + * Auto-discovers everything under the documents/ prefix — no hardcoded list. + * Downloads to test-data/ preserving the folder structure. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3'; +import { createR2Client, DOCUMENTS_PREFIX } from './r2.js'; + +const TEST_DATA_DIR = path.resolve(import.meta.dirname, '../test-data'); + +async function listDocuments(client: any, bucket: string) { + const keys: string[] = []; + let continuationToken: string | undefined; + + do { + const response = await client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: `${DOCUMENTS_PREFIX}/`, + ContinuationToken: continuationToken, + }), + ); + + for (const item of response.Contents ?? []) { + if (item.Key) keys.push(item.Key); + } + + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + } while (continuationToken); + + return keys; +} + +async function downloadFile(client: any, bucket: string, key: string, dest: string) { + const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + const bytes = await response.Body!.transformToByteArray(); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, bytes); +} + +async function main() { + const { client, bucket } = createR2Client(); + + console.log('Listing documents in R2...'); + const keys = await listDocuments(client, bucket); + + if (keys.length === 0) { + console.log('No documents found in R2.'); + process.exit(0); + } + + console.log(`Found ${keys.length} documents.`); + + const toDownload: { key: string; relative: string; dest: string }[] = []; + let skipped = 0; + + for (const key of keys) { + const relative = key.slice(`${DOCUMENTS_PREFIX}/`.length); + const dest = path.join(TEST_DATA_DIR, relative); + + if (fs.existsSync(dest)) { + skipped++; + } else { + toDownload.push({ key, relative, dest }); + } + } + + console.log(`Downloading ${toDownload.length} files (${skipped} cached)...`); + + const CONCURRENCY = 10; + let downloaded = 0; + let failed = 0; + + for (let i = 0; i < toDownload.length; i += CONCURRENCY) { + const batch = toDownload.slice(i, i + CONCURRENCY); + const results = await Promise.allSettled( + batch.map(async ({ key, relative, dest }) => { + await downloadFile(client, bucket, key, dest); + downloaded++; + console.log(` ✓ ${relative}`); + }), + ); + + for (let j = 0; j < results.length; j++) { + if (results[j].status === 'rejected') { + failed++; + console.error(` ✗ ${batch[j].relative}: ${(results[j] as PromiseRejectedResult).reason?.message}`); + } + } + } + + console.log(`\nDone. Downloaded: ${downloaded}, Cached: ${skipped}, Failed: ${failed}`); + client.destroy(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/visual/scripts/r2.ts b/tests/visual/scripts/r2.ts new file mode 100644 index 0000000000..3f42b2c1f2 --- /dev/null +++ b/tests/visual/scripts/r2.ts @@ -0,0 +1,26 @@ +import 'dotenv/config'; +import { S3Client } from '@aws-sdk/client-s3'; + +export const BASELINES_PREFIX = 'baselines'; +export const DOCUMENTS_PREFIX = 'documents'; + +export function createR2Client() { + const accountId = process.env.SD_VISUAL_TESTING_R2_ACCOUNT_ID; + const bucket = process.env.SD_VISUAL_TESTING_R2_BUCKET; + const accessKeyId = process.env.SD_VISUAL_TESTING_R2_ACCESS_KEY_ID; + const secretAccessKey = process.env.SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY; + + if (!accountId || !bucket || !accessKeyId || !secretAccessKey) { + throw new Error( + 'Missing R2 env vars. Need: SD_VISUAL_TESTING_R2_ACCOUNT_ID, SD_VISUAL_TESTING_R2_BUCKET, SD_VISUAL_TESTING_R2_ACCESS_KEY_ID, SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY', + ); + } + + const client = new S3Client({ + region: 'auto', + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId, secretAccessKey }, + }); + + return { client, bucket }; +} diff --git a/tests/visual/scripts/upload-baselines.ts b/tests/visual/scripts/upload-baselines.ts new file mode 100644 index 0000000000..37639a292b --- /dev/null +++ b/tests/visual/scripts/upload-baselines.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { createR2Client, BASELINES_PREFIX } from './r2.js'; + +const TESTS_DIR = path.resolve(import.meta.dirname, '../tests'); +const VERSION_FILE = path.resolve(import.meta.dirname, '../.baselines-version'); + +function findSnapshots(dir: string): string[] { + const files: string[] = []; + + function walk(d: string) { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.png') && full.includes('-snapshots')) { + files.push(full); + } + } + } + + walk(dir); + return files; +} + +async function main() { + const { client, bucket } = createR2Client(); + const snapshots = findSnapshots(TESTS_DIR); + + if (snapshots.length === 0) { + console.log('No snapshots found. Run tests with --update-snapshots first.'); + process.exit(1); + } + + console.log(`Uploading ${snapshots.length} snapshots to R2...`); + + const hash = crypto.createHash('sha256'); + + for (const file of snapshots) { + const relative = path.relative(TESTS_DIR, file); + const key = `${BASELINES_PREFIX}/${relative}`; + const body = fs.readFileSync(file); + + hash.update(relative); + hash.update(body); + + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: 'image/png', + }), + ); + + console.log(` ✓ ${relative}`); + } + + const version = hash.digest('hex').slice(0, 16); + fs.writeFileSync(VERSION_FILE, version); + console.log(`\nDone. Version: ${version}`); + + client.destroy(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/visual/scripts/upload-test-doc.ts b/tests/visual/scripts/upload-test-doc.ts new file mode 100644 index 0000000000..2898f7cabc --- /dev/null +++ b/tests/visual/scripts/upload-test-doc.ts @@ -0,0 +1,70 @@ +/** + * Upload a test document to R2. + * + * Usage: + * pnpm docs:upload + * + * Examples: + * pnpm docs:upload ~/Downloads/bug-repro.docx behavior/comments-tcs + * pnpm docs:upload ~/Downloads/table.docx rendering + * + * The file will be uploaded to: documents// + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { createR2Client, DOCUMENTS_PREFIX } from './r2.js'; + +async function main() { + const [filePath, category] = process.argv.slice(2); + + if (!filePath || !category) { + console.error('Usage: pnpm docs:upload '); + console.error(''); + console.error('Categories match the test folder structure:'); + console.error(' behavior/basic-commands'); + console.error(' behavior/formatting'); + console.error(' behavior/comments-tcs'); + console.error(' behavior/lists'); + console.error(' behavior/field-annotations'); + console.error(' behavior/headers'); + console.error(' behavior/search'); + console.error(' behavior/importing'); + console.error(' behavior/structured-content'); + console.error(' rendering'); + process.exit(1); + } + + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + console.error(`File not found: ${resolved}`); + process.exit(1); + } + + const fileName = path.basename(resolved); + const key = `${DOCUMENTS_PREFIX}/${category}/${fileName}`; + + const { client, bucket } = createR2Client(); + + const body = fs.readFileSync(resolved); + + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }), + ); + + console.log(`Uploaded: ${key}`); + console.log(`\nUse in your test:`); + console.log(` const DOC_PATH = path.join(DOCS_DIR, '${category}/${fileName}');`); + + client.destroy(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/visual/tests/behavior/basic-commands/document-mode-dropdown-sync.spec.ts b/tests/visual/tests/behavior/basic-commands/document-mode-dropdown-sync.spec.ts new file mode 100644 index 0000000000..51648f8c05 --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/document-mode-dropdown-sync.spec.ts @@ -0,0 +1,20 @@ +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full' } }); + +test('@behavior document mode dropdown syncs on mode change', async ({ superdoc }) => { + await superdoc.waitForStable(); + await superdoc.screenshot('mode-initial-editing'); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + await superdoc.screenshot('mode-suggesting'); + + await superdoc.setDocumentMode('viewing'); + await superdoc.waitForStable(); + await superdoc.screenshot('mode-viewing'); + + await superdoc.setDocumentMode('editing'); + await superdoc.waitForStable(); + await superdoc.screenshot('mode-back-to-editing'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/drag-selection-autoscroll.spec.ts b/tests/visual/tests/behavior/basic-commands/drag-selection-autoscroll.spec.ts new file mode 100644 index 0000000000..dea51299b6 --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/drag-selection-autoscroll.spec.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/basic-commands/h_f-normal-odd-even.docx'); + +test.use({ config: { hideSelection: false, height: 800 } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available'); + +test('@behavior drag selection with autoscroll across pages', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.screenshot('drag-autoscroll-loaded'); + + const editorBox = await superdoc.page.locator('#editor').first().boundingBox(); + if (!editorBox) throw new Error('Editor not found'); + + // Basic drag selection within viewport + await superdoc.page.mouse.move(editorBox.x + 100, editorBox.y + 150); + await superdoc.page.mouse.down(); + await superdoc.page.mouse.move(editorBox.x + 500, editorBox.y + 300, { steps: 5 }); + await superdoc.page.mouse.up(); + await superdoc.waitForStable(); + await superdoc.screenshot('drag-autoscroll-basic'); + + // Drag towards bottom edge to trigger autoscroll + await superdoc.page.mouse.move(editorBox.x + 100, editorBox.y + 100); + await superdoc.page.mouse.down(); + await superdoc.page.mouse.move(editorBox.x + 200, editorBox.y + editorBox.height - 10, { steps: 10 }); + await superdoc.waitForStable(1000); + await superdoc.page.mouse.up(); + await superdoc.waitForStable(); + await superdoc.screenshot('drag-autoscroll-scrolled'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/insert-table.spec.ts b/tests/visual/tests/behavior/basic-commands/insert-table.spec.ts new file mode 100644 index 0000000000..ab7ebdc2f1 --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/insert-table.spec.ts @@ -0,0 +1,7 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior insert 2x2 table', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + await superdoc.screenshot('insert-table-2x2'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/multi-paragraph.spec.ts b/tests/visual/tests/behavior/basic-commands/multi-paragraph.spec.ts new file mode 100644 index 0000000000..4d99549e25 --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/multi-paragraph.spec.ts @@ -0,0 +1,20 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior multi-paragraph document with heading and list', async ({ superdoc }) => { + await superdoc.type('Document Heading'); + await superdoc.newLine(); + await superdoc.newLine(); + await superdoc.type('This is the first paragraph of text.'); + await superdoc.newLine(); + await superdoc.newLine(); + await superdoc.type('This is the second paragraph with more content.'); + await superdoc.newLine(); + await superdoc.newLine(); + await superdoc.type('- Bullet item one'); + await superdoc.newLine(); + await superdoc.type('- Bullet item two'); + await superdoc.newLine(); + await superdoc.type('- Bullet item three'); + + await superdoc.screenshot('multi-paragraph'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/select-all-complex-doc.spec.ts b/tests/visual/tests/behavior/basic-commands/select-all-complex-doc.spec.ts new file mode 100644 index 0000000000..b29c866fff --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/select-all-complex-doc.spec.ts @@ -0,0 +1,16 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data/rendering'); + +test.use({ config: { hideSelection: false } }); + +test('@behavior select all in complex document', async ({ superdoc }) => { + await superdoc.loadDocument(path.join(DOCS_DIR, 'advanced-tables.docx')); + await superdoc.screenshot('select-all-loaded'); + + await superdoc.selectAll(); + await superdoc.screenshot('select-all-selected'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/slash-menu-paste.spec.ts b/tests/visual/tests/behavior/basic-commands/slash-menu-paste.spec.ts new file mode 100644 index 0000000000..418a2d5cd0 --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/slash-menu-paste.spec.ts @@ -0,0 +1,40 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior slash menu paste preserves formatting', async ({ superdoc }) => { + await superdoc.type('Normal line'); + await superdoc.newLine(); + await superdoc.waitForStable(); + await superdoc.screenshot('slash-menu-before-paste'); + + // Right-click to open context menu + const lines = superdoc.page.locator('.superdoc-line'); + const lastLine = lines.last(); + const box = await lastLine.boundingBox(); + if (!box) throw new Error('Last line not visible'); + + await superdoc.page.mouse.click(box.x + 20, box.y + box.height / 2, { button: 'right' }); + await superdoc.waitForStable(); + + const menu = superdoc.page.locator('.slash-menu'); + const menuVisible = await menu.isVisible().catch(() => false); + + if (menuVisible) { + await superdoc.screenshot('slash-menu-open'); + await superdoc.press('Escape'); + await superdoc.waitForStable(); + } + + // Paste formatted HTML via editor API + await superdoc.clickOnLine(1); + await superdoc.waitForStable(); + + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + if (editor?.commands?.insertContent) { + editor.commands.insertContent('Bold pasted text'); + } + }); + + await superdoc.waitForStable(); + await superdoc.screenshot('slash-menu-after-paste'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/table-add-row-formatting.spec.ts b/tests/visual/tests/behavior/basic-commands/table-add-row-formatting.spec.ts new file mode 100644 index 0000000000..b88f553291 --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/table-add-row-formatting.spec.ts @@ -0,0 +1,19 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior table row addition preserves formatting', async ({ superdoc }) => { + // Insert 2x2 table + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + // Type bold text in first cell + await superdoc.bold(); + await superdoc.type('Bold header'); + await superdoc.bold(); + await superdoc.screenshot('table-before-add-row'); + + // Add row after and type in it + await superdoc.executeCommand('addRowAfter'); + await superdoc.waitForStable(); + await superdoc.type('New row text'); + await superdoc.screenshot('table-after-add-row'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/toolbar-bubble.spec.ts b/tests/visual/tests/behavior/basic-commands/toolbar-bubble.spec.ts new file mode 100644 index 0000000000..31ca2aa208 --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/toolbar-bubble.spec.ts @@ -0,0 +1,20 @@ +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', hideSelection: false } }); + +test('@behavior toolbar bubble appears on text selection', async ({ superdoc }) => { + await superdoc.type('I am some text'); + await superdoc.screenshot('toolbar-before-select'); + + // Select "some" using keyboard + await superdoc.press('Home'); + for (let i = 0; i < 5; i++) await superdoc.press('ArrowRight'); // move past "I am " + for (let i = 0; i < 4; i++) await superdoc.press('Shift+ArrowRight'); // select "some" + await superdoc.waitForStable(); + await superdoc.screenshot('toolbar-text-selected'); + + // Deselect — toolbar should disappear + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + await superdoc.screenshot('toolbar-deselected'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/type-basic-text.spec.ts b/tests/visual/tests/behavior/basic-commands/type-basic-text.spec.ts new file mode 100644 index 0000000000..b8086e8d93 --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/type-basic-text.spec.ts @@ -0,0 +1,8 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior type basic text into blank document', async ({ superdoc }) => { + await superdoc.type('Hello, SuperDoc!'); + await superdoc.newLine(); + await superdoc.type('This is a simple paragraph of text.'); + await superdoc.screenshot('type-basic-text'); +}); diff --git a/tests/visual/tests/behavior/basic-commands/undo-redo.spec.ts b/tests/visual/tests/behavior/basic-commands/undo-redo.spec.ts new file mode 100644 index 0000000000..55b69b249e --- /dev/null +++ b/tests/visual/tests/behavior/basic-commands/undo-redo.spec.ts @@ -0,0 +1,14 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior undo and redo text', async ({ superdoc }) => { + await superdoc.type('First paragraph.'); + await superdoc.newLine(); + await superdoc.type('Second paragraph.'); + await superdoc.screenshot('before-undo'); + + await superdoc.undo(); + await superdoc.screenshot('after-undo'); + + await superdoc.redo(); + await superdoc.screenshot('after-redo'); +}); diff --git a/tests/visual/tests/behavior/comments-tcs/basic-comment-insertion.spec.ts b/tests/visual/tests/behavior/comments-tcs/basic-comment-insertion.spec.ts new file mode 100644 index 0000000000..d67b52ac29 --- /dev/null +++ b/tests/visual/tests/behavior/comments-tcs/basic-comment-insertion.spec.ts @@ -0,0 +1,26 @@ +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { comments: 'on', hideSelection: false } }); + +test('@behavior comment insertion and tracked change', async ({ superdoc }) => { + await superdoc.type('hello'); + await superdoc.newLine(); + await superdoc.newLine(); + await superdoc.type('world'); + await superdoc.screenshot('comment-typed'); + + // Switch to suggesting mode + await superdoc.page.evaluate(() => { + (window as any).superdoc.setDocumentMode('suggesting'); + }); + await superdoc.waitForStable(); + + // Select "world" and add comment + await superdoc.press('End'); + for (let i = 0; i < 5; i++) await superdoc.press('Shift+ArrowLeft'); + await superdoc.screenshot('comment-select-world'); + + await superdoc.executeCommand('addComment', { text: 'my comment text' }); + await superdoc.waitForStable(); + await superdoc.screenshot('comment-added'); +}); diff --git a/tests/visual/tests/behavior/comments-tcs/basic-tracked-change-existing-doc.spec.ts b/tests/visual/tests/behavior/comments-tcs/basic-tracked-change-existing-doc.spec.ts new file mode 100644 index 0000000000..3691454709 --- /dev/null +++ b/tests/visual/tests/behavior/comments-tcs/basic-tracked-change-existing-doc.spec.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/comments-tcs/tracked-changes.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available'); + +test('@behavior tracked change replacement in existing document', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await superdoc.screenshot('tc-existing-doc-loaded'); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + // Select first line and type replacement + await superdoc.tripleClickLine(0); + + await superdoc.waitForStable(); + await superdoc.type('programmatically inserted'); + await superdoc.waitForStable(); + await superdoc.screenshot('tc-existing-doc-replaced'); +}); diff --git a/tests/visual/tests/behavior/comments-tcs/comment-on-tracked-change.spec.ts b/tests/visual/tests/behavior/comments-tcs/comment-on-tracked-change.spec.ts new file mode 100644 index 0000000000..9c1061bece --- /dev/null +++ b/tests/visual/tests/behavior/comments-tcs/comment-on-tracked-change.spec.ts @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/comments-tcs/gdocs-comment-on-change.docx'); + +test.use({ config: { comments: 'panel', trackChanges: true, hideSelection: false } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior comment highlighting on tracked change text', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.page.waitForSelector('.superdoc-comment-highlight', { timeout: 30_000 }); + await superdoc.waitForStable(); + await superdoc.screenshot('comment-on-tc-initial'); + + await superdoc.clickOnCommentedText('new text'); + await superdoc.waitForStable(); + await superdoc.screenshot('comment-on-tc-selected'); + + await superdoc.clickOnCommentedText('Test'); + await superdoc.waitForStable(); + await superdoc.screenshot('comment-on-tc-regular'); + + await superdoc.clickOnLine(4); + await superdoc.waitForStable(); + await superdoc.screenshot('comment-on-tc-deselected'); +}); diff --git a/tests/visual/tests/behavior/comments-tcs/edit-comment-text.spec.ts b/tests/visual/tests/behavior/comments-tcs/edit-comment-text.spec.ts new file mode 100644 index 0000000000..5f701c5917 --- /dev/null +++ b/tests/visual/tests/behavior/comments-tcs/edit-comment-text.spec.ts @@ -0,0 +1,56 @@ +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { comments: 'panel' } }); + +test('@behavior editing comment preserves original text', async ({ superdoc }) => { + await superdoc.type('hello comments'); + await superdoc.waitForStable(); + await superdoc.screenshot('edit-comment-typed'); + + // Select "comments" (8 chars from end) + await superdoc.shortcut('ArrowRight'); + await superdoc.pressTimes('Shift+ArrowLeft', 8); + await superdoc.waitForStable(); + await superdoc.screenshot('edit-comment-selected'); + + // Click the comment tool button + const commentTool = superdoc.page.locator('.tools-item[data-id="is-tool"]'); + await commentTool.click(); + await superdoc.waitForStable(); + await superdoc.screenshot('edit-comment-dialog-open'); + + // Type comment text + await superdoc.page.keyboard.type('original comment'); + await superdoc.waitForStable(); + + // Submit the comment + const commentButton = superdoc.page.locator('.sd-button.primary').filter({ hasText: 'Comment' }); + await commentButton.click(); + await superdoc.waitForStable(); + await superdoc.screenshot('edit-comment-submitted'); + + // Open overflow menu and click Edit + const overflowIcon = superdoc.page.locator('.floating-comment .overflow-icon').last(); + await overflowIcon.click(); + await superdoc.waitForStable(); + + const editOption = superdoc.page.locator('.n-dropdown-option-body__label').filter({ hasText: 'Edit' }); + await editOption.click(); + await superdoc.waitForStable(); + await superdoc.screenshot('edit-comment-edit-mode'); + + // Select "original" and replace with "changed" + await superdoc.shortcut('ArrowLeft'); + await superdoc.pressTimes('Shift+ArrowRight', 8); + await superdoc.page.keyboard.type('changed'); + await superdoc.waitForStable(); + + // Update + const updateButton = superdoc.page + .locator('.comment-editing .sd-button.primary') + .filter({ hasText: 'Update' }) + .last(); + await updateButton.click(); + await superdoc.waitForStable(); + await superdoc.screenshot('edit-comment-updated'); +}); diff --git a/tests/visual/tests/behavior/comments-tcs/nested-comments-gdocs.spec.ts b/tests/visual/tests/behavior/comments-tcs/nested-comments-gdocs.spec.ts new file mode 100644 index 0000000000..b57b4aaf17 --- /dev/null +++ b/tests/visual/tests/behavior/comments-tcs/nested-comments-gdocs.spec.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/comments-tcs/nested-comments-gdocs.docx'); + +test.use({ config: { comments: 'panel', hideSelection: false } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior nested and overlapping comments from Google Docs', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.page.waitForSelector('.superdoc-comment-highlight', { timeout: 30_000 }); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-gdocs-initial'); + + await superdoc.clickOnCommentedText('modify'); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-gdocs-inner'); + + await superdoc.clickOnCommentedText('Licensee'); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-gdocs-outer'); + + await superdoc.clickOnCommentedText('proprietary'); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-gdocs-overlap-first'); + + await superdoc.clickOnCommentedText('labels'); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-gdocs-overlap-second'); + + await superdoc.clickOnLine(1, 50); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-gdocs-deselected'); +}); diff --git a/tests/visual/tests/behavior/comments-tcs/nested-comments-word.spec.ts b/tests/visual/tests/behavior/comments-tcs/nested-comments-word.spec.ts new file mode 100644 index 0000000000..fe70cb1fd2 --- /dev/null +++ b/tests/visual/tests/behavior/comments-tcs/nested-comments-word.spec.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/comments-tcs/nested-comments-word.docx'); + +test.use({ config: { comments: 'panel', hideSelection: false } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior nested and overlapping comments from MS Word', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.page.waitForSelector('.superdoc-comment-highlight', { timeout: 30_000 }); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-word-initial'); + + await superdoc.clickOnCommentedText('modify'); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-word-inner'); + + await superdoc.clickOnCommentedText('Licensee'); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-word-outer'); + + await superdoc.clickOnCommentedText('proprietary'); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-word-overlap-first'); + + await superdoc.clickOnCommentedText('labels'); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-word-overlap-second'); + + await superdoc.clickOnLine(1, 50); + await superdoc.waitForStable(); + await superdoc.screenshot('nested-word-deselected'); +}); diff --git a/tests/visual/tests/behavior/comments-tcs/programmatic-tracked-change.spec.ts b/tests/visual/tests/behavior/comments-tcs/programmatic-tracked-change.spec.ts new file mode 100644 index 0000000000..29e3be80a6 --- /dev/null +++ b/tests/visual/tests/behavior/comments-tcs/programmatic-tracked-change.spec.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/comments-tcs/sd-tracked-style-change.docx'); + +test.use({ config: { comments: 'panel', hideSelection: false } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior programmatic insertTrackedChange commands', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await superdoc.screenshot('programmatic-tc-loaded'); + + // Replacement: search + replace via insertTrackedChange + const matches = await superdoc.page.evaluate((query: string) => { + const editor = (window as any).editor; + return editor?.commands?.search?.(query) ?? []; + }, 'a tracked style'); + + if (matches.length > 0) { + await superdoc.page.evaluate((match: any) => { + (window as any).editor.commands.goToSearchResult(match); + }, matches[0]); + await superdoc.waitForStable(); + + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertTrackedChange({ + text: 'new fancy', + user: { name: 'AI Bot', email: 'ai@superdoc.dev' }, + }); + }); + await superdoc.waitForStable(); + await superdoc.screenshot('programmatic-tc-replaced'); + } + + // Deletion with comment + const deleteMatches = await superdoc.page.evaluate((query: string) => { + return (window as any).editor?.commands?.search?.(query) ?? []; + }, 'Here'); + + if (deleteMatches.length > 0) { + await superdoc.page.evaluate((match: any) => { + (window as any).editor.commands.goToSearchResult(match); + }, deleteMatches[0]); + await superdoc.waitForStable(); + + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertTrackedChange({ + text: '', + comment: 'Removing unnecessary word', + user: { name: 'Deletion Bot' }, + }); + }); + await superdoc.waitForStable(); + await superdoc.screenshot('programmatic-tc-deleted'); + } + + // Insertion at position + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertTrackedChange({ + from: 9, + to: 9, + text: 'ABC', + user: { name: 'Insert Bot' }, + }); + }); + await superdoc.waitForStable(); + await superdoc.screenshot('programmatic-tc-inserted'); + + // addToHistory: false — undo should NOT revert this + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertTrackedChange({ + from: 1, + to: 1, + text: 'PERSISTENT ', + user: { name: 'No-History Bot' }, + addToHistory: false, + }); + }); + await superdoc.waitForStable(); + await superdoc.screenshot('programmatic-tc-persistent-before-undo'); + + await superdoc.undo(); + await superdoc.waitForStable(); + await superdoc.screenshot('programmatic-tc-persistent-after-undo'); +}); diff --git a/tests/visual/tests/behavior/comments-tcs/type-after-fully-deleted-content.spec.ts b/tests/visual/tests/behavior/comments-tcs/type-after-fully-deleted-content.spec.ts new file mode 100644 index 0000000000..4f7a8bc484 --- /dev/null +++ b/tests/visual/tests/behavior/comments-tcs/type-after-fully-deleted-content.spec.ts @@ -0,0 +1,25 @@ +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { comments: 'off', hideCaret: false, hideSelection: false } }); + +test('@behavior cursor positioning after fully track-deleted content', async ({ superdoc }) => { + await superdoc.type('Hello World'); + await superdoc.waitForStable(); + await superdoc.screenshot('tc-delete-initial'); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.screenshot('tc-delete-selected'); + + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + await superdoc.screenshot('tc-delete-fully-deleted'); + + // Typing "TEST" — bug would produce "TSET" instead + await superdoc.type('TEST'); + await superdoc.waitForStable(); + await superdoc.screenshot('tc-delete-after-typing'); +}); diff --git a/tests/visual/tests/behavior/field-annotations/annotation-formatting.spec.ts b/tests/visual/tests/behavior/field-annotations/annotation-formatting.spec.ts new file mode 100644 index 0000000000..fdddd3cc98 --- /dev/null +++ b/tests/visual/tests/behavior/field-annotations/annotation-formatting.spec.ts @@ -0,0 +1,81 @@ +import { test } from '../../fixtures/superdoc.js'; + +async function replaceTextWithFormattedAnnotation( + page: any, + searchText: string, + displayLabel: string, + fieldId: string, + formatting: { bold?: boolean; italic?: boolean; underline?: boolean } = {}, +) { + await page.evaluate( + ({ search, label, id, format }: any) => { + const editor = (window as any).editor; + const doc = editor.state.doc; + let found: { from: number; to: number } | null = null; + + doc.descendants((node: any, pos: number) => { + if (found) return false; + if (node.isText && node.text) { + const index = node.text.indexOf(search); + if (index !== -1) { + found = { from: pos + index, to: pos + index + search.length }; + return false; + } + } + return true; + }); + + if (!found) throw new Error(`Text "${search}" not found`); + + editor.commands.replaceWithFieldAnnotation([ + { + from: (found as any).from, + to: (found as any).to, + attrs: { + type: 'text', + displayLabel: label, + fieldId: id, + fieldColor: '#6366f1', + highlighted: true, + ...format, + }, + }, + ]); + }, + { search: searchText, label: displayLabel, id: fieldId, format: formatting }, + ); +} + +test('@behavior field annotations render with bold, italic, underline formatting', async ({ superdoc }) => { + await superdoc.type('Plain: [PLAIN]'); + await superdoc.newLine(); + await superdoc.type('Bold: [BOLD]'); + await superdoc.newLine(); + await superdoc.type('Italic: [ITALIC]'); + await superdoc.newLine(); + await superdoc.type('Underline: [UNDERLINE]'); + await superdoc.newLine(); + await superdoc.type('Bold+Italic: [BOLD_ITALIC]'); + await superdoc.newLine(); + await superdoc.type('All formatting: [ALL]'); + await superdoc.waitForStable(); + await superdoc.screenshot('annotation-format-text'); + + await replaceTextWithFormattedAnnotation(superdoc.page, '[PLAIN]', 'Plain text', 'field-plain'); + await replaceTextWithFormattedAnnotation(superdoc.page, '[BOLD]', 'Bold text', 'field-bold', { bold: true }); + await replaceTextWithFormattedAnnotation(superdoc.page, '[ITALIC]', 'Italic text', 'field-italic', { italic: true }); + await replaceTextWithFormattedAnnotation(superdoc.page, '[UNDERLINE]', 'Underlined', 'field-underline', { + underline: true, + }); + await replaceTextWithFormattedAnnotation(superdoc.page, '[BOLD_ITALIC]', 'Bold italic', 'field-bi', { + bold: true, + italic: true, + }); + await replaceTextWithFormattedAnnotation(superdoc.page, '[ALL]', 'All formats', 'field-all', { + bold: true, + italic: true, + underline: true, + }); + await superdoc.waitForStable(); + await superdoc.screenshot('annotation-format-all-variants'); +}); diff --git a/tests/visual/tests/behavior/field-annotations/insert-all-types.spec.ts b/tests/visual/tests/behavior/field-annotations/insert-all-types.spec.ts new file mode 100644 index 0000000000..8828e26d9e --- /dev/null +++ b/tests/visual/tests/behavior/field-annotations/insert-all-types.spec.ts @@ -0,0 +1,87 @@ +import { test } from '../../fixtures/superdoc.js'; + +async function replaceTextWithAnnotation( + page: any, + searchText: string, + annotationType: string, + displayLabel: string, + fieldId: string, + extraAttrs: Record = {}, +) { + await page.evaluate( + ({ search, type, label, id, extras }: any) => { + const editor = (window as any).editor; + const doc = editor.state.doc; + let found: { from: number; to: number } | null = null; + + doc.descendants((node: any, pos: number) => { + if (found) return false; + if (node.isText && node.text) { + const index = node.text.indexOf(search); + if (index !== -1) { + found = { from: pos + index, to: pos + index + search.length }; + return false; + } + } + return true; + }); + + if (!found) throw new Error(`Text "${search}" not found`); + + editor.commands.replaceWithFieldAnnotation([ + { + from: (found as any).from, + to: (found as any).to, + attrs: { + type, + displayLabel: label, + fieldId: id, + fieldColor: '#6366f1', + highlighted: true, + ...extras, + }, + }, + ]); + }, + { search: searchText, type: annotationType, label: displayLabel, id: fieldId, extras: extraAttrs }, + ); +} + +test('@behavior insert all 6 field annotation types', async ({ superdoc }) => { + await superdoc.type('Name: [NAME]'); + await superdoc.newLine(); + await superdoc.type('Agree to terms: [CHECKBOX]'); + await superdoc.newLine(); + await superdoc.type('Signature: [SIGNATURE]'); + await superdoc.newLine(); + await superdoc.type('Photo: [IMAGE]'); + await superdoc.newLine(); + await superdoc.type('Website: [LINK]'); + await superdoc.newLine(); + await superdoc.type('Custom content: [HTML]'); + await superdoc.waitForStable(); + await superdoc.screenshot('field-annotations-text'); + + await replaceTextWithAnnotation(superdoc.page, '[NAME]', 'text', 'Enter name', 'field-name'); + await superdoc.waitForStable(); + + await replaceTextWithAnnotation(superdoc.page, '[CHECKBOX]', 'checkbox', '☐', 'field-checkbox'); + await superdoc.waitForStable(); + + await replaceTextWithAnnotation(superdoc.page, '[SIGNATURE]', 'signature', 'Sign here', 'field-signature'); + await superdoc.waitForStable(); + + await replaceTextWithAnnotation(superdoc.page, '[IMAGE]', 'image', 'Add photo', 'field-image'); + await superdoc.waitForStable(); + + await replaceTextWithAnnotation(superdoc.page, '[LINK]', 'link', 'example.com', 'field-link', { + linkUrl: 'https://example.com', + }); + await superdoc.waitForStable(); + + await replaceTextWithAnnotation(superdoc.page, '[HTML]', 'html', '', 'field-html', { + rawHtml: '

Custom HTML

', + }); + await superdoc.waitForStable(); + await superdoc.screenshot('field-annotations-all-types'); +}); diff --git a/tests/visual/tests/behavior/field-annotations/table-cell-leading-caret.spec.ts b/tests/visual/tests/behavior/field-annotations/table-cell-leading-caret.spec.ts new file mode 100644 index 0000000000..212e32162c --- /dev/null +++ b/tests/visual/tests/behavior/field-annotations/table-cell-leading-caret.spec.ts @@ -0,0 +1,34 @@ +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { hideCaret: false } }); + +test('@behavior cursor placement before field annotation at start of table cell', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + await superdoc.screenshot('table-cell-caret-table'); + + // Insert field annotation at cursor (start of first cell) + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + editor.commands.addFieldAnnotationAtSelection({ + type: 'text', + displayLabel: 'Enter value', + fieldId: 'field-in-cell', + fieldColor: '#6366f1', + highlighted: true, + }); + }); + await superdoc.waitForStable(); + await superdoc.screenshot('table-cell-caret-annotation'); + + // Navigate to start of cell + await superdoc.press('End'); + await superdoc.shortcut('ArrowLeft'); + await superdoc.waitForStable(); + await superdoc.screenshot('table-cell-caret-at-start'); + + // Type before annotation + await superdoc.type('Prefix: '); + await superdoc.waitForStable(); + await superdoc.screenshot('table-cell-caret-typed-before'); +}); diff --git a/tests/visual/tests/behavior/formatting/apply-font.spec.ts b/tests/visual/tests/behavior/formatting/apply-font.spec.ts new file mode 100644 index 0000000000..aee970edaa --- /dev/null +++ b/tests/visual/tests/behavior/formatting/apply-font.spec.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/formatting/sd-1778-apply-font.docx'); + +test.use({ config: { toolbar: 'full' } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior apply Courier New font to selected text', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.selectAll(); + await superdoc.waitForStable(); + + await superdoc.executeCommand('setFontFamily', { fontFamily: 'Courier New' }); + await superdoc.waitForStable(); + await superdoc.screenshot('apply-font-courier'); +}); diff --git a/tests/visual/tests/behavior/formatting/bold-italic-formatting.spec.ts b/tests/visual/tests/behavior/formatting/bold-italic-formatting.spec.ts new file mode 100644 index 0000000000..facd3ee13b --- /dev/null +++ b/tests/visual/tests/behavior/formatting/bold-italic-formatting.spec.ts @@ -0,0 +1,32 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior bold and italic formatting', async ({ superdoc }) => { + await superdoc.type('This text will be bold.'); + await superdoc.newLine(); + await superdoc.type('This text will be italic.'); + await superdoc.newLine(); + await superdoc.type('This text will be both bold and italic.'); + + await superdoc.waitForStable(); + + // Select line 0 via Home → Shift+Down and apply bold + await superdoc.shortcut('Home'); // go to start of doc + await superdoc.press('Home'); + await superdoc.press('Shift+ArrowDown'); + await superdoc.bold(); + + // Move to line 1, select it, apply italic + await superdoc.press('ArrowDown'); + await superdoc.press('Home'); + await superdoc.press('Shift+End'); + await superdoc.italic(); + + // Move to line 2, select it, apply bold + italic + await superdoc.press('ArrowDown'); + await superdoc.press('Home'); + await superdoc.press('Shift+End'); + await superdoc.bold(); + await superdoc.italic(); + + await superdoc.screenshot('bold-italic-formatting'); +}); diff --git a/tests/visual/tests/behavior/formatting/clear-format-undo.spec.ts b/tests/visual/tests/behavior/formatting/clear-format-undo.spec.ts new file mode 100644 index 0000000000..8b2e79e3f0 --- /dev/null +++ b/tests/visual/tests/behavior/formatting/clear-format-undo.spec.ts @@ -0,0 +1,26 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior clear formatting and undo restores it', async ({ superdoc }) => { + // Type formatted text + await superdoc.bold(); + await superdoc.type('Bold text here.'); + await superdoc.bold(); + await superdoc.newLine(); + + await superdoc.italic(); + await superdoc.type('Italic text here.'); + await superdoc.italic(); + await superdoc.newLine(); + + await superdoc.type('Plain text here.'); + await superdoc.screenshot('clear-format-formatted'); + + // Clear formatting + await superdoc.selectAll(); + await superdoc.executeCommand('clearFormat'); + await superdoc.screenshot('clear-format-cleared'); + + // Undo should restore formatting + await superdoc.undo(); + await superdoc.screenshot('clear-format-after-undo'); +}); diff --git a/tests/visual/tests/behavior/formatting/insert-hyperlink.spec.ts b/tests/visual/tests/behavior/formatting/insert-hyperlink.spec.ts new file mode 100644 index 0000000000..679a90ac79 --- /dev/null +++ b/tests/visual/tests/behavior/formatting/insert-hyperlink.spec.ts @@ -0,0 +1,22 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior insert hyperlink on selected text', async ({ superdoc }) => { + await superdoc.type('Visit our website for more information'); + await superdoc.screenshot('hyperlink-text-typed'); + + // Select "website" by finding its position + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + const doc = editor.state.doc; + const text = doc.textContent; + const start = text.indexOf('website'); + // +1 because ProseMirror positions are 1-indexed from doc start + editor.commands.setTextSelection({ from: start + 1, to: start + 1 + 'website'.length }); + }); + await superdoc.screenshot('hyperlink-text-selected'); + + // Apply hyperlink + await superdoc.executeCommand('setLink', { href: 'https://example.com' }); + await superdoc.press('ArrowRight'); + await superdoc.screenshot('hyperlink-applied'); +}); diff --git a/tests/visual/tests/behavior/formatting/paragraph-style-inheritance.spec.ts b/tests/visual/tests/behavior/formatting/paragraph-style-inheritance.spec.ts new file mode 100644 index 0000000000..5874ac8ebc --- /dev/null +++ b/tests/visual/tests/behavior/formatting/paragraph-style-inheritance.spec.ts @@ -0,0 +1,26 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior new paragraphs inherit formatting', async ({ superdoc }) => { + // Type and apply bold + await superdoc.type('First paragraph text'); + await superdoc.selectAll(); + await superdoc.bold(); + await superdoc.screenshot('style-inheritance-bold'); + + // New paragraph should inherit bold + await superdoc.press('End'); + await superdoc.newLine(); + await superdoc.type('Second paragraph text'); + await superdoc.screenshot('style-inheritance-bold-inherited'); + + // Apply italic on top + await superdoc.selectAll(); + await superdoc.italic(); + await superdoc.screenshot('style-inheritance-bold-italic'); + + // New paragraph should inherit both + await superdoc.press('End'); + await superdoc.newLine(); + await superdoc.type('Third paragraph text'); + await superdoc.screenshot('style-inheritance-both-inherited'); +}); diff --git a/tests/visual/tests/behavior/formatting/toggle-formatting-off.spec.ts b/tests/visual/tests/behavior/formatting/toggle-formatting-off.spec.ts new file mode 100644 index 0000000000..8ae6d9d830 --- /dev/null +++ b/tests/visual/tests/behavior/formatting/toggle-formatting-off.spec.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/formatting/sd-1727-formatting-lost.docx'); + +test.use({ config: { toolbar: 'full', hideCaret: false, hideSelection: false } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior toggle bold off retains other formatting', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.screenshot('toggle-format-initial'); + + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.screenshot('toggle-format-selected'); + + await superdoc.bold(); + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + await superdoc.screenshot('toggle-format-bold-applied'); + + await superdoc.bold(); + await superdoc.waitForStable(); + await superdoc.screenshot('toggle-format-bold-off'); + + await superdoc.press('Enter'); + await superdoc.italic(); + await superdoc.type('hello italic'); + await superdoc.waitForStable(); + await superdoc.screenshot('toggle-format-italic-typed'); +}); diff --git a/tests/visual/tests/behavior/headers/double-click-edit-header.spec.ts b/tests/visual/tests/behavior/headers/double-click-edit-header.spec.ts new file mode 100644 index 0000000000..e65045634d --- /dev/null +++ b/tests/visual/tests/behavior/headers/double-click-edit-header.spec.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/headers/longer-header.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available'); + +test('@behavior double-click header to enter edit mode', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await superdoc.screenshot('header-edit-loaded'); + + // Double-click on header + const header = superdoc.page.locator('.superdoc-page-header').first(); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + await header.dblclick({ force: true }); + await superdoc.waitForStable(); + await superdoc.screenshot('header-edit-editing'); + + await superdoc.type(' - Edited'); + await superdoc.waitForStable(); + await superdoc.screenshot('header-edit-typed'); + + await superdoc.press('Escape'); + await superdoc.waitForStable(); + await superdoc.screenshot('header-edit-exited'); + + // Double-click on footer + const footer = superdoc.page.locator('.superdoc-page-footer').first(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + await footer.dblclick({ force: true }); + await superdoc.waitForStable(); + await superdoc.screenshot('footer-edit-editing'); + + await superdoc.type(' - Edited'); + await superdoc.waitForStable(); + await superdoc.screenshot('footer-edit-typed'); + + await superdoc.press('Escape'); + await superdoc.waitForStable(); + await superdoc.screenshot('footer-edit-exited'); +}); diff --git a/tests/visual/tests/behavior/importing/load-doc-with-pict.spec.ts b/tests/visual/tests/behavior/importing/load-doc-with-pict.spec.ts new file mode 100644 index 0000000000..a15e5c1e8c --- /dev/null +++ b/tests/visual/tests/behavior/importing/load-doc-with-pict.spec.ts @@ -0,0 +1,21 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/importing/sd-1558-fld-char-issue.docx'); + +test.use({ config: { comments: 'off' } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior load document with w:pict elements without schema errors', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await superdoc.screenshot('load-pict-loaded'); + + // Screenshot first 5 pages (doc has 6+, later pages may not render in time) + await superdoc.screenshotPages('importing/load-pict', 5); +}); diff --git a/tests/visual/tests/behavior/lists/empty-list-item-markers.spec.ts b/tests/visual/tests/behavior/lists/empty-list-item-markers.spec.ts new file mode 100644 index 0000000000..933d72665b --- /dev/null +++ b/tests/visual/tests/behavior/lists/empty-list-item-markers.spec.ts @@ -0,0 +1,30 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/lists/sd-1543-empty-list-items.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior empty list items show correct markers', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await superdoc.screenshot('empty-list-markers-loaded'); + + // Type into a later empty list item first (position 229) + await superdoc.setTextSelection(229); + await superdoc.waitForStable(); + await superdoc.type('item 2'); + await superdoc.waitForStable(); + await superdoc.screenshot('empty-list-markers-typed-item2'); + + // Type into an earlier empty list item (position 34) + await superdoc.setTextSelection(34); + await superdoc.waitForStable(); + await superdoc.type('New content in empty list item'); + await superdoc.waitForStable(); + await superdoc.screenshot('empty-list-markers-typed-item1'); +}); diff --git a/tests/visual/tests/behavior/lists/indent-list-items.spec.ts b/tests/visual/tests/behavior/lists/indent-list-items.spec.ts new file mode 100644 index 0000000000..0bca48b39f --- /dev/null +++ b/tests/visual/tests/behavior/lists/indent-list-items.spec.ts @@ -0,0 +1,25 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('@behavior list indentation and outdentation', async ({ superdoc }) => { + // Create a numbered list by typing "1. " + await superdoc.type('1. '); + await superdoc.type('item 1'); + await superdoc.screenshot('list-item-1'); + + // Add second item + await superdoc.newLine(); + await superdoc.type('item 2'); + await superdoc.screenshot('list-item-2'); + + // Indent third item with Tab + await superdoc.newLine(); + await superdoc.press('Tab'); + await superdoc.type('item a'); + await superdoc.screenshot('list-indented'); + + // Outdent with Shift+Tab + await superdoc.newLine(); + await superdoc.press('Shift+Tab'); + await superdoc.type('item 3'); + await superdoc.screenshot('list-outdented'); +}); diff --git a/tests/visual/tests/behavior/lists/same-level-same-indicator.spec.ts b/tests/visual/tests/behavior/lists/same-level-same-indicator.spec.ts new file mode 100644 index 0000000000..2f8da070ea --- /dev/null +++ b/tests/visual/tests/behavior/lists/same-level-same-indicator.spec.ts @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/lists/sd-1658-lists-same-level.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available (R2)'); + +test('@behavior list items with same indicator at same level render correctly', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await superdoc.screenshot('same-level-same-indicator'); +}); diff --git a/tests/visual/tests/behavior/search/search-and-navigate.spec.ts b/tests/visual/tests/behavior/search/search-and-navigate.spec.ts new file mode 100644 index 0000000000..ed610b3533 --- /dev/null +++ b/tests/visual/tests/behavior/search/search-and-navigate.spec.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../../test-data'); +const DOC_PATH = path.join(DOCS_DIR, 'behavior/headers/longer-header.docx'); + +test.use({ config: { hideSelection: false } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available'); + +test('@behavior search and navigate to results in document', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await superdoc.screenshot('search-navigate-loaded'); + + // Cross-paragraph search + const query1 = 'works of the Licensed Material; (b) distribute, sell,'; + const matches1 = await superdoc.page.evaluate((q: string) => { + return (window as any).editor?.commands?.search?.(q) ?? []; + }, query1); + + if (matches1.length > 0) { + await superdoc.page.evaluate((match: any) => { + (window as any).editor.commands.goToSearchResult(match); + }, matches1[0]); + await superdoc.waitForStable(); + await superdoc.screenshot('search-navigate-first-result'); + } + + // Multi-paragraph search + const query2 = 'Law This Agreement shall be governed by'; + const matches2 = await superdoc.page.evaluate((q: string) => { + return (window as any).editor?.commands?.search?.(q) ?? []; + }, query2); + + if (matches2.length > 0) { + await superdoc.page.evaluate((match: any) => { + (window as any).editor.commands.goToSearchResult(match); + }, matches2[0]); + await superdoc.waitForStable(); + await superdoc.screenshot('search-navigate-second-result'); + } +}); diff --git a/tests/visual/tests/behavior/structured-content/sdt-lock-modes.spec.ts b/tests/visual/tests/behavior/structured-content/sdt-lock-modes.spec.ts new file mode 100644 index 0000000000..72c7fa90a9 --- /dev/null +++ b/tests/visual/tests/behavior/structured-content/sdt-lock-modes.spec.ts @@ -0,0 +1,116 @@ +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { hideCaret: false } }); + +test.fixme('@behavior SDT lock modes enforcement', async ({ superdoc }) => { + // Insert unlocked inline SDT + await superdoc.type('Unlocked inline: '); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { id: '100', alias: 'Unlocked Field', lockMode: 'unlocked' }, + text: 'editable value', + }); + }); + await superdoc.waitForStable(); + + // Insert sdtLocked inline SDT + await superdoc.press('End'); + await superdoc.press('Enter'); + await superdoc.type('SDT-locked inline: '); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { id: '200', alias: 'SDT Locked', lockMode: 'sdtLocked' }, + text: 'cannot delete wrapper', + }); + }); + await superdoc.waitForStable(); + + // Insert contentLocked inline SDT + await superdoc.press('End'); + await superdoc.press('Enter'); + await superdoc.type('Content-locked inline: '); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { id: '300', alias: 'Content Locked', lockMode: 'contentLocked' }, + text: 'read-only content', + }); + }); + await superdoc.waitForStable(); + await superdoc.screenshot('sdt-inline-created'); + + // Insert block SDT with sdtContentLocked + await superdoc.press('End'); + await superdoc.press('Enter'); + await superdoc.press('Enter'); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentBlock({ + attrs: { id: '400', alias: 'Fully Locked Block', lockMode: 'sdtContentLocked' }, + html: '

This block is fully locked (sdtContentLocked).

', + }); + }); + await superdoc.waitForStable(); + await superdoc.screenshot('sdt-block-created'); + + // Type inside sdtLocked (content should be editable) + const sdt200 = await superdoc.page.evaluate(() => { + let result: { pos: number; size: number } | null = null; + (window as any).editor.state.doc.descendants((node: any, pos: number) => { + if (result) return false; + if ( + (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && + String(node.attrs.id) === '200' + ) { + result = { pos, size: node.nodeSize }; + return false; + } + return true; + }); + return result; + }); + + if (sdt200) { + await superdoc.setTextSelection(sdt200.pos + 2); + await superdoc.waitForStable(); + await superdoc.type(' ADDED'); + await superdoc.waitForStable(); + await superdoc.screenshot('sdt-locked-typed'); + } + + // Try typing inside contentLocked (should be blocked) + const sdt300 = await superdoc.page.evaluate(() => { + let result: { pos: number; size: number } | null = null; + (window as any).editor.state.doc.descendants((node: any, pos: number) => { + if (result) return false; + if ( + (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && + String(node.attrs.id) === '300' + ) { + result = { pos, size: node.nodeSize }; + return false; + } + return true; + }); + return result; + }); + + if (sdt300) { + await superdoc.setTextSelection(sdt300.pos + 2); + await superdoc.waitForStable(); + await superdoc.type('BLOCKED'); + await superdoc.waitForStable(); + await superdoc.screenshot('sdt-content-locked-typing'); + } + + // Update lock mode: unlocked → contentLocked + await superdoc.page.evaluate(() => { + (window as any).editor.commands.updateStructuredContentById('100', { + attrs: { lockMode: 'contentLocked' }, + }); + }); + await superdoc.waitForStable(); + await superdoc.screenshot('sdt-lock-mode-updated'); +}); diff --git a/tests/visual/tests/fixtures/superdoc.ts b/tests/visual/tests/fixtures/superdoc.ts new file mode 100644 index 0000000000..dd80af3944 --- /dev/null +++ b/tests/visual/tests/fixtures/superdoc.ts @@ -0,0 +1,306 @@ +import { test as base, expect, type Page } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Helpers — inline versions of what @superdoc-testing/helpers provides, +// kept here so the prototype has zero workspace deps beyond Playwright. +// --------------------------------------------------------------------------- + +const HARNESS_URL = 'http://localhost:9989'; + +interface HarnessConfig { + layout?: boolean; + toolbar?: 'none' | 'minimal' | 'full'; + comments?: 'off' | 'on' | 'panel' | 'readonly'; + trackChanges?: boolean; + hideCaret?: boolean; + hideSelection?: boolean; + width?: number; + height?: number; +} + +function buildHarnessUrl(config: HarnessConfig = {}): string { + const params = new URLSearchParams(); + if (config.layout !== undefined) params.set('layout', config.layout ? '1' : '0'); + if (config.toolbar) params.set('toolbar', config.toolbar); + if (config.comments) params.set('comments', config.comments); + if (config.trackChanges) params.set('trackChanges', '1'); + if (config.hideCaret !== undefined) params.set('hideCaret', config.hideCaret ? '1' : '0'); + if (config.hideSelection !== undefined) params.set('hideSelection', config.hideSelection ? '1' : '0'); + if (config.width) params.set('width', String(config.width)); + if (config.height) params.set('height', String(config.height)); + const qs = params.toString(); + return qs ? `${HARNESS_URL}?${qs}` : HARNESS_URL; +} + +async function waitForReady(page: Page, timeout = 30_000): Promise { + await page.waitForFunction(() => (window as any).superdocReady === true, null, { polling: 100, timeout }); +} + +async function waitForStable(page: Page, ms = 500): Promise { + await page.waitForTimeout(ms); + await page.evaluate(() => document.fonts.ready); +} + +// --------------------------------------------------------------------------- +// SuperDoc fixture +// --------------------------------------------------------------------------- + +export interface SuperDocFixture { + /** The raw Playwright Page */ + page: Page; + + /** Type text into the editor */ + type(text: string): Promise; + /** Press a single key */ + press(key: string): Promise; + /** Press Enter */ + newLine(): Promise; + /** Press Cmd/Ctrl+key */ + shortcut(key: string): Promise; + /** Toggle bold */ + bold(): Promise; + /** Toggle italic */ + italic(): Promise; + /** Toggle underline */ + underline(): Promise; + /** Undo */ + undo(): Promise; + /** Redo */ + redo(): Promise; + /** Select all */ + selectAll(): Promise; + /** Triple-click a line by index to select it */ + tripleClickLine(lineIndex: number): Promise; + /** Execute an editor command via window.editor.commands */ + executeCommand(name: string, args?: Record): Promise; + /** Set document mode (editing, suggesting, viewing) */ + setDocumentMode(mode: 'editing' | 'suggesting' | 'viewing'): Promise; + /** Set cursor/selection position via ProseMirror positions */ + setTextSelection(from: number, to?: number): Promise; + /** Single click on a line by index */ + clickOnLine(lineIndex: number, xOffset?: number): Promise; + /** Click on a comment highlight containing the given text */ + clickOnCommentedText(textMatch: string): Promise; + /** Press a key multiple times */ + pressTimes(key: string, count: number): Promise; + /** Wait for the editor to stabilize */ + waitForStable(ms?: number): Promise; + + /** Wait for editor to stabilize, then take a full-page screenshot */ + screenshot(name: string): Promise; + + /** Load a .docx document into the editor */ + loadDocument(filePath: string): Promise; + + /** Screenshot every rendered page (for paginated/layout docs) */ + screenshotPages(baseName: string, maxPages?: number): Promise; +} + +interface SuperDocOptions { + config?: HarnessConfig; +} + +export const test = base.extend<{ superdoc: SuperDocFixture } & SuperDocOptions>({ + config: [{}, { option: true }], + + superdoc: async ({ page, config }, use) => { + const modKey = process.platform === 'darwin' ? 'Meta' : 'Control'; + + // Navigate to harness + const url = buildHarnessUrl({ + layout: true, + hideCaret: true, + hideSelection: true, + ...config, + }); + await page.goto(url); + await waitForReady(page); + + // Focus the editor — use .focus() not .click() because in layout mode + // the ProseMirror contenteditable is positioned off-screen (DomPainter renders visuals). + const editor = page.locator('[contenteditable="true"]').first(); + await editor.waitFor({ state: 'visible', timeout: 10_000 }); + await editor.focus(); + + const fixture: SuperDocFixture = { + page, + + async type(text: string) { + await editor.focus(); + await page.keyboard.type(text, { delay: 30 }); + }, + + async press(key: string) { + await page.keyboard.press(key); + }, + + async newLine() { + await page.keyboard.press('Enter'); + }, + + async shortcut(key: string) { + await page.keyboard.press(`${modKey}+${key}`); + }, + + async bold() { + await page.keyboard.press(`${modKey}+b`); + }, + + async italic() { + await page.keyboard.press(`${modKey}+i`); + }, + + async underline() { + await page.keyboard.press(`${modKey}+u`); + }, + + async undo() { + await page.keyboard.press(`${modKey}+z`); + }, + + async redo() { + await page.keyboard.press(`${modKey}+Shift+z`); + }, + + async selectAll() { + await page.keyboard.press(`${modKey}+a`); + }, + + async tripleClickLine(lineIndex: number) { + const line = page.locator('.superdoc-line').nth(lineIndex); + const box = await line.boundingBox(); + if (!box) throw new Error(`Line ${lineIndex} not visible`); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { clickCount: 3 }); + }, + + async setDocumentMode(mode: 'editing' | 'suggesting' | 'viewing') { + await page.evaluate((m) => { + const sd = (window as any).superdoc; + // Some modes (e.g., viewing) access toolbar internals — guard against null + if (sd.toolbar) { + sd.setDocumentMode(m); + } else { + // Fallback: set mode on activeEditor directly + sd.activeEditor?.setDocumentMode(m); + } + }, mode); + }, + + async setTextSelection(from: number, to?: number) { + await page.waitForFunction(() => (window as any).editor?.commands, null, { timeout: 10_000 }); + await page.evaluate( + ({ f, t }) => { + const editor = (window as any).editor; + editor.commands.setTextSelection({ from: f, to: t ?? f }); + }, + { f: from, t: to }, + ); + }, + + async clickOnLine(lineIndex: number, xOffset = 10) { + const line = page.locator('.superdoc-line').nth(lineIndex); + const box = await line.boundingBox(); + if (!box) throw new Error(`Line ${lineIndex} not visible`); + await page.mouse.click(box.x + xOffset, box.y + box.height / 2); + }, + + async clickOnCommentedText(textMatch: string) { + const highlights = page.locator('.superdoc-comment-highlight'); + const count = await highlights.count(); + let bestIndex = -1; + let bestArea = Infinity; + + for (let i = 0; i < count; i++) { + const hl = highlights.nth(i); + const text = await hl.textContent(); + if (text && text.includes(textMatch)) { + const box = await hl.boundingBox(); + if (box) { + const area = box.width * box.height; + if (area < bestArea) { + bestArea = area; + bestIndex = i; + } + } + } + } + + if (bestIndex === -1) throw new Error(`No comment highlight found for "${textMatch}"`); + await highlights.nth(bestIndex).click(); + }, + + async pressTimes(key: string, count: number) { + for (let i = 0; i < count; i++) { + await page.keyboard.press(key); + } + }, + + async executeCommand(name: string, args?: Record) { + await page.waitForFunction(() => (window as any).editor?.commands, null, { timeout: 10_000 }); + await page.evaluate( + ({ cmd, cmdArgs }) => { + const editor = (window as any).editor; + if (!editor?.commands?.[cmd]) throw new Error(`Command "${cmd}" not found`); + if (cmdArgs && Object.keys(cmdArgs).length > 0) { + editor.commands[cmd](cmdArgs); + } else { + editor.commands[cmd](); + } + }, + { cmd: name, cmdArgs: args }, + ); + }, + + async waitForStable(ms?: number) { + await waitForStable(page, ms); + }, + + async screenshot(name: string) { + await waitForStable(page); + + await expect(page).toHaveScreenshot(`${name}.png`, { + fullPage: true, + timeout: 15_000, + }); + }, + + async loadDocument(filePath: string) { + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(filePath); + // Wait for document to load and render + await page.waitForFunction( + () => (window as any).superdoc !== undefined && (window as any).editor !== undefined, + null, + { polling: 100, timeout: 30_000 }, + ); + await waitForStable(page, 1000); + }, + + async screenshotPages(baseName: string, maxPages?: number) { + await waitForStable(page); + + const pages = page.locator('.superdoc-page[data-page-index]'); + let count = await pages.count(); + if (maxPages && count > maxPages) count = maxPages; + + if (count === 0) { + // No paginated pages — screenshot the whole editor + await fixture.screenshot(baseName); + return; + } + + for (let i = 0; i < count; i++) { + const pageEl = pages.nth(i); + + await expect(pageEl).toHaveScreenshot(`${baseName}-p${i + 1}.png`, { + timeout: 15_000, + }); + } + }, + }; + + await use(fixture); + }, +}); + +export { expect }; diff --git a/tests/visual/tests/rendering/basic-layout.spec.ts b/tests/visual/tests/rendering/basic-layout.spec.ts new file mode 100644 index 0000000000..dec4b3ec77 --- /dev/null +++ b/tests/visual/tests/rendering/basic-layout.spec.ts @@ -0,0 +1,16 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../test-data/rendering'); + +test('@rendering basic document renders correctly', async ({ superdoc }) => { + await superdoc.loadDocument(path.join(DOCS_DIR, 'advanced-text.docx')); + await superdoc.screenshotPages('rendering/advanced-text'); +}); + +test('@rendering table document renders correctly', async ({ superdoc }) => { + await superdoc.loadDocument(path.join(DOCS_DIR, 'advanced-tables.docx')); + await superdoc.screenshotPages('rendering/advanced-tables'); +}); diff --git a/tests/visual/tsconfig.json b/tests/visual/tsconfig.json new file mode 100644 index 0000000000..a998c0737c --- /dev/null +++ b/tests/visual/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["tests/**/*.ts", "playwright.config.ts"] +}