diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0928de2..5b7ff33 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -91,7 +91,9 @@ jobs: - run: npm run build --workspaces --if-present - - run: npm run test --workspaces --if-present + # shuffled so a test that only passes after another one fails here; the seed is the run id, so a failure + # reproduces locally with `npm test -- --sequence.shuffle --sequence.seed=` + - run: npm run test --workspaces --if-present -- --sequence.shuffle --sequence.seed=${{ github.run_id }} next-fixture: if: startsWith(github.head_ref, 'release-please--') diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml new file mode 100644 index 0000000..d10c85c --- /dev/null +++ b/.github/workflows/contract.yml @@ -0,0 +1,31 @@ +name: contract + +# The derivative API contract tests against the real upload.uploadcare.com. +# Run by hand only (each run spends one AI generation); the same tests run +# against the emulator in every `npm test`. Needs the UC_CONTRACT_PUBLIC_KEY +# secret: a project with AI derivatives enabled and signed uploads off. When +# it is missing the run fails instead of skipping. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + + - run: npm ci + + - name: Run the contract tests against the real API + env: + UC_CONTRACT_PUBLIC_KEY: ${{ secrets.UC_CONTRACT_PUBLIC_KEY }} + run: npm run test:contract:live -w @uploadcare/ai-image-editor diff --git a/AGENTS.md b/AGENTS.md index 8dd55ed..5b41e4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,64 @@ packages again would give the plugin two `AuthTokenResolverError` classes: `instanceof` fails across them and the editor wraps the uploader's error a second time, pushing the original one level deeper on `cause`. +## Tests talk to the Uploadcare API emulator + +`@uploadcare/api-emulator` (a devDependency of `packages/ai-image-editor`) +stands in for the Upload API and CDN; tests should not hand-write Uploadcare +responses. + +- **Specs** (happy-dom) run against `setupEmulator()` from + `@uploadcare/api-emulator/node`, started once in `tests/specs/setup.ts` + (the `specs` project's setup file). It answers the global `fetch` and + `node:http(s)` (upload-client's transport, so `getFileInfo` too), refuses + every other origin, and resets the session before every test: import + `session` from there to register a scenario or read what was sent from its + `requests`. The clients call the global `fetch`; a spec that checks what + they pass it uses `vi.spyOn(globalThis, 'fetch')` and restores it after the + test. +- **Browser e2e** run the emulator in the page with + `setupEmulator()` from `@uploadcare/api-emulator/browser` (MSW and + `@mswjs/interceptors` underneath, both devDependencies of the root + `package.json`): + `tests/emulator.ts` emulates Uploadcare's hosts and `cdn.example.com` (the + tests' CDN cname), fails any other Uploadcare host, passes every + non-Uploadcare origin through, and resets the session before every test; + import `session` from there to register a scenario or preset, or to read + what the editor sent (`requests`) and what a run produced (`files`; the + harness's `resultUuid()` answers the one that isn't a `DEMO_FILES` image). + A test that checks what the editor passed `fetch` itself uses + `vi.spyOn(globalThis, 'fetch')` and restores it after the test. The editor + tests apply the `derivativesInstant` preset (`tests/editor/harness.ts`) so + a generation finishes on its first poll instead of after the editor's 1.5s + interval several times over. +- **Contract** tests (`tests/contract/`, the `contract` project) hold the + derivative API to one set of assertions on two targets: the emulator in + every `npm test`, and the real `upload.uploadcare.com` with + `npm run test:contract:live -w @uploadcare/ai-image-editor` + (`UC_CONTRACT_PUBLIC_KEY` required; the run fails without it). CI runs the + live target only by hand (the `contract` workflow). +- In specs, e2e and contract tests, a request or response that fails the + client's dev schemas (`uploadcareApiClient.schemas.dev.ts`) fails the test + (`tests/schema-drift.ts`). +- Steer the emulator per test with `session.on()` and `session.use(preset)` + (`session` from the project's setup file); there are no magic keys, + prompts or uuids. +- Bearer tokens must be real: mint them with the emulator's `mintAuthToken()` + (WebCrypto, so it works in the page too). The images every fresh session + holds are its `DEMO_FILES`. +- A `session.on()` handler runs before the emulator's routes and can answer + any `Response` or a promise that never settles, so a bare non-JSON failure, + a job stuck in `processing` or a poll that hangs is a scenario, not a stub. + Stub `fetch` only where it is simpler, and say why next to it. + +The React wrapper's e2e project runs against the same in-page emulator +(`packages/react-ai-image-editor/tests/e2e/setup.ts`), so a request the +element starts making there fails loudly instead of reaching the network. + +In both packages the dependency is a TEMPORARY `file:` link to an unreleased +checkout, so `npm ci` only resolves it on that machine; swap it for a version +once the package ships. + ## Docs layout `docs/` is a VitePress site published to GitHub Pages: hand-written guides in diff --git a/biome.json b/biome.json index 54ea4d6..0a151aa 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,8 @@ { "$schema": "https://biomejs.dev/schemas/2.2.6/schema.json", + "files": { + "includes": ["**", "!**/.next"] + }, "assist": { "actions": { "source": { diff --git a/package-lock.json b/package-lock.json index cc13342..631c880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,18 +14,60 @@ ], "devDependencies": { "@biomejs/biome": "2.2.6", - "@vitest/browser-playwright": "^4.1.10", + "@microsoft/api-extractor": "^7.59.4", + "@mswjs/interceptors": "0.45.7", + "@vitest/browser-playwright": "^4.1.11", + "esbuild": "^0.28.2", "happy-dom": "^20.9.0", "husky": "^9.0.11", "lerna": "^10.0.1", "lint-staged": "^15.2.2", + "msw": "3.0.2", "typescript": "^5.6.0", - "vite": "^6.2.3", - "vite-plugin-dts": "^4.5.0", - "vitest": "^4.1.10" + "vite": "^8.3.3", + "vite-plugin-dts": "^5.1.2", + "vitest": "^4.1.11" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "../uc-586-sync/packages/api-emulator": { + "name": "@uploadcare/api-emulator", + "version": "6.23.0", + "dev": true, + "license": "MIT", + "devDependencies": { + "@mswjs/interceptors": "^0.45.7", + "@vitest/browser-playwright": "^4.1.11", + "@vitest/coverage-v8": "^4.1.11", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "fast-check": "^4.10.2", + "msw": "^3.0.2", + "oxfmt": "^0.53.0", + "oxlint": "^1.68.0", + "oxlint-tsgolint": "^0.23.0", + "playwright": "^1.63.0", + "vite": "^7.0.0", + "vite-plugin-dts": "^4.5.4", + "vitest": "^4.1.11", + "yaml": "^2.9.1" }, "engines": { "node": ">=20" + }, + "peerDependencies": { + "@mswjs/interceptors": "^0.45.7", + "msw": "^3.0.2" + }, + "peerDependenciesMeta": { + "@mswjs/interceptors": { + "optional": true + }, + "msw": { + "optional": true + } } }, "node_modules/@algolia/abtesting": { @@ -715,9 +757,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -732,9 +774,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -749,9 +791,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -766,9 +808,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -783,9 +825,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -800,9 +842,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -817,9 +859,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -834,9 +876,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -851,9 +893,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -868,9 +910,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -885,9 +927,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -902,9 +944,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -919,9 +961,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -936,9 +978,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -953,9 +995,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -970,9 +1012,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -987,9 +1029,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -1004,9 +1046,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -1021,9 +1063,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -1038,9 +1080,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -1055,9 +1097,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1072,9 +1114,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1089,9 +1131,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1106,9 +1148,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1123,9 +1165,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1140,9 +1182,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1607,6 +1649,38 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1614,6 +1688,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@lit-labs/motion": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lit-labs/motion/-/motion-1.1.0.tgz", @@ -1659,19 +1744,19 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.58.7", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.7.tgz", - "integrity": "sha512-yK6OycD46gIzLRpj6ueVUWPk1ACSpkN1LBo05gY1qPTylbWyUCanXfH7+VgkI5LJrJoRSQR5F04XuCffCXLOBw==", + "version": "7.59.4", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.59.4.tgz", + "integrity": "sha512-XqseDKIw736qlhQwS9SOpbY4veOiP2Nc7YiWmgXXBmeA3kY9qGXw+Tu7DFpU9FvXywE1z8SvLJKUVxFpcX0kig==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor-model": "7.33.8", - "@microsoft/tsdoc": "~0.16.0", - "@microsoft/tsdoc-config": "~0.18.1", - "@rushstack/node-core-library": "5.23.1", - "@rushstack/rig-package": "0.7.3", - "@rushstack/terminal": "0.24.0", - "@rushstack/ts-command-line": "5.3.9", + "@microsoft/api-extractor-model": "7.33.15", + "@microsoft/tsdoc": "~0.17.0", + "@microsoft/tsdoc-config": "~0.18.2", + "@rushstack/node-core-library": "5.25.1", + "@rushstack/rig-package": "0.7.4", + "@rushstack/terminal": "0.24.7", + "@rushstack/ts-command-line": "5.3.17", "diff": "~8.0.2", "minimatch": "10.2.3", "resolve": "~1.22.1", @@ -1681,18 +1766,24 @@ }, "bin": { "api-extractor": "bin/api-extractor" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.33.8", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.8.tgz", - "integrity": "sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==", + "version": "7.33.15", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.15.tgz", + "integrity": "sha512-UpCnItIJwu03iNTWtJVKv8Qbf0MXN7WLQZUail9cpAQmnZiIX36YPRRoRxz0pocwytzUpmJhstsLkyW5tsaHBA==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "~0.16.0", - "@microsoft/tsdoc-config": "~0.18.1", - "@rushstack/node-core-library": "5.23.1" + "@microsoft/tsdoc": "~0.17.0", + "@microsoft/tsdoc-config": "~0.18.2", + "@rushstack/node-core-library": "5.25.1" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@microsoft/api-extractor/node_modules/balanced-match": { @@ -1748,25 +1839,50 @@ } }, "node_modules/@microsoft/tsdoc": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", - "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.17.1.tgz", + "integrity": "sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA==", "dev": true, "license": "MIT" }, "node_modules/@microsoft/tsdoc-config": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", - "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.3.tgz", + "integrity": "sha512-0I0rGXo1eJziw3EEErkDS9p/0RzBcyOiV+zbSDqOZTeURmhtut+WIvpih0LM5g+ZpZ9SyFxDfkhAcl/r8e2zGw==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "0.16.0", + "@microsoft/tsdoc": "0.17.1", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, + "node_modules/@msw/url": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@msw/url/-/url-0.1.2.tgz", + "integrity": "sha512-DdeBLQxQ+ZrtmwEmaj7GQ7lbzPCgbbbyAqOz/I4ulo68hwBpfdNvQJ5bmihJUkAAKOeCmqCIG3OFZGosqFN4mA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.45.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.45.7.tgz", + "integrity": "sha512-PPZQBiojRuy1jJQoUqbrJhoac47GC1GwRazDrYwwbE70zfqMkOY/5a+Du4hp3vlPUXLt/um4TMcmRabmooAkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/until": "^3.0.1", + "@types/debug": "^4.1.13", + "debug": "^4.4.3", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "rettime": "^0.11.12" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -2748,6 +2864,26 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@open-draft/until": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-3.0.1.tgz", + "integrity": "sha512-s7/9ELP4aP9YZtW7RJaa0Xf3RISaRH9+EFN18FtykJYRHGNrl8f9ymvoNXzjhrjmftFvkQudtuDU9RYhk0xs3A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { "version": "11.21.3", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", @@ -3090,53 +3226,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "cpu": [ "arm" ], @@ -3145,12 +3238,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -3159,12 +3255,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -3173,12 +3272,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -3187,26 +3289,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -3215,12 +3306,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -3229,105 +3323,427 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ - "arm" + "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ - "loong64" + "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ - "loong64" + "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ - "ppc64" + "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.4", @@ -3484,13 +3900,13 @@ ] }, "node_modules/@rushstack/node-core-library": { - "version": "5.23.1", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.23.1.tgz", - "integrity": "sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==", + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.25.1.tgz", + "integrity": "sha512-ozoRDwIx3jStOYpx86rFyXH4SaUR+DVT6HubQ59avLplJkWXSm5InztYH7RrOzzq9PJXTdq9/+lKX06SfkLVsA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "~8.18.0", + "ajv": "~8.20.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", @@ -3499,6 +3915,9 @@ "resolve": "~1.22.1", "semver": "~7.7.4" }, + "engines": { + "node": ">=20.9.0" + }, "peerDependencies": { "@types/node": "*" }, @@ -3508,6 +3927,23 @@ } } }, + "node_modules/@rushstack/node-core-library/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@rushstack/node-core-library/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3537,27 +3973,33 @@ } }, "node_modules/@rushstack/rig-package": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.3.tgz", - "integrity": "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==", + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.4.tgz", + "integrity": "sha512-SHZdzc9b2ptUb5uvRWALtAceD/QKZ4UAnZjcaKviTMPmHBnW14gs7XDocn6sDv2EqgnxxFPFkpV7/I3cUijCbw==", "dev": true, "license": "MIT", "dependencies": { "jju": "~1.4.0", "resolve": "~1.22.1" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@rushstack/terminal": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.24.0.tgz", - "integrity": "sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==", + "version": "0.24.7", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.24.7.tgz", + "integrity": "sha512-o8J9nHOIwfYb0s0eG2WYhx7ed3YIvBXQHVFxYdXCmVdBOkTkdArJcgVzRS8ewUepmlic4nGgJPjKIVaNKSqEnA==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/node-core-library": "5.23.1", + "@rushstack/node-core-library": "5.25.1", "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, + "engines": { + "node": ">=20.9.0" + }, "peerDependencies": { "@types/node": "*" }, @@ -3584,16 +4026,19 @@ } }, "node_modules/@rushstack/ts-command-line": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.9.tgz", - "integrity": "sha512-GIHqU+sRGQ3LGWAZu1O+9Yh++qwtyNIIGuNbcWHJjBTm2qRez0cwINUHZ+pQLR8UuzZDcMajrDaNbUYoaL/XtQ==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.17.tgz", + "integrity": "sha512-pIwhWawZXAT0nTXwvcE/InQ7uaVAgSeIzILMb2mBrI01Hz1yEvxrhV2+DE2Fzux8aZY9OJTdLsI+sk9kZDuAJw==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/terminal": "0.24.0", + "@rushstack/terminal": "0.24.7", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@rushstack/ts-command-line/node_modules/argparse": { @@ -4179,6 +4624,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4225,6 +4680,10 @@ "resolved": "packages/ai-image-editor", "link": true }, + "node_modules/@uploadcare/api-emulator": { + "resolved": "../uc-586-sync/packages/api-emulator", + "link": true + }, "node_modules/@uploadcare/cdn-url": { "version": "6.20.0-alpha.6", "resolved": "https://registry.npmjs.org/@uploadcare/cdn-url/-/cdn-url-6.20.0-alpha.6.tgz", @@ -4300,30 +4759,16 @@ "node": ">=16" } }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, "node_modules/@vitest/browser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", - "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", + "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", "dev": true, "license": "MIT", "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/mocker": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", @@ -4334,18 +4779,18 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.10" + "vitest": "4.1.11" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", - "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", + "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/browser": "4.1.10", - "@vitest/mocker": "4.1.10", + "@vitest/browser": "4.1.11", + "@vitest/mocker": "4.1.11", "tinyrainbow": "^3.1.0" }, "funding": { @@ -4353,7 +4798,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.10" + "vitest": "4.1.11" }, "peerDependenciesMeta": { "playwright": { @@ -4361,32 +4806,41 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "node_modules/@vitest/browser/node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4406,10 +4860,28 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -4420,13 +4892,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -4434,14 +4906,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4450,9 +4922,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -4460,13 +4932,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4578,17 +5050,6 @@ "@vue/shared": "3.5.38" } }, - "node_modules/@vue/compiler-vue2": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", - "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, "node_modules/@vue/devtools-api": { "version": "7.7.9", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", @@ -4625,57 +5086,6 @@ "rfdc": "^1.4.1" } }, - "node_modules/@vue/language-core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.0.tgz", - "integrity": "sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "~2.4.11", - "@vue/compiler-dom": "^3.5.0", - "@vue/compiler-vue2": "^2.7.16", - "@vue/shared": "^3.5.0", - "alien-signals": "^0.4.9", - "minimatch": "^9.0.3", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@vue/reactivity": { "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", @@ -5034,9 +5444,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "bin": { @@ -5146,13 +5556,6 @@ "node": ">= 14.0.0" } }, - "node_modules/alien-signals": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.4.14.tgz", - "integrity": "sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==", - "dev": true, - "license": "MIT" - }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -5621,9 +6024,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -6054,9 +6457,9 @@ "license": "MIT" }, "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", "dev": true, "license": "MIT" }, @@ -6180,6 +6583,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -6287,13 +6704,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -6430,6 +6840,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -6695,9 +7115,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -6729,9 +7149,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6742,32 +7162,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -6832,9 +7252,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6849,9 +7269,9 @@ "license": "Apache-2.0" }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "dev": true, "license": "MIT" }, @@ -6862,6 +7282,29 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6899,10 +7342,27 @@ "node": ">= 6" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -6916,6 +7376,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7260,6 +7730,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/glob/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -7482,14 +7959,15 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", "dev": true, "license": "MIT", - "bin": { - "he": "bin/he" + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" } }, "node_modules/hookable": { @@ -7892,13 +8370,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.4" }, "engines": { "node": ">= 0.4" @@ -8007,6 +8485,13 @@ "node": ">=8" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -8350,24 +8835,297 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/libnpmpublish": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/libnpmpublish/-/libnpmpublish-11.1.2.tgz", - "integrity": "sha512-tNcU3cLH7toloAzhOOrBDhjzgbxpyuYvkf+BPPnnJCdc5EIcdJ8JcT+SglvCQKKyZ6m9dVXtCVlJcA6csxKdEA==", + "node_modules/libnpmpublish": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/libnpmpublish/-/libnpmpublish-11.1.2.tgz", + "integrity": "sha512-tNcU3cLH7toloAzhOOrBDhjzgbxpyuYvkf+BPPnnJCdc5EIcdJ8JcT+SglvCQKKyZ6m9dVXtCVlJcA6csxKdEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.7", + "sigstore": "^4.0.0", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^7.0.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^13.0.0", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.7", - "sigstore": "^4.0.0", - "ssri": "^12.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -10537,44 +11295,372 @@ "dev": true, "license": "MIT", "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-3.0.2.tgz", + "integrity": "sha512-/74rddlgCmlHvrXUkuYKoUjDJTiP4Nrz2uyIpOTLtxJNCS+2yY+DlLfzzRUc61cvRjCo6Jvi2+vRNZyZQa1j4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@msw/url": "^0.1.2", + "@mswjs/interceptors": "^0.45.6", + "cookie": "^2.0.1", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "rettime": "^0.11.12", + "tough-cookie": "^6.0.2", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^18.2.0" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "graphql": ">=16", + "typescript": ">=5.9.x", + "vite": ">=6" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/@inquirer/ansi": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", + "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/msw/node_modules/@inquirer/confirm": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.3.tgz", + "integrity": "sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/@inquirer/core": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.4.tgz", + "integrity": "sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/msw/node_modules/@inquirer/type": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", + "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/msw/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/msw/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/msw/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/msw/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/msw/node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "node_modules/msw/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "node_modules/msw/node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", "dev": true, "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "node_modules/msw/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } }, "node_modules/mute-stream": { "version": "2.0.0", @@ -10587,9 +11673,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -11189,9 +12275,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -11310,6 +12396,13 @@ "node": ">=8" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/oxc-resolver": { "version": "11.21.3", "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", @@ -11854,14 +12947,14 @@ } }, "node_modules/pkg-types": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", - "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", "dev": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.4", - "exsolve": "^1.0.8", + "confbox": "^0.3.1", + "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, @@ -11923,9 +13016,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -11943,9 +13036,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -12081,6 +13174,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -12562,14 +13672,14 @@ } }, "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.17.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -12682,6 +13792,13 @@ "node": ">= 4" } }, + "node_modules/rettime": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.12.tgz", + "integrity": "sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -12700,6 +13817,40 @@ "dev": true, "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -12900,6 +14051,13 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13147,9 +14305,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -13241,9 +14399,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -13376,6 +14534,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -13418,9 +14589,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -13476,15 +14647,35 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -13518,6 +14709,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/treeverse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", @@ -13884,6 +15088,100 @@ "node": ">= 10.0.0" } }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-dts": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/unplugin-dts/-/unplugin-dts-1.1.2.tgz", + "integrity": "sha512-RDqy90XUmFj6rz4U5YoTMdYdaCJe2ZnQZaWIjkWS4952Gv0zBTQyqWtlGgVMPYHa982dURvyoc+sUOFIcDFv+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "@volar/typescript": "^2.4.26", + "compare-versions": "^6.1.1", + "debug": "^4.4.0", + "glob-to-regexp": "0.4.1", + "kolorist": "^1.8.0", + "local-pkg": "^1.1.1", + "magic-string": "^0.30.17", + "unplugin": "^2.3.2" + }, + "peerDependencies": { + "@microsoft/api-extractor": ">=7", + "@rspack/core": "^1", + "@vue/language-core": "^3.1.5", + "esbuild": "*", + "rolldown": "*", + "rollup": ">=3", + "typescript": ">=4", + "vite": ">=3", + "webpack": "^4 || ^5" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -14002,24 +15300,23 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -14028,14 +15325,15 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -14044,13 +15342,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -14077,27 +15378,26 @@ } }, "node_modules/vite-plugin-dts": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz", - "integrity": "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-5.1.2.tgz", + "integrity": "sha512-1bXUz836sWq6ewEMQre3ZtNFVfZk/92DppgCCKP8tWx/NWwV2wHuXlRHb2TztHjbikPxvyAGLGu7hVh94Plwww==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor": "^7.50.1", - "@rollup/pluginutils": "^5.1.4", - "@volar/typescript": "^2.4.11", - "@vue/language-core": "2.2.0", - "compare-versions": "^6.1.1", - "debug": "^4.4.0", - "kolorist": "^1.8.0", - "local-pkg": "^1.0.0", - "magic-string": "^0.30.17" + "unplugin-dts": "1.1.2" }, "peerDependencies": { - "typescript": "*", - "vite": "*" + "@microsoft/api-extractor": ">=7", + "rollup": ">=3", + "vite": ">=3" }, "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "rollup": { + "optional": true + }, "vite": { "optional": true } @@ -14122,9 +15422,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -14135,9 +15435,9 @@ } }, "node_modules/vite/node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -14595,6 +15895,20 @@ "@types/hast": "^3.0.4" } }, + "node_modules/vitepress/node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, "node_modules/vitepress/node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -14695,19 +16009,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -14735,12 +16049,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -14784,6 +16098,33 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/vitest/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -14803,9 +16144,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -14816,9 +16157,9 @@ } }, "node_modules/vitest/node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -14894,9 +16235,9 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", "dev": true, "license": "MIT" }, @@ -14973,6 +16314,13 @@ "node": ">=14.17" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -15212,8 +16560,10 @@ "devDependencies": { "@custom-elements-manifest/analyzer": "^0.11.0", "@custom-elements-manifest/to-markdown": "^0.1.0", + "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@uploadcare/file-uploader": "1.34.0", - "@vitest/browser-playwright": "^4.1.10", + "@vitest/browser-playwright": "^4.1.11", + "fast-check": "^4.10.2", "lit-analyzer": "^2.0.3", "playwright": "^1.49.0", "typedoc": "^0.28.19", @@ -15241,6 +16591,7 @@ "devDependencies": { "@types/react": "^19", "@types/react-dom": "^19", + "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "react": "^19", "react-dom": "^19" }, diff --git a/package.json b/package.json index cbff116..04f4c2f 100644 --- a/package.json +++ b/package.json @@ -19,18 +19,22 @@ ], "devDependencies": { "@biomejs/biome": "2.2.6", - "@vitest/browser-playwright": "^4.1.10", + "@microsoft/api-extractor": "^7.59.4", + "@mswjs/interceptors": "0.45.7", + "@vitest/browser-playwright": "^4.1.11", + "esbuild": "^0.28.2", "happy-dom": "^20.9.0", "husky": "^9.0.11", "lerna": "^10.0.1", "lint-staged": "^15.2.2", + "msw": "3.0.2", "typescript": "^5.6.0", - "vite": "^6.2.3", - "vite-plugin-dts": "^4.5.0", - "vitest": "^4.1.10" + "vite": "^8.3.3", + "vite-plugin-dts": "^5.1.2", + "vitest": "^4.1.11" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" }, "license": "MIT", "author": "Uploadcare", @@ -41,5 +45,10 @@ "homepage": "https://github.com/uploadcare/ai-image-editor#readme", "bugs": { "url": "https://github.com/uploadcare/ai-image-editor/issues" + }, + "overrides": { + "@vitest/mocker": { + "msw": "$msw" + } } } diff --git a/packages/ai-image-editor/.gitignore b/packages/ai-image-editor/.gitignore index d4ef08f..ceeafeb 100644 --- a/packages/ai-image-editor/.gitignore +++ b/packages/ai-image-editor/.gitignore @@ -1,6 +1,6 @@ # Vitest browser failure screenshots (debug artifacts) -tests/__screenshots__/ +__screenshots__/ # Generated API reference (regenerated by `docs:api`) + VitePress build output docs/api/custom-elements.json diff --git a/packages/ai-image-editor/package.json b/packages/ai-image-editor/package.json index 7a088a2..057ff87 100644 --- a/packages/ai-image-editor/package.json +++ b/packages/ai-image-editor/package.json @@ -32,6 +32,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", + "test:contract:live": "UC_CONTRACT_TARGET=live vitest run --project contract", "docs:api": "cem analyze && node scripts/build-cem-docs.mjs && typedoc && node scripts/clean-typedoc-class.mjs", "docs:dev": "npm run docs:api && vitepress dev docs", "docs:build": "npm run docs:api && vitepress build docs", @@ -60,8 +61,10 @@ "devDependencies": { "@custom-elements-manifest/analyzer": "^0.11.0", "@custom-elements-manifest/to-markdown": "^0.1.0", + "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@uploadcare/file-uploader": "1.34.0", - "@vitest/browser-playwright": "^4.1.10", + "@vitest/browser-playwright": "^4.1.11", + "fast-check": "^4.10.2", "lit-analyzer": "^2.0.3", "playwright": "^1.49.0", "typedoc": "^0.28.19", diff --git a/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts b/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts index 1cb7d7b..1db7639 100644 --- a/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts +++ b/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts @@ -1,3 +1,4 @@ +import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import { aspectRatioEquals, @@ -107,6 +108,31 @@ describe('parseAspectRatioList', () => { expect(parseAspectRatioList('')).toEqual([]); expect(parseAspectRatioList(' ')).toEqual([]); }); + + /** A side length, as a host might write it: whole or fractional, tiny to huge. */ + const side = fc.double({ min: 0.001, max: 10_000, noNaN: true }); + const validRatio = fc.tuple(side, side).filter((ratio) => isValidAspectRatio(ratio)); + const separator = fc.constantFrom(',', ' ', ', ', ' , ', ',,', '\n', '\t'); + + it('reads back any list of valid ratios, in order, whatever separates them', () => { + fc.assert( + fc.property(fc.array(validRatio), separator, (ratios, sep) => { + expect(parseAspectRatioList(ratios.map(aspectRatioKey).join(sep))).toEqual(ratios); + }), + ); + }); + + it('answers only valid ratios, whatever it is given', () => { + // Any string, or "w:h" pairs of any doubles (NaN, ±Infinity, -0, negatives, out of the 10:1 bound). + const pairs = fc + .array(fc.tuple(fc.double(), fc.double())) + .map((list) => list.map(([w, h]) => `${w}:${h}`).join(',')); + fc.assert( + fc.property(fc.oneof(fc.string(), pairs), (input) => { + expect(parseAspectRatioList(input).filter((ratio) => !isValidAspectRatio(ratio))).toEqual([]); + }), + ); + }); }); describe('aspectRatioEquals', () => { @@ -120,10 +146,8 @@ describe('aspectRatioEquals', () => { }); describe('POPULAR_ASPECT_RATIOS', () => { - it('contains only valid ratios', () => { - for (const ratio of POPULAR_ASPECT_RATIOS) { - expect(isValidAspectRatio(ratio)).toBe(true); - } + it.each(POPULAR_ASPECT_RATIOS)('offers %i:%i, a valid ratio', (w, h) => { + expect(isValidAspectRatio([w, h])).toBe(true); }); it('contains 1:1', () => { diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts index cbab2ca..ea24fa9 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts @@ -1,10 +1,7 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { PRODUCTION_STATUS_SUCCESS } from '../../../../tests/fixtures/production-status-success'; import { validate } from './uploadcareApiClient.schemas.dev'; -afterEach(() => { - vi.restoreAllMocks(); -}); - describe('uploadcare derivative API dev schema validation', () => { it('stays silent for a well-formed generate request body', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -113,51 +110,9 @@ describe('uploadcare derivative API dev schema validation', () => { expect(spy).not.toHaveBeenCalled(); }); - // A verbatim `derivative/status/` success frame from production (no `type` on - // success; image file → image_info + content_info.image present, video_info null). it('stays silent for a real derivative status success frame', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - validate('status', { - size: 1620930, - total: 1620930, - done: 1620930, - uuid: '2e0c4294-32e0-4999-aed1-e78221224339', - file_id: '2e0c4294-32e0-4999-aed1-e78221224339', - original_filename: 'generated.png', - is_image: true, - is_stored: false, - image_info: { - dpi: null, - width: 1248, - format: 'PNG', - height: 832, - sequence: false, - color_mode: 'RGB', - orientation: null, - geo_location: null, - datetime_original: null, - }, - video_info: null, - content_info: { - mime: { mime: 'image/png', type: 'image', subtype: 'png' }, - image: { - dpi: null, - width: 1248, - format: 'PNG', - height: 832, - sequence: false, - color_mode: 'RGB', - orientation: null, - geo_location: null, - datetime_original: null, - }, - }, - is_ready: true, - filename: 'generated.png', - mime_type: 'image/png', - metadata: {}, - status: 'success', - }); + validate('status', PRODUCTION_STATUS_SUCCESS); expect(spy).not.toHaveBeenCalled(); }); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index c61603c..6ecd61b 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,14 +1,16 @@ +import { DEMO_IMAGE_UUID, mintAuthToken } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; +import { requestsTo, session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; import { UploadcareApiClient } from './uploadcareApiClient'; -function jsonResponse(body: unknown, init: ResponseInit = {}): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - ...init, - }); -} +const PUBLIC_KEY = 'demopublickey'; +const GENERATE = 'POST /derivative/image/generate/'; +const EDIT = 'POST /derivative/image/edit/'; +const STATUS = 'GET /derivative/status/'; + +/** Answers nothing, ever: a request to `route` stays in flight until its caller aborts it. */ +const hang = (route: string) => session.on(route, () => new Promise(() => {})); describe('UploadcareApiClient', () => { it('throws when publicKey is missing', () => { @@ -16,55 +18,48 @@ describe('UploadcareApiClient', () => { }); describe('authToken', () => { - const headersOf = (call: unknown) => new Headers((call as [string, RequestInit])[1].headers); - it('sends no Authorization header when unset', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(headersOf(fetchImpl.mock.calls[0]!).has('Authorization')).toBe(false); + expect(requestsTo(GENERATE)[0].headers.get('Authorization')).toBeNull(); }); it('sends a plain token as a bearer header', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl, authToken: 'eyJ' }); + const token = await mintAuthToken(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, authToken: token }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(headersOf(fetchImpl.mock.calls[0]!).get('Authorization')).toBe('Bearer eyJ'); + expect(requestsTo(GENERATE)[0].headers.get('Authorization')).toBe(`Bearer ${token}`); }); it('re-resolves a resolver per request, so a job can rotate tokens mid-flight', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(jsonResponse({ type: 'job', job_id: 'j' })) - .mockResolvedValueOnce(jsonResponse({ type: 'status', status: 'pending' })); - const authToken = vi.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second'); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl, authToken }); + const [first, second] = [await mintAuthToken({ tokenId: 'first' }), await mintAuthToken({ tokenId: 'second' })]; + const authToken = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, authToken }); - await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - await client.getJobStatus('j'); + const { job_id } = await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); + await client.getJobStatus(job_id!); - expect(headersOf(fetchImpl.mock.calls[0]!).get('Authorization')).toBe('Bearer first'); - expect(headersOf(fetchImpl.mock.calls[1]!).get('Authorization')).toBe('Bearer second'); + expect(requestsTo(GENERATE)[0].headers.get('Authorization')).toBe(`Bearer ${first}`); + expect(requestsTo(STATUS)[0].headers.get('Authorization')).toBe(`Bearer ${second}`); }); }); describe('generate', () => { it('POSTs pub_key + prompt + aspect_ratio + filename and returns the job', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'job-1' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const job = await client.generate({ prompt: 'a hat', aspectRatio: [16, 9], filename: 'generated.png' }); - expect(job).toEqual({ type: 'job', job_id: 'job-1' }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body as string)).toMatchObject({ - pub_key: 'pk', + expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); + expect(requestsTo(GENERATE)).toHaveLength(1); + const [sent] = requestsTo(GENERATE); + expect(sent.url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); + expect(await sent.json()).toMatchObject({ + pub_key: PUBLIC_KEY, prompt: 'a hat', aspect_ratio: [16, 9], filename: 'generated.png', @@ -72,194 +67,204 @@ describe('UploadcareApiClient', () => { }); it('includes store only when provided', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + + await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', store: true }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(JSON.parse(init.body as string).store).toBe(true); + + expect((await requestsTo(GENERATE)[0].json()).store).toBeUndefined(); + expect((await requestsTo(GENERATE)[1].json()).store).toBe(true); }); it('includes metadata only when provided', async () => { - const fetchImpl = vi - .fn() - .mockImplementation(async () => jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(JSON.parse((fetchImpl.mock.calls[0]![1] as RequestInit).body as string).metadata).toBeUndefined(); - await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', metadata: { source: 'ai-image-editor' }, }); - expect(JSON.parse((fetchImpl.mock.calls[1]![1] as RequestInit).body as string).metadata).toEqual({ - source: 'ai-image-editor', - }); + + expect((await requestsTo(GENERATE)[0].json()).metadata).toBeUndefined(); + expect((await requestsTo(GENERATE)[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('honours baseUrl override', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); const client = new UploadcareApiClient({ - publicKey: 'pk', + publicKey: PUBLIC_KEY, baseUrl: 'https://upload.example.com/', - fetch: fetchImpl, }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - const [url] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.example.com/derivative/image/generate/'); + expect(requestsTo(GENERATE)[0].url).toBe('https://upload.example.com/derivative/image/generate/'); }); - it('throws with status text on non-2xx', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response('bad ratio', { status: 400, statusText: 'Bad Request' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('throws with status text on a non-2xx response that is not the error envelope', async () => { + session.on( + 'POST /derivative/image/generate/', + () => new Response('upstream failure', { status: 400, statusText: 'Bad Request' }), + ); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toThrow(/400/); }); - it('forwards the abort signal', async () => { - const fetchImpl = vi.fn().mockImplementation(async (_url, init) => { - expect(init?.signal).toBeInstanceOf(AbortSignal); - return jsonResponse({ type: 'job', job_id: 'j' }); - }); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('gives up on a hanging request when the caller aborts', async () => { + hang(GENERATE); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); - await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', signal: controller.signal }); + + const pending = client.generate({ + prompt: 'x', + aspectRatio: [1, 1], + filename: 'f.png', + signal: controller.signal, + }); + await vi.waitFor(() => expect(requestsTo(GENERATE)).toHaveLength(1)); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }); it('sends Accept: application/json', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect((init.headers as Record).Accept).toBe('application/json'); + expect(requestsTo(GENERATE)[0].headers.get('Accept')).toBe('application/json'); }); it('surfaces a platform error envelope as an AiProviderError with its code', async () => { - const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse({ - error: { status_code: 400, error_code: 'canvas_too_large', content: 'Canvas size exceeds the 4MP limit.' }, - }), - ); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + session.use('derivativesDisabled'); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toMatchObject({ name: 'AiProviderError', - errorCode: 'canvas_too_large', - message: 'Canvas size exceeds the 4MP limit.', + errorCode: 'derivative_disabled', + message: 'Derivatives are not enabled for this project.', }); }); }); describe('edit', () => { it('POSTs pub_key + prompt + source uuid + filename to the edit endpoint', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'job-e' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); - const job = await client.edit({ prompt: 'remove the cat', source: 'src-uuid', filename: 'edited.png' }); + const job = await client.edit({ prompt: 'remove the cat', source: DEMO_IMAGE_UUID, filename: 'edited.png' }); - expect(job).toEqual({ type: 'job', job_id: 'job-e' }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body as string)).toMatchObject({ - pub_key: 'pk', + expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); + expect(requestsTo(EDIT)).toHaveLength(1); + const [sent] = requestsTo(EDIT); + expect(sent.url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(await sent.json()).toMatchObject({ + pub_key: PUBLIC_KEY, prompt: 'remove the cat', - source: 'src-uuid', + source: DEMO_IMAGE_UUID, filename: 'edited.png', }); }); it('includes aspect_ratio only when provided', async () => { - // Fresh Response per call — a single shared Response body can be read once. - const fetchImpl = vi - .fn() - .mockImplementation(async () => jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png' }); - expect(JSON.parse((fetchImpl.mock.calls[0]![1] as RequestInit).body as string).aspect_ratio).toBeUndefined(); + await client.edit({ prompt: 'x', source: DEMO_IMAGE_UUID, filename: 'f.png' }); + await client.edit({ prompt: 'x', source: DEMO_IMAGE_UUID, filename: 'f.png', aspectRatio: [16, 9] }); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png', aspectRatio: [16, 9] }); - expect(JSON.parse((fetchImpl.mock.calls[1]![1] as RequestInit).body as string).aspect_ratio).toEqual([16, 9]); + expect((await requestsTo(EDIT)[0].json()).aspect_ratio).toBeUndefined(); + expect((await requestsTo(EDIT)[1].json()).aspect_ratio).toEqual([16, 9]); }); it('includes metadata only when provided', async () => { - const fetchImpl = vi - .fn() - .mockImplementation(async () => jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + + await client.edit({ prompt: 'x', source: DEMO_IMAGE_UUID, filename: 'f.png' }); + await client.edit({ + prompt: 'x', + source: DEMO_IMAGE_UUID, + filename: 'f.png', + metadata: { source: 'ai-image-editor' }, + }); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png' }); - expect(JSON.parse((fetchImpl.mock.calls[0]![1] as RequestInit).body as string).metadata).toBeUndefined(); + expect((await requestsTo(EDIT)[0].json()).metadata).toBeUndefined(); + expect((await requestsTo(EDIT)[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); + }); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png', metadata: { source: 'ai-image-editor' } }); - expect(JSON.parse((fetchImpl.mock.calls[1]![1] as RequestInit).body as string).metadata).toEqual({ - source: 'ai-image-editor', + it('surfaces a source the project does not have as an AiProviderError', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + await expect(client.edit({ prompt: 'x', source: 'missing', filename: 'f.png' })).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'source_not_found', }); }); - it('throws with status text on non-2xx', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response('bad source', { status: 400, statusText: 'Bad Request' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('throws with status text on a non-2xx response that is not the error envelope', async () => { + session.on( + 'POST /derivative/image/edit/', + () => new Response('upstream failure', { status: 400, statusText: 'Bad Request' }), + ); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.edit({ prompt: 'x', source: 'u', filename: 'f.png' })).rejects.toThrow(/400/); }); - it('forwards the abort signal', async () => { - const fetchImpl = vi.fn().mockImplementation(async (_url, init) => { - expect(init?.signal).toBeInstanceOf(AbortSignal); - return jsonResponse({ type: 'job', job_id: 'j' }); - }); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('gives up on a hanging request when the caller aborts', async () => { + hang(EDIT); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png', signal: controller.signal }); + + const pending = client.edit({ + prompt: 'x', + source: DEMO_IMAGE_UUID, + filename: 'f.png', + signal: controller.signal, + }); + await vi.waitFor(() => expect(requestsTo(EDIT)).toHaveLength(1)); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }); }); describe('getJobStatus', () => { + const startJob = async (client: UploadcareApiClient) => + (await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).job_id!; + it('GETs the status endpoint with pub_key + job_id and returns the parsed status', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', status: 'processing' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + const jobId = await startJob(client); - const status = await client.getJobStatus('job-42'); + const status = await client.getJobStatus(jobId); expect(status).toEqual({ type: 'job', status: 'processing' }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/status/?pub_key=pk&job_id=job-42'); - expect((init?.method ?? 'GET').toUpperCase()).toBe('GET'); + expect(requestsTo(STATUS)).toHaveLength(1); + expect(requestsTo(STATUS)[0].url).toBe( + `https://upload.uploadcare.com/derivative/status/?pub_key=${PUBLIC_KEY}&job_id=${jobId}`, + ); }); - it('throws with status text on non-2xx', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response('nope', { status: 404, statusText: 'Not Found' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('throws with status text on a non-2xx response that is not the error envelope', async () => { + session.on( + 'GET /derivative/status/', + () => new Response('upstream failure', { status: 404, statusText: 'Not Found' }), + ); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.getJobStatus('job-1')).rejects.toThrow(/404/); }); it('surfaces a platform error envelope (e.g. job_not_found) as an AiProviderError', async () => { - const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse({ - error: { status_code: 404, error_code: 'job_not_found', content: 'Derivative job is not found.' }, - }), - ); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); - const err = await client.getJobStatus('job-1').catch((e) => e); - expect(err).toBeInstanceOf(AiProviderError); - expect(err.errorCode).toBe('job_not_found'); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + const pending = client.getJobStatus('job-1'); + await expect(pending).rejects.toBeInstanceOf(AiProviderError); + await expect(pending).rejects.toMatchObject({ errorCode: 'job_not_found' }); }); - it('forwards the abort signal', async () => { - const fetchImpl = vi.fn().mockImplementation(async (_url, init) => { - expect(init?.signal).toBeInstanceOf(AbortSignal); - return jsonResponse({ type: 'job', status: 'processing' }); - }); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('gives up on a hanging request when the caller aborts', async () => { + hang(STATUS); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + const jobId = await startJob(client); const controller = new AbortController(); - await client.getJobStatus('job-1', controller.signal); + + const pending = client.getJobStatus(jobId, controller.signal); + await vi.waitFor(() => expect(requestsTo(STATUS)).toHaveLength(1)); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }); }); }); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts index ef0bdbf..9a716b1 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts @@ -21,8 +21,6 @@ export type UploadcareApiClientOptions = { publicKey: string; /** Base URL for the upload API. Defaults to https://upload.uploadcare.com. */ baseUrl?: string; - /** Override the global fetch — useful for tests. */ - fetch?: typeof fetch; /** * JWT for the `Authorization: Bearer` scheme. A plain token, or a resolver * called before every request so a long-running job can rotate tokens @@ -154,6 +152,12 @@ async function readJson(response: Response, action: string): Promise { `Uploadcare ${action} failed (${response.status} ${response.statusText})${text ? `: ${text}` : ''}`, ); } + // Every derivative endpoint answers a JSON object; anything else (a proxy's HTML page, an empty body) is a failure. + if (data === null || typeof data !== 'object') { + throw new Error( + `Uploadcare ${action} failed (${response.status}): the response is not a JSON object${text ? `: ${text}` : ''}`, + ); + } return data; } @@ -161,7 +165,6 @@ async function readJson(response: Response, action: string): Promise { export class UploadcareApiClient { private readonly publicKey: string; private readonly baseUrl: string; - private readonly doFetch: typeof fetch; private readonly authToken: AuthToken | undefined; constructor(options: UploadcareApiClientOptions) { @@ -170,7 +173,6 @@ export class UploadcareApiClient { } this.publicKey = options.publicKey; this.baseUrl = options.baseUrl ?? 'https://upload.uploadcare.com'; - this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis); this.authToken = options.authToken; } @@ -211,7 +213,7 @@ export class UploadcareApiClient { url.searchParams.set('pub_key', this.publicKey); url.searchParams.set('job_id', jobId); - const response = await this.doFetch(url.href, { + const response = await fetch(url.href, { method: 'GET', headers: { Accept: 'application/json', ...(await this.authHeaders()) }, signal, @@ -233,7 +235,7 @@ export class UploadcareApiClient { action: string, signal?: AbortSignal, ): Promise { - const response = await this.doFetch(endpoint.href, { + const response = await fetch(endpoint.href, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index b95932c..4ba402f 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,452 +1,470 @@ +import { DEMO_IMAGE_UUID, mintAuthToken } from '@uploadcare/api-emulator'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; -import { isReadyPoll } from '@uploadcare/upload-client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { UploadcareFile } from '@uploadcare/upload-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { requestsTo, session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; -// Keep the real UploadcareFile (used to wrap results) but stub the `isReadyPoll` -// network call (used by getFileInfo) so it's exercised without the Upload API. -vi.mock('@uploadcare/upload-client', async (importActual) => { - const actual = await importActual(); - return { ...actual, isReadyPoll: vi.fn() }; -}); -const mockIsReadyPoll = vi.mocked(isReadyPoll); +const PUBLIC_KEY = 'demopublickey'; +const CDN = 'https://cdn.example.com'; +const NO_DELAY = { pollIntervalMs: 0 } as const; +const GENERATE = 'POST /derivative/image/generate/'; +const EDIT = 'POST /derivative/image/edit/'; +const STATUS = 'GET /derivative/status/'; -function jsonResponse(body: unknown, init: ResponseInit = {}): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - ...init, - }); -} - -function readSentBody(init: RequestInit | undefined): Record { - return JSON.parse(init?.body as string) as Record; -} - -/** - * A complete `derivative/status/` success frame minus `uuid` and `status` (which - * every test sets). Merged under each success fixture so the strict dev-schema - * validates them and the readiness poll ends — tests set only what they assert. - */ -const SUCCESS_FRAME_DEFAULTS = { - file_id: '', - size: 0, - done: 0, - total: 0, - original_filename: '', - filename: '', - mime_type: 'image/png', - is_image: true, - is_stored: false, - is_ready: true, - image_info: null, - video_info: null, - content_info: null, - metadata: {}, -} as const; - -/** - * Builds a fetch mock that answers the generate POST with `jobResponse` and - * then walks through `statuses` on each successive status GET. - */ -function routedFetch(jobResponse: unknown, statuses: unknown[]): ReturnType> { - let statusIndex = 0; - return vi.fn().mockImplementation(async (url, init) => { - const method = (init?.method ?? 'GET').toUpperCase(); - if (method === 'POST') return jsonResponse(jobResponse); - const body = statuses[Math.min(statusIndex, statuses.length - 1)]; - statusIndex += 1; - void url; - const frame = - body && typeof body === 'object' && 'status' in body && body.status === 'success' - ? { ...SUCCESS_FRAME_DEFAULTS, ...body } - : body; - return jsonResponse(frame); - }); -} +/** A result's width over its height. */ +const ratioOf = (file: UploadcareFile) => + (file.imageInfo?.width ?? Number.NaN) / (file.imageInfo?.height ?? Number.NaN); -const NO_DELAY = { pollIntervalMs: 0 } as const; +/** Answers nothing, ever: a request to `route` stays in flight until its caller aborts it. */ +const hang = (route: string) => session.on(route, () => new Promise(() => {})); + +/** A job that never finishes: every status poll answers `processing`. */ +const neverFinish = () => session.on(STATUS, () => Response.json({ type: 'job', status: 'processing' })); describe('UploadcareDerivativeApi', () => { it('throws when publicKey is missing', () => { expect(() => new UploadcareDerivativeApi({ publicKey: '' })).toThrow(/publicKey/); }); - it('POSTs the prompt + aspect ratio + pub_key to the derivative endpoint', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk_test', fetch: fetchImpl, ...NO_DELAY }); - await provider.generate({ prompt: 'a hat', mode: 'generate', aspectRatio: [16, 9] }); - - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(init.method).toBe('POST'); - expect((init.headers as Record)['Content-Type']).toBe('application/json'); - expect(readSentBody(init)).toMatchObject({ - pub_key: 'pk_test', - prompt: 'a hat', - aspect_ratio: [16, 9], - filename: 'generated.png', - }); + it('generates an image at the requested aspect ratio', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + const { file } = await provider.generate({ prompt: 'a hat', mode: 'generate', aspectRatio: [16, 9] }); + expect(ratioOf(file)).toBe(16 / 9); + }); + + it('names the result generated.png when no filename is given', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + const { file } = await provider.generate({ prompt: 'x', mode: 'generate' }); + expect(file.originalFilename).toBe('generated.png'); }); it('forwards request metadata to the generate endpoint', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate', metadata: { source: 'ai-image-editor' } }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(readSentBody(init).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await requestsTo(GENERATE)[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('forwards request metadata to the edit endpoint', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-e' }, [{ status: 'success', uuid: 'edited' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await provider.generate({ prompt: 'x', mode: 'edit', source: 'src', metadata: { source: 'ai-image-editor' } }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(readSentBody(init).metadata).toEqual({ source: 'ai-image-editor' }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + await provider.generate({ + prompt: 'x', + mode: 'edit', + source: DEMO_IMAGE_UUID, + metadata: { source: 'ai-image-editor' }, + }); + expect((await requestsTo(EDIT)[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); - it('uses 1:1 when aspectRatio is missing or invalid', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await provider.generate({ prompt: 'x', mode: 'generate' }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(readSentBody(init).aspect_ratio).toEqual([1, 1]); + it.each([ + ['missing', undefined], + ['not positive', [0, 1]], + ['wider than 10:1', [20, 1]], + ] as const)('generates a square image when aspectRatio is %s', async (_, aspectRatio) => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + const { file } = await provider.generate({ + prompt: 'x', + mode: 'generate', + aspectRatio: aspectRatio && [...aspectRatio], + }); + expect(ratioOf(file)).toBe(1); }); - it('polls the status endpoint with pub_key + job_id until the job succeeds', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-42' }, [ - { type: 'job', status: 'processing' }, - { type: 'job', status: 'uploading' }, - { status: 'success', uuid: 'final-uuid' }, - ]); - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: fetchImpl, - ...NO_DELAY, + it('keeps polling through processing, uploading and an unready success until is_ready', async () => { + // Oldest first: the emulator's own frames, walked straight to ready. + session.use('derivativesInstant'); + session.on(STATUS, async ({ next }) => Response.json({ ...(await (await next())?.json()), is_ready: false }), { + times: 1, }); + session.on(STATUS, () => Response.json({ type: 'job', status: 'uploading' }), { times: 1 }); + session.on(STATUS, () => Response.json({ type: 'job', status: 'processing' }), { times: 1 }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, ...NO_DELAY }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(result.url).toBe('https://cdn.example.com/final-uuid/'); - // 1 POST + 3 status polls - expect(fetchImpl).toHaveBeenCalledTimes(4); - const [statusUrl, statusInit] = fetchImpl.mock.calls[1]! as [string, RequestInit]; - expect(statusUrl).toBe('https://upload.uploadcare.com/derivative/status/?pub_key=pk&job_id=job-42'); - expect((statusInit?.method ?? 'GET').toUpperCase()).toBe('GET'); + expect(result.url).toBe(`${CDN}/${result.uuid}/`); + // processing, uploading, unready success, ready success: one poll each, none after. + expect(requestsTo(STATUS)).toHaveLength(4); }); it('throws when the job ends in an error status', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-err' }, [ - { type: 'job', status: 'processing' }, - { type: 'job', status: 'error', error_source: 'ai_gateway', error_code: 'content_policy', error: 'blocked' }, - ]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/content_policy|blocked/); + session.use('derivativeFailure', { code: 'content_moderated' }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'content_moderated', + }); }); it('wraps an internal poll timeout as a generation_timeout provider error', async () => { - // Never-successful status + a zero timeout: `poll` gives up on its own, - // without the caller's signal ever aborting. - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-slow' }, [{ type: 'job', status: 'processing' }]); + // A zero timeout: `poll` gives up on its own, without the caller's signal ever aborting. + neverFinish(); + let jobId: string | undefined; + session.on('POST /derivative/image/generate/', async ({ next }) => { + const answer = await next(); + jobId = (await answer?.clone().json())?.job_id; + return answer; + }); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - fetch: fetchImpl, + publicKey: PUBLIC_KEY, pollIntervalMs: 0, pollTimeoutMs: 0, }); - const err = await provider.generate({ prompt: 'x', mode: 'generate' }).catch((e) => e); - expect(err).toBeInstanceOf(AiProviderError); - expect(err.errorCode).toBe('generation_timeout'); - expect(err.message).toContain('job-slow'); + const pending = provider.generate({ prompt: 'x', mode: 'generate' }); + await expect(pending).rejects.toBeInstanceOf(AiProviderError); + await expect(pending).rejects.toMatchObject({ errorCode: 'generation_timeout' }); + expect(jobId).toEqual(expect.any(String)); + await expect(pending).rejects.toThrow(jobId); + }); + + describe('when a status poll fails mid-job', () => { + /** The job's status polls, in order. */ + const statusPolls = () => requestsTo(STATUS); + + /** The job's first poll answers `processing`; the next one answers `failure`. */ + const failSecondPoll = (failure: () => Response) => { + session.on(STATUS, failure, { times: 1 }); + session.on(STATUS, () => Response.json({ type: 'job', status: 'processing' }), { times: 1 }); + }; + + it('rejects with the status of a 5xx, and polls no more', async () => { + failSecondPoll(() => new Response('upstream down', { status: 503, statusText: 'Service Unavailable' })); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/503 Service Unavailable/); + expect(statusPolls()).toHaveLength(2); + }); + + it('rejects with the network error of a dropped connection, and polls no more', async () => { + failSecondPoll(() => Response.error()); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + + // fetch's own error, untouched: not a provider error and not a timeout. + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/fetch/i); + expect(statusPolls()).toHaveLength(2); + }); + + it('rejects a status frame that is not a JSON object, and polls no more', async () => { + failSecondPoll(() => new Response('Bad gateway', { headers: { 'Content-Type': 'text/html' } })); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow( + 'Uploadcare generate status failed (200): the response is not a JSON object: Bad gateway', + ); + expect(statusPolls()).toHaveLength(2); + }); + }); + + describe('with the default poll options', () => { + // The documented defaults: a poll every 1.5s, giving up after 1,000,000ms. + const INTERVAL = 1500; + const TIMEOUT = 1_000_000; + + // Only the clock is fake: the emulator's requests still need real I/O turns to complete. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + return () => vi.useRealTimers(); + }); + + /** Starts a generation and tracks whether it has settled. */ + const start = () => { + const run = { settled: false, pending: Promise.resolve() as Promise }; + run.pending = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }) + .generate({ prompt: 'x', mode: 'generate' }) + .finally(() => { + run.settled = true; + }); + run.pending.catch(() => {}); + return run; + }; + + /** Lets the in-flight request finish, without moving the clock, until the poll sleeps or `run` settles. */ + const untilIdle = async (run: { settled: boolean }) => { + while (vi.getTimerCount() === 0 && !run.settled) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + + it('waits the default interval between status polls', async () => { + const polledAt: number[] = []; + session.on(STATUS, () => { + polledAt.push(Date.now()); + return Response.json({ type: 'job', status: 'processing' }); + }); + const run = start(); + + for (let sleeps = 0; sleeps < 3; sleeps++) { + await untilIdle(run); + await vi.advanceTimersToNextTimerAsync(); + } + await untilIdle(run); + + expect(polledAt.slice(1).map((at, i) => at - polledAt[i])).toEqual([INTERVAL, INTERVAL, INTERVAL]); + }); + + it('gives up at the default timeout', async () => { + neverFinish(); + const startedAt = Date.now(); + const run = start(); + + for (;;) { + await untilIdle(run); + if (run.settled) break; + await vi.advanceTimersToNextTimerAsync(); + } + + expect(Date.now() - startedAt).toBe(TIMEOUT); + await expect(run.pending).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'generation_timeout' }); + }); }); it('honours baseUrl + cdnBaseUrl overrides for both generate and status', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'xyz' }]); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', + publicKey: PUBLIC_KEY, baseUrl: 'https://upload.example.com', - cdnBaseUrl: 'https://cdn.example.com', - fetch: fetchImpl, + cdnBaseUrl: CDN, ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - const [genUrl] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - const [statusUrl] = fetchImpl.mock.calls[1]! as [string, RequestInit]; - expect(genUrl).toBe('https://upload.example.com/derivative/image/generate/'); - expect(statusUrl).toBe('https://upload.example.com/derivative/status/?pub_key=pk&job_id=job-1'); - expect(result.url).toBe('https://cdn.example.com/xyz/'); + expect(requestsTo(GENERATE)[0].url).toBe('https://upload.example.com/derivative/image/generate/'); + expect(requestsTo(STATUS)[0].url).toMatch( + new RegExp(`^https://upload\\.example\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=`), + ); + expect(result.url).toBe(`${CDN}/${result.uuid}/`); }); it('returns an UploadcareFile on the result (with camelized fields)', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [ - { status: 'success', uuid: 'final-uuid', original_filename: 'f.png', is_image: true }, - ]); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: fetchImpl, + publicKey: PUBLIC_KEY, + cdnBaseUrl: CDN, ...NO_DELAY, }); - const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(result.file.uuid).toBe('final-uuid'); - expect(result.file.cdnUrl).toBe('https://cdn.example.com/final-uuid/'); + const result = await provider.generate({ prompt: 'x', mode: 'generate', filename: 'f.png' }); + expect(result.file.uuid).toBe(result.uuid); + expect(result.file.cdnUrl).toBe(`${CDN}/${result.uuid}/`); expect(result.file.originalFilename).toBe('f.png'); expect(result.file.isImage).toBe(true); - expect(result.url).toBe('https://cdn.example.com/final-uuid/'); - }); - - it('keeps polling the status until the success frame reports is_ready: true', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [ - { status: 'success', uuid: 'final-uuid', is_ready: false }, - { status: 'success', uuid: 'final-uuid', is_ready: true, original_filename: 'ready.png' }, - ]); - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: fetchImpl, - ...NO_DELAY, - }); - - const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - - // 1 POST + 2 status polls: the first `success` frame isn't CDN-ready yet. - expect(fetchImpl).toHaveBeenCalledTimes(3); - expect(result.file.originalFilename).toBe('ready.png'); - expect(result.url).toBe('https://cdn.example.com/final-uuid/'); + expect(result.file.imageInfo).toMatchObject({ width: expect.any(Number), height: expect.any(Number) }); + expect(result.url).toBe(`${CDN}/${result.uuid}/`); }); it('derives the CDN base from the public key when cdnBaseUrl is left at the default', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'final-uuid' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - const base = await getPrefixedCdnBaseAsync('pk', 'https://ucarecd.net'); - expect(result.url).toBe(`${base}/final-uuid/`); + const base = await getPrefixedCdnBaseAsync(PUBLIC_KEY, 'https://ucarecd.net'); + expect(result.url).toBe(`${base}/${result.uuid}/`); }); it('surfaces non-2xx generate responses with status text', async () => { - const fetchImpl = vi.fn().mockResolvedValue( - new Response('bad ratio', { - status: 400, - statusText: 'Bad Request', - headers: { 'Content-Type': 'text/plain' }, - }), + session.on( + 'POST /derivative/image/generate/', + () => new Response('upstream failure', { status: 400, statusText: 'Bad Request' }), ); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/400/); - }); - - it('times out when the job never reaches a terminal state', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ type: 'job', status: 'processing' }]); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - fetch: fetchImpl, - pollIntervalMs: 0, - pollTimeoutMs: 5, + publicKey: PUBLIC_KEY, + ...NO_DELAY, }); - await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/time/i); + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/400/); }); - it('passes the abort signal to generate and status fetches', async () => { - const seen: (AbortSignal | undefined)[] = []; - const base = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'a' }]); - const fetchImpl = vi.fn().mockImplementation((url, init) => { - seen.push(init?.signal ?? undefined); - return base(url, init); - }); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + it('cancels a job that is still starting when the caller aborts', async () => { + hang('POST /derivative/image/generate/'); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); const controller = new AbortController(); - await provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); - expect(seen.length).toBeGreaterThanOrEqual(2); - expect(seen.every((s) => s === controller.signal)).toBe(true); + + const pending = provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); + await vi.waitFor(() => expect(requestsTo(GENERATE)).toHaveLength(1)); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }); - it('stops polling when the signal is aborted mid-flight', async () => { + it('cancels a status poll in flight when the caller aborts', async () => { + hang(STATUS); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); const controller = new AbortController(); - const fetchImpl = vi.fn().mockImplementation(async (_url, init) => { - const method = (init?.method ?? 'GET').toUpperCase(); - if (method === 'POST') return jsonResponse({ type: 'job', job_id: 'job-1' }); - // Abort while "processing": the next poll must never happen. - controller.abort(); - return jsonResponse({ type: 'job', status: 'processing' }); - }); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow( - /cancel/i, - ); - // 1 POST + exactly 1 status poll, then it bails — no further polling. - expect(fetchImpl).toHaveBeenCalledTimes(2); + const pending = provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); + await vi.waitFor(() => expect(requestsTo(STATUS)).toHaveLength(1)); + controller.abort(); + + await expect(pending).rejects.toThrow(/cancel/i); }); - it('does not start polling when the signal is already aborted', async () => { + it('sends nothing when the signal is already aborted', async () => { const controller = new AbortController(); controller.abort(); - const fetchImpl = vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toMatchObject( + { name: 'AbortError' }, + ); + expect(session.requests).toHaveLength(0); }); describe('edit mode', () => { - it('POSTs prompt + source uuid to the edit endpoint and resolves the result', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-e' }, [{ status: 'success', uuid: 'edited-uuid' }]); + it('edits the source into a new file at the requested aspect ratio', async () => { const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: fetchImpl, + publicKey: PUBLIC_KEY, + cdnBaseUrl: CDN, ...NO_DELAY, }); const result = await provider.generate({ prompt: 'remove the cat', mode: 'edit', - source: 'src-uuid', + source: DEMO_IMAGE_UUID, aspectRatio: [16, 9], }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(readSentBody(init)).toMatchObject({ - pub_key: 'pk', - prompt: 'remove the cat', - source: 'src-uuid', - aspect_ratio: [16, 9], - }); - expect(result.url).toBe('https://cdn.example.com/edited-uuid/'); + expect(requestsTo(EDIT)).toHaveLength(1); + expect(result.uuid).not.toBe(DEMO_IMAGE_UUID); + expect(ratioOf(result.file)).toBe(16 / 9); + expect(result.url).toBe(`${CDN}/${result.uuid}/`); expect(result.mode).toBe('edit'); }); - it('omits aspect_ratio when none is provided', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-e' }, [{ status: 'success', uuid: 'u' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await provider.generate({ prompt: 'x', mode: 'edit', source: 'src-uuid' }); - expect(readSentBody(fetchImpl.mock.calls[0]![1] as RequestInit).aspect_ratio).toBeUndefined(); + it("keeps the source's dimensions when no aspectRatio is given", async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + const source = await provider.getFileInfo(DEMO_IMAGE_UUID); + const { file } = await provider.generate({ prompt: 'x', mode: 'edit', source: DEMO_IMAGE_UUID }); + expect(file.imageInfo).toMatchObject({ width: source.imageInfo?.width, height: source.imageInfo?.height }); + // The seeded demo image is not square, so a 1:1 default would show here. + expect(ratioOf(file)).not.toBe(1); }); it('throws (without any request) when an edit has no source uuid', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'j' }, [{ status: 'success', uuid: 'x' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'edit' })).rejects.toThrow(/source/i); - expect(fetchImpl).not.toHaveBeenCalled(); + expect(session.requests).toHaveLength(0); }); it('resolveCdnUrl maps a uuid to its CDN URL', async () => { - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: vi.fn(), - ...NO_DELAY, - }); - expect(await provider.resolveCdnUrl('some-uuid')).toBe('https://cdn.example.com/some-uuid/'); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN }); + expect(await provider.resolveCdnUrl('some-uuid')).toBe(`${CDN}/some-uuid/`); }); }); describe('getFileInfo', () => { - afterEach(() => mockIsReadyPoll.mockReset()); - - const fileInfoFixture = { - uuid: 'file-uuid', - originalFilename: 'portrait.png', - isImage: true, - mimeType: 'image/png', - imageInfo: { width: 800, height: 1200, format: 'PNG' }, - } as unknown as Awaited>; - - it('polls isReadyPoll() and wraps the ready file as an UploadcareFile on the CDN base', async () => { - mockIsReadyPoll.mockResolvedValue(fileInfoFixture); - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: vi.fn(), - ...NO_DELAY, - }); + it('waits for the file to be ready and wraps it as an UploadcareFile on the CDN base', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN }); - const file = await provider.getFileInfo('file-uuid'); + const file = await provider.getFileInfo(DEMO_IMAGE_UUID); - expect(mockIsReadyPoll).toHaveBeenCalledWith('file-uuid', expect.objectContaining({ publicKey: 'pk' })); - expect(file.uuid).toBe('file-uuid'); - expect(file.originalFilename).toBe('portrait.png'); - expect(file.cdnUrl).toBe('https://cdn.example.com/file-uuid/'); - expect(file.imageInfo).toMatchObject({ width: 800, height: 1200 }); + expect(file.uuid).toBe(DEMO_IMAGE_UUID); + expect(file.cdnUrl).toBe(`${CDN}/${DEMO_IMAGE_UUID}/`); + expect(file.imageInfo).toMatchObject({ width: expect.any(Number), height: expect.any(Number) }); }); - it('forwards baseUrl + abort signal to isReadyPoll()', async () => { - mockIsReadyPoll.mockResolvedValue(fileInfoFixture); - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - baseUrl: 'https://upload.example.com', - fetch: vi.fn(), - ...NO_DELAY, - }); + it('honours the abort signal', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); - await provider.getFileInfo('file-uuid', controller.signal); - expect(mockIsReadyPoll).toHaveBeenCalledWith( - 'file-uuid', - expect.objectContaining({ baseURL: 'https://upload.example.com', signal: controller.signal }), - ); + controller.abort(); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID, controller.signal)).rejects.toThrow(/cancel/i); }); - it('propagates isReadyPoll() failures', async () => { - mockIsReadyPoll.mockRejectedValue(new Error('FileNotFound')); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: vi.fn(), ...NO_DELAY }); - await expect(provider.getFileInfo('missing')).rejects.toThrow(/FileNotFound/); + it('propagates a lookup failure', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }); + // A well-formed uuid the session does not hold: a malformed id would be + // refused earlier, as invalid, and never reach the not-found lookup. + await expect(provider.getFileInfo('00000000-0000-4000-8000-000000000000')).rejects.toThrow(/not found/i); }); }); describe('authToken', () => { - afterEach(() => mockIsReadyPoll.mockReset()); + // A signed-uploads project refuses every request without a valid Bearer + // token, so each call below succeeding is the proof the token got there. + beforeEach(() => { + session.use('signedUploads'); + }); it('carries the token on both network paths', async () => { // The two paths are independent: `generate`/`edit`/`status` go through // the client's own fetch, while `getFileInfo` hands options to // upload-client's isReadyPoll. Dropping either assignment would leave // half the provider unauthenticated. - mockIsReadyPoll.mockResolvedValue({ uuid: 'file-uuid' } as unknown as Awaited>); - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); + const token = await mintAuthToken(); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - authToken: 'eyJ.first.sig', - fetch: fetchImpl, + publicKey: PUBLIC_KEY, + authToken: token, ...NO_DELAY, }); await provider.generate({ prompt: 'x', mode: 'generate' }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(new Headers(init.headers).get('Authorization')).toBe('Bearer eyJ.first.sig'); + expect(requestsTo(GENERATE)[0].headers.get('Authorization')).toBe(`Bearer ${token}`); - await provider.getFileInfo('file-uuid'); - expect(mockIsReadyPoll).toHaveBeenCalledWith( - 'file-uuid', - expect.objectContaining({ authToken: 'eyJ.first.sig' }), - ); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).resolves.toMatchObject({ uuid: DEMO_IMAGE_UUID }); + }); + + it('is refused on both network paths without one', async () => { + const provider = new UploadcareDerivativeApi({ + publicKey: PUBLIC_KEY, + ...NO_DELAY, + }); + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/signature/i); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).rejects.toThrow(/signature/i); + }); + + it('surfaces a token the API cannot read as AccessTokenInvalidError, without polling', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, authToken: 'not-a-jwt', ...NO_DELAY }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'AccessTokenInvalidError', + }); + expect(requestsTo(STATUS)).toHaveLength(0); + }); + + it('surfaces a token that expires mid-job as AccessTokenExpiredError on the next poll', async () => { + // Two minutes past `exp`, beyond the API's 30s clock leeway. + const [fresh, expired] = [await mintAuthToken(), await mintAuthToken({ lifetime: -120_000 })]; + const authToken = vi.fn().mockResolvedValueOnce(fresh).mockResolvedValue(expired); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, authToken, ...NO_DELAY }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'AccessTokenExpiredError', + }); + // The start went through on the fresh token; the first poll, on the expired one, ended the job. + expect(requestsTo(STATUS)).toHaveLength(1); + }); + + it("rejects with the token function's own error and sends nothing when it throws", async () => { + const provider = new UploadcareDerivativeApi({ + publicKey: PUBLIC_KEY, + authToken: async () => { + throw new Error('token service down'); + }, + ...NO_DELAY, + }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow('token service down'); + expect(session.requests).toHaveLength(0); }); it('follows a token that changes, on both network paths', async () => { // The provider holds one resolver for its lifetime; whoever owns the // token changes what that resolver returns, and nothing is pushed in. - mockIsReadyPoll.mockResolvedValue({ uuid: 'file-uuid' } as unknown as Awaited>); - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); - let token = 'eyJ.first.sig'; + const [first, second] = [await mintAuthToken({ tokenId: 'first' }), await mintAuthToken({ tokenId: 'second' })]; + let token = first; const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', + publicKey: PUBLIC_KEY, authToken: () => token, - fetch: fetchImpl, ...NO_DELAY, }); + const tokensSent = (requests: Request[]) => + new Set(requests.map((request) => request.headers.get('Authorization'))); - token = 'eyJ.second.sig'; + await provider.generate({ prompt: 'x', mode: 'generate' }); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).resolves.toMatchObject({ uuid: DEMO_IMAGE_UUID }); + const beforeRotation = session.requests.length; + token = second; await provider.generate({ prompt: 'x', mode: 'generate' }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(new Headers(init.headers).get('Authorization')).toBe('Bearer eyJ.second.sig'); - - // upload-client is handed the resolver itself and calls it per request, - // so what matters is what it resolves to now. - await provider.getFileInfo('file-uuid'); - const [, pollOptions] = mockIsReadyPoll.mock.calls[0]! as [string, { authToken: () => string }]; - expect(pollOptions.authToken()).toBe('eyJ.second.sig'); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).resolves.toMatchObject({ uuid: DEMO_IMAGE_UUID }); + + expect(tokensSent(session.requests.slice(0, beforeRotation))).toEqual(new Set([`Bearer ${first}`])); + expect(tokensSent(session.requests.slice(beforeRotation))).toEqual(new Set([`Bearer ${second}`])); }); }); }); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.ts index 16f6e57..0823792 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.ts @@ -33,8 +33,6 @@ export type UploadcareDerivativeApiOptions = { filename?: string; /** Whether to store the generated image. Defaults to "auto" (per server default). */ store?: 'auto' | boolean; - /** Override the global fetch — useful for tests. */ - fetch?: typeof fetch; /** * CDN cname for resolving `{uuid}` responses. When left at the default * `https://ucarecdn.com` (or already a prefixed base), the actual base is @@ -91,7 +89,6 @@ export class UploadcareDerivativeApi implements AiProvider { this.api = new UploadcareApiClient({ publicKey: options.publicKey, baseUrl: options.baseUrl, - fetch: options.fetch, authToken: options.authToken, }); this.publicKey = options.publicKey; diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts index 75cbf8f..ae5860c 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts @@ -34,9 +34,19 @@ describe('UcAiAspectRatio', () => { expect(selected[0]!.querySelector('.option-ratio')?.textContent).toBe('3:2'); }); + it('names the listbox after the picker', async () => { + const el = await mount({ labelText: 'Pick aspect ratio' }); + expect(el.shadowRoot!.querySelector('[role="listbox"]')?.getAttribute('aria-label')).toBe('Pick aspect ratio'); + }); + + it('announces the selected ratio in the trigger name', async () => { + const el = await mount({ labelText: 'Pick aspect ratio' }); + expect(trigger(el).getAttribute('aria-label')).toBe('Pick aspect ratio: 3:2'); + }); + it('disables the trigger while busy', async () => { const el = await mount({ busy: true }); - expect(trigger(el).disabled).toBe(true); + expect(trigger(el)).toHaveProperty('disabled', true); }); it('renders an Auto entry when one is provided (edit mode)', async () => { diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts index 23521c8..db77719 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts @@ -31,7 +31,8 @@ export class UcAiAspectRatio extends LitElement { public busy = false; /** - * Accessible label for the trigger. A plain property (not the inherited + * Accessible label for the trigger (which appends the current selection) and + * the listbox. A plain property (not the inherited * `ariaLabel`) so it doesn't also stamp `aria-label` on the host element. */ @property({ attribute: 'aria-label-text' }) @@ -93,6 +94,7 @@ export class UcAiAspectRatio extends LitElement { public override render(): TemplateResult { const triggerLabel = this._triggerLabel(); + const label = this.labelText ?? 'Aspect ratio'; return html` -
+
${this.options.map((option) => { const isSelected = this.selected ? aspectRatioValueEquals(option.value, this.selected) : false; diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/aspect-ratio.css b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/aspect-ratio.css index c8f89eb..3fe7303 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/aspect-ratio.css +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/aspect-ratio.css @@ -22,7 +22,7 @@ } .trigger:hover, -.trigger[aria-expanded='true'] { +.trigger[aria-expanded="true"] { background: var(--uc-ai-secondary-hover); } @@ -123,7 +123,7 @@ background: var(--uc-ai-secondary); } -.option[aria-selected='true'] { +.option[aria-selected="true"] { background: var(--uc-ai-secondary-hover); } diff --git a/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts b/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts index 44a12f6..aaa8af7 100644 --- a/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts +++ b/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts @@ -16,8 +16,17 @@ const okResult = (url: string, prompt: string, mode: AiEditorMode = 'generate'): class FakeHost implements ReactiveControllerHost { public requestUpdate = vi.fn(); public updateComplete = Promise.resolve(true); - public addController = vi.fn<(controller: ReactiveController) => void>(); public removeController = vi.fn(); + private readonly controllers: ReactiveController[] = []; + + public addController(controller: ReactiveController): void { + this.controllers.push(controller); + } + + /** What a Lit element does when it leaves the DOM. */ + public disconnect(): void { + for (const controller of this.controllers) controller.hostDisconnected?.(); + } } /** Provider whose generate() resolves with the next value supplied via setNext(). */ @@ -58,11 +67,6 @@ describe('GenerationController', () => { host = new FakeHost(); }); - it('registers itself with the host on construction', () => { - const ctrl = new GenerationController(host); - expect(host.addController).toHaveBeenCalledWith(ctrl); - }); - it('starts with busy=false, no result, no error, empty history', () => { const ctrl = new GenerationController(host); expect(ctrl.busy).toBe(false); @@ -176,23 +180,24 @@ describe('GenerationController', () => { expect(host.requestUpdate).toHaveBeenCalled(); }); - it('hostDisconnected aborts the in-flight request', () => { + it('aborts the in-flight request when its host disconnects', () => { const ctrl = new GenerationController(host); const { provider, observeSignal } = createDeferredProvider(); void ctrl.run({ provider, prompt: 'x', mode: 'generate' }); - ctrl.hostDisconnected(); + host.disconnect(); expect(observeSignal()?.aborted).toBe(true); }); - it('requests an update on each significant transition', async () => { + it('asks the host to re-render when a run starts and again when it settles', async () => { const ctrl = new GenerationController(host); - const provider: AiProvider = { - id: 'fake', - generate: async ({ prompt, mode }) => okResult('https://example.com/x.jpg', prompt, mode), - }; + const { provider, setNext } = createDeferredProvider(); + + const run = ctrl.run({ provider, prompt: 'x', mode: 'generate' }); + expect(host.requestUpdate).toHaveBeenCalled(); + host.requestUpdate.mockClear(); - await ctrl.run({ provider, prompt: 'x', mode: 'generate' }); - // One on enter-busy + one on finally. - expect(host.requestUpdate).toHaveBeenCalledTimes(2); + setNext(okResult('https://example.com/x.jpg', 'x')); + await run; + expect(host.requestUpdate).toHaveBeenCalled(); }); }); diff --git a/packages/ai-image-editor/src/features/preset-chips/ui/UcAiChips.test.ts b/packages/ai-image-editor/src/features/preset-chips/ui/UcAiChips.test.ts index 10cf5c3..1244a57 100644 --- a/packages/ai-image-editor/src/features/preset-chips/ui/UcAiChips.test.ts +++ b/packages/ai-image-editor/src/features/preset-chips/ui/UcAiChips.test.ts @@ -28,7 +28,8 @@ describe('UcAiChips', () => { it('renders chips as plain buttons (no sticky pressed state)', async () => { const el = await mount({ mode: 'generate' }); - expect(chips(el).some((c) => c.hasAttribute('aria-pressed'))).toBe(false); + expect(chips(el)).not.toHaveLength(0); + expect(chips(el).filter((c) => c.hasAttribute('aria-pressed'))).toEqual([]); }); it('emits uc:select with the preset when a chip is clicked', async () => { @@ -44,6 +45,6 @@ describe('UcAiChips', () => { it('disables the chips while busy', async () => { const el = await mount({ mode: 'generate', busy: true }); - expect(chips(el).every((c) => c.disabled)).toBe(true); + expect(chips(el).map((c) => c.disabled)).toEqual(MODES.generate.presets.map(() => true)); }); }); diff --git a/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts b/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts index 5fd091c..bc3c14b 100644 --- a/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts +++ b/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts @@ -37,7 +37,6 @@ async function mount(overrides: Partial = {}): Promise } const chips = (el: UcAiHistory) => [...el.shadowRoot!.querySelectorAll('.chip')]; -const startOver = (el: UcAiHistory) => el.shadowRoot!.querySelector('.startover__btn'); const prev = (el: UcAiHistory) => el.shadowRoot!.querySelector('.nav--prev'); const next = (el: UcAiHistory) => el.shadowRoot!.querySelector('.nav--next'); @@ -53,7 +52,6 @@ describe('UcAiHistory', () => { it('renders nothing in generate mode with no results', async () => { const el = await mount({ entries: [], showStartOver: false }); expect(chips(el)).toHaveLength(0); - expect(startOver(el)).toBeNull(); expect(el.shadowRoot!.querySelector('.strip')).toBeNull(); }); @@ -104,8 +102,8 @@ describe('UcAiHistory', () => { const el = await mount({ entries: series(7), selectedUuid: 'e6' }); // ordered oldest→newest is e0…e6; the newest window is e2…e6. expect(labelsOf(el)).toEqual(['e2', 'e3', 'e4', 'e5', 'e6']); - expect(prev(el)!.disabled).toBe(false); // an older result exists - expect(next(el)!.disabled).toBe(true); // already at the newest + expect(prev(el)).toHaveProperty('disabled', false); // an older result exists + expect(next(el)).toHaveProperty('disabled', true); // already at the newest }); it('prev/next step the active result and emit uc:select', async () => { @@ -114,6 +112,7 @@ describe('UcAiHistory', () => { el.addEventListener('uc:select', onSelect); prev(el)!.click(); // one older than e6 + expect(onSelect).toHaveBeenCalledOnce(); expect((onSelect.mock.calls[0]![0] as CustomEvent).detail.entry.id).toBe('e5'); // The parent restores that result, handing the selection back down. @@ -155,12 +154,12 @@ describe('UcAiHistory', () => { it('disables prev at the oldest result and next at the newest', async () => { const oldest = await mount({ entries: series(7), selectedUuid: 'e0' }); - expect(prev(oldest)!.disabled).toBe(true); - expect(next(oldest)!.disabled).toBe(false); + expect(prev(oldest)).toHaveProperty('disabled', true); + expect(next(oldest)).toHaveProperty('disabled', false); const newest = await mount({ entries: series(7), selectedUuid: 'e6' }); - expect(prev(newest)!.disabled).toBe(false); - expect(next(newest)!.disabled).toBe(true); + expect(prev(newest)).toHaveProperty('disabled', false); + expect(next(newest)).toHaveProperty('disabled', true); }); it('keeps the window steady across unrelated re-renders (fresh array, same selection)', async () => { @@ -226,7 +225,7 @@ describe('UcAiHistory', () => { expect(chips(el)).toHaveLength(9); expect(prev(el)).toBeNull(); expect(next(el)).toBeNull(); - expect(el.shadowRoot!.querySelector('.chips--carousel')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('.chips--carousel')).not.toBeNull(); }); it('selects the chip whose uuid is tapped and emits uc:select', async () => { @@ -236,6 +235,7 @@ describe('UcAiHistory', () => { el.addEventListener('uc:select', onSelect); chips(el)[0]!.click(); // oldest (e0) + expect(onSelect).toHaveBeenCalledOnce(); expect((onSelect.mock.calls[0]![0] as CustomEvent).detail.entry.id).toBe('e0'); }); @@ -243,19 +243,8 @@ describe('UcAiHistory', () => { mockCoarse(false); const el = await mount({ entries: series(9), selectedUuid: 'e8' }); expect(chips(el)).toHaveLength(5); - expect(next(el)).toBeTruthy(); + expect(next(el)).not.toBeNull(); expect(el.shadowRoot!.querySelector('.chips--carousel')).toBeNull(); }); }); - - it.skip('shows a Start over control in edit mode and emits uc:start-over', async () => { - const el = await mount({ entries: [], showStartOver: true, startOverLabel: 'Start over' }); - expect(el.shadowRoot!.querySelector('.strip')).toBeTruthy(); - expect(startOver(el)?.textContent).toContain('Start over'); - - const onStartOver = vi.fn(); - el.addEventListener('uc:start-over', onStartOver); - startOver(el)!.click(); - expect(onStartOver).toHaveBeenCalledOnce(); - }); }); diff --git a/packages/ai-image-editor/src/features/prompt-input/ui/UcAiPromptRow.test.ts b/packages/ai-image-editor/src/features/prompt-input/ui/UcAiPromptRow.test.ts index b14d1cb..53aba22 100644 --- a/packages/ai-image-editor/src/features/prompt-input/ui/UcAiPromptRow.test.ts +++ b/packages/ai-image-editor/src/features/prompt-input/ui/UcAiPromptRow.test.ts @@ -19,16 +19,17 @@ const keydown = (el: UcAiPromptRow, init: KeyboardEventInit) => describe('UcAiPromptRow', () => { it('renders a multiline textarea inside the composer card', async () => { const el = await mount({ placeholder: 'Describe your image' }); - expect(textarea(el)).toBeTruthy(); + expect(textarea(el)).toBeInstanceOf(HTMLTextAreaElement); expect(el.shadowRoot!.querySelector('input')).toBeNull(); - expect(el.shadowRoot!.querySelector('.card')).toBeTruthy(); + // The textarea sits inside the composer card. + expect(el.shadowRoot!.querySelector('.card')).toContain(textarea(el)); expect(textarea(el).placeholder).toBe('Describe your image'); }); it('exposes slots for chips and the aspect-ratio picker', async () => { const el = await mount(); - expect(el.shadowRoot!.querySelector('slot[name="chips"]')).toBeTruthy(); - expect(el.shadowRoot!.querySelector('slot[name="aspect-ratio"]')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('slot[name="chips"]')).not.toBeNull(); + expect(el.shadowRoot!.querySelector('slot[name="aspect-ratio"]')).not.toBeNull(); }); it('emits uc:send on Enter when the value is non-empty', async () => { @@ -100,18 +101,18 @@ describe('UcAiPromptRow', () => { it('disables the textarea and send button while busy', async () => { const el = await mount({ value: 'hi', busy: true }); - expect(textarea(el).disabled).toBe(true); - expect(sendBtn(el)!.disabled).toBe(true); - expect(sendBtn(el)!.classList.contains('send--busy')).toBe(true); + expect(textarea(el)).toHaveProperty('disabled', true); + expect(sendBtn(el)).toHaveProperty('disabled', true); + expect(sendBtn(el)!.classList).toContain('send--busy'); }); it('hides (collapses) the send button when empty and reveals it otherwise', async () => { const el = await mount(); - expect(sendBtn(el)!.hidden).toBe(true); + expect(sendBtn(el)).toHaveProperty('hidden', true); el.value = 'hi'; await el.updateComplete; - expect(sendBtn(el)!.hidden).toBe(false); + expect(sendBtn(el)).toHaveProperty('hidden', false); }); it('emits uc:send when the send button is clicked', async () => { diff --git a/packages/ai-image-editor/src/shared/lib/cdn.test.ts b/packages/ai-image-editor/src/shared/lib/cdn.test.ts index 2a3a1b0..4bb9573 100644 --- a/packages/ai-image-editor/src/shared/lib/cdn.test.ts +++ b/packages/ai-image-editor/src/shared/lib/cdn.test.ts @@ -1,3 +1,4 @@ +import fc from 'fast-check'; import { describe, expect, it, vi } from 'vitest'; import { CDN_MAX_OUTPUT_DIMENSION, cdnPreviewUrl, cdnSquareThumbUrl } from './cdn'; @@ -13,31 +14,63 @@ describe('cdn helpers', () => { it('builds a dpr-scaled preview url capped at the CDN limit (resize directive only)', () => { vi.stubGlobal('devicePixelRatio', 2); - try { - // Only the resize directive — the CDN applies format/auto + quality itself. - expect(cdnPreviewUrl(UC_URL, 800)).toBe(`${UC_URL}-/preview/1600x1600/`); - expect(cdnPreviewUrl(UC_URL, 2000)).toContain(`preview/${CDN_MAX_OUTPUT_DIMENSION}x${CDN_MAX_OUTPUT_DIMENSION}`); - } finally { - vi.unstubAllGlobals(); - } + // Only the resize directive — the CDN applies format/auto + quality itself. + expect(cdnPreviewUrl(UC_URL, 800)).toBe(`${UC_URL}-/preview/1600x1600/`); + expect(cdnPreviewUrl(UC_URL, 2000)).toContain(`preview/${CDN_MAX_OUTPUT_DIMENSION}x${CDN_MAX_OUTPUT_DIMENSION}`); }); it('scales the preview by devicePixelRatio', () => { vi.stubGlobal('devicePixelRatio', 1); - try { - expect(cdnPreviewUrl(UC_URL, 800)).toBe(`${UC_URL}-/preview/800x800/`); - } finally { - vi.unstubAllGlobals(); - } + expect(cdnPreviewUrl(UC_URL, 800)).toBe(`${UC_URL}-/preview/800x800/`); }); it('builds a dpr-scaled centered square crop-to-fill thumbnail', () => { vi.stubGlobal('devicePixelRatio', 2); - try { - // scale_crop (not preview) so a non-square source fills the square tile sharply. - expect(cdnSquareThumbUrl(UC_URL, 48)).toBe(`${UC_URL}-/scale_crop/96x96/center/`); - } finally { - vi.unstubAllGlobals(); - } + // scale_crop (not preview) so a non-square source fills the square tile sharply. + expect(cdnSquareThumbUrl(UC_URL, 48)).toBe(`${UC_URL}-/scale_crop/96x96/center/`); + }); + + describe('for any file, box size and devicePixelRatio', () => { + const fileUrl = fc + .tuple( + fc.constantFrom('https://ucarecdn.com', 'https://1a2b3c4d5e.ucarecd.net', 'https://cdn.example.com'), + fc.uuid(), + ) + .map(([origin, uuid]) => `${origin}/${uuid}/`); + const cssSize = fc.double({ min: 1, max: 5000, noNaN: true }); + const dpr = fc.double({ min: 0.25, max: 4, noNaN: true }); + + /** + * Holds `side` (the n of an n×n directive) to the box: never past the CDN limit, never short of the box's device + * pixels (counting a dpr below 1 as 1) unless the limit stops it, and never a whole pixel more. + */ + const expectSideToFit = (side: number, css: number, ratio: number) => { + const needed = css * Math.max(ratio, 1); + expect(side).toBeLessThanOrEqual(CDN_MAX_OUTPUT_DIMENSION); + expect(side).toBeGreaterThanOrEqual(Math.min(needed, CDN_MAX_OUTPUT_DIMENSION)); + expect(side - 1).toBeLessThan(needed); + }; + + it('appends one square preview sized to the box', () => { + fc.assert( + fc.property(fileUrl, cssSize, dpr, (url, css, ratio) => { + vi.stubGlobal('devicePixelRatio', ratio); + const [, w] = cdnPreviewUrl(url, css).match(/\/-\/preview\/(\d+)x/) ?? []; + expect(cdnPreviewUrl(url, css)).toBe(`${url}-/preview/${w}x${w}/`); + expectSideToFit(Number(w), css, ratio); + }), + ); + }); + + it('appends one centred square crop sized to the box', () => { + fc.assert( + fc.property(fileUrl, cssSize, dpr, (url, css, ratio) => { + vi.stubGlobal('devicePixelRatio', ratio); + const [, w] = cdnSquareThumbUrl(url, css).match(/\/-\/scale_crop\/(\d+)x/) ?? []; + expect(cdnSquareThumbUrl(url, css)).toBe(`${url}-/scale_crop/${w}x${w}/center/`); + expectSideToFit(Number(w), css, ratio); + }), + ); + }); }); }); diff --git a/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts b/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts index 2cad2ca..6a5eee8 100644 --- a/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts +++ b/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import './UcAiCanvas'; import type { UcAiCanvas } from './UcAiCanvas'; @@ -15,23 +15,22 @@ const preloadImg = (el: UcAiCanvas) => el.shadowRoot!.querySelector('img.preload const shownImg = (el: UcAiCanvas) => el.shadowRoot!.querySelector('img.canvas__image'); const errorState = (el: UcAiCanvas) => el.shadowRoot!.querySelector('.error-state'); const canvasEl = (el: UcAiCanvas) => el.shadowRoot!.querySelector('.canvas')!; -const isEmpty = (el: UcAiCanvas) => canvasEl(el).classList.contains('is-empty'); -const isLoading = (el: UcAiCanvas) => canvasEl(el).classList.contains('is-loading'); +const canvasClasses = (el: UcAiCanvas) => canvasEl(el).classList; describe('UcAiCanvas', () => { it('preloads the new url before displaying it', async () => { const el = await mount('https://example.com/a.png'); // Before load: empty state (dot grid shown), nothing displayed, preloader present. - expect(isEmpty(el)).toBe(true); + expect(canvasClasses(el)).toContain('is-empty'); expect(shownImg(el)).toBeNull(); - expect(preloadImg(el)).toBeTruthy(); + expect(preloadImg(el)).not.toBeNull(); preloadImg(el)!.dispatchEvent(new Event('load')); await el.updateComplete; // After load: the image is displayed, empty state cleared, preloader gone. expect(shownImg(el)?.getAttribute('src')).toBe('https://example.com/a.png'); - expect(isEmpty(el)).toBe(false); + expect(canvasClasses(el)).not.toContain('is-empty'); expect(preloadImg(el)).toBeNull(); }); @@ -57,7 +56,7 @@ describe('UcAiCanvas', () => { el.ratio = 16 / 9; await el.updateComplete; // The frame element exists and is the box the image is cropped into. - expect(el.shadowRoot!.querySelector('.canvas__frame')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('.canvas__frame')).not.toBeNull(); }); describe('frame sizing', () => { @@ -199,7 +198,7 @@ describe('UcAiCanvas', () => { const el = await mount('https://example.com/broken.png'); preloadImg(el)!.dispatchEvent(new Event('error')); await el.updateComplete; - expect(errorState(el)).toBeTruthy(); + expect(errorState(el)).not.toBeNull(); el.url = 'https://example.com/fresh.png'; await el.updateComplete; @@ -210,11 +209,11 @@ describe('UcAiCanvas', () => { it('marks the canvas as loading while busy', async () => { const el = await mount(null); - expect(isLoading(el)).toBe(false); + expect(canvasClasses(el)).not.toContain('is-loading'); el.busy = true; await el.updateComplete; - expect(isLoading(el)).toBe(true); - expect(isEmpty(el)).toBe(true); + expect(canvasClasses(el)).toContain('is-loading'); + expect(canvasClasses(el)).toContain('is-empty'); }); describe('fullscreen button', () => { @@ -228,6 +227,13 @@ describe('UcAiCanvas', () => { return requestFullscreen; } + // happy-dom has none of these, so removing them restores it; a stub left behind would show the button in a later test. + afterEach(() => { + Reflect.deleteProperty(document, 'fullscreenEnabled'); + Reflect.deleteProperty(document, 'fullscreenElement'); + Reflect.deleteProperty(HTMLElement.prototype, 'requestFullscreen'); + }); + async function mountWithImage(): Promise { const el = await mount('https://example.com/a.png'); el.fullscreenLabel = 'View fullscreen'; @@ -249,7 +255,7 @@ describe('UcAiCanvas', () => { preloadImg(el)!.dispatchEvent(new Event('load')); await el.updateComplete; - expect(fullscreenBtn(el)).toBeTruthy(); + expect(fullscreenBtn(el)).not.toBeNull(); }); it('requests fullscreen on the canvas when clicked', async () => { @@ -273,20 +279,16 @@ describe('UcAiCanvas', () => { } }, ); - try { - const el = await mountWithImage(); - el.fullsizeUrl = 'https://example.com/full.png'; - await el.updateComplete; - - fullscreenBtn(el)!.dispatchEvent(new Event('mouseenter')); - expect(created).toEqual(['https://example.com/full.png']); - - // Hovering again must not refetch the same url. - fullscreenBtn(el)!.dispatchEvent(new Event('mouseenter')); - expect(created).toHaveLength(1); - } finally { - vi.unstubAllGlobals(); - } + const el = await mountWithImage(); + el.fullsizeUrl = 'https://example.com/full.png'; + await el.updateComplete; + + fullscreenBtn(el)!.dispatchEvent(new Event('mouseenter')); + expect(created).toEqual(['https://example.com/full.png']); + + // Hovering again must not refetch the same url. + fullscreenBtn(el)!.dispatchEvent(new Event('mouseenter')); + expect(created).toHaveLength(1); }); it('overlays the fullsize rendition only while fullscreen', async () => { diff --git a/packages/ai-image-editor/src/shared/ui/footer/UcAiFooter.test.ts b/packages/ai-image-editor/src/shared/ui/footer/UcAiFooter.test.ts index e267c40..62665df 100644 --- a/packages/ai-image-editor/src/shared/ui/footer/UcAiFooter.test.ts +++ b/packages/ai-image-editor/src/shared/ui/footer/UcAiFooter.test.ts @@ -40,6 +40,6 @@ describe('UcAiFooter', () => { it('disables Done when primaryDisabled is set', async () => { const el = await mount({ primaryDisabled: true }); - expect(primaryBtn(el)!.disabled).toBe(true); + expect(primaryBtn(el)).toHaveProperty('disabled', true); }); }); diff --git a/packages/ai-image-editor/src/widgets/ai-image-editor-plugin/ui/AiImageEditorPlugin.test.ts b/packages/ai-image-editor/src/widgets/ai-image-editor-plugin/ui/AiImageEditorPlugin.test.ts index 140722f..7b648ee 100644 --- a/packages/ai-image-editor/src/widgets/ai-image-editor-plugin/ui/AiImageEditorPlugin.test.ts +++ b/packages/ai-image-editor/src/widgets/ai-image-editor-plugin/ui/AiImageEditorPlugin.test.ts @@ -21,7 +21,6 @@ describe('aspectRatiosFromCropPreset', () => { [1, 1], ]); expect(warn).toHaveBeenCalled(); - warn.mockRestore(); }); it('falls back to the popular set when only "free" is configured', () => { diff --git a/packages/ai-image-editor/tests/contract/derivative.contract.test.ts b/packages/ai-image-editor/tests/contract/derivative.contract.test.ts new file mode 100644 index 0000000..1286014 --- /dev/null +++ b/packages/ai-image-editor/tests/contract/derivative.contract.test.ts @@ -0,0 +1,58 @@ +import { assert, expect, it } from 'vitest'; +import { UploadcareApiClient, type UploadcareJobStatus } from '../../src/entities/provider/api/uploadcareApiClient'; +import { UploadcareDerivativeApi } from '../../src/entities/provider/api/uploadcareDerivativeApi'; +import { PRODUCTION_STATUS_SUCCESS, shapeOf } from '../fixtures/production-status-success'; +import { POLL_INTERVAL_MS, PUBLIC_KEY } from './setup'; + +/** A well-formed uuid no project holds. */ +const ABSENT_UUID = '00000000-0000-4000-8000-000000000000'; + +/** Polls `jobId` until it errors or its result is ready, for at most two minutes. */ +async function pollToEnd(client: UploadcareApiClient, jobId: string): Promise { + const deadline = Date.now() + 120_000; + for (;;) { + const status = await client.getJobStatus(jobId); + if ((status.status === 'success' && status.is_ready) || status.status === 'error' || Date.now() > deadline) + return status; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +} + +/** + * The one scenario that spends a generation: start it, poll it to a ready result, then read the file's info. Every + * request and response also passes the client's dev schemas (see `tests/schema-drift.ts`). + */ +it("generates an image at the requested ratio, ready in a success frame shaped like production's", async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + + const { job_id } = await client.generate({ + prompt: 'a red apple on a white table', + aspectRatio: [3, 2], + filename: 'generated.png', + store: false, + }); + const done = await pollToEnd(client, job_id!); + + expect(shapeOf(done)).toEqual(shapeOf(PRODUCTION_STATUS_SUCCESS)); + assert(done.status === 'success'); + // Live sizes are the model's own (production drew 1248x832 for one request), so allow a pixel of rounding. + expect(done.image_info!.width / done.image_info!.height).toBeCloseTo(3 / 2, 2); + + const info = await new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }).getFileInfo(done.uuid); + expect(info.imageInfo).toMatchObject({ width: done.image_info!.width, height: done.image_info!.height }); +}); + +it('refuses the status of a job nobody started with job_not_found', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + await expect(client.getJobStatus(ABSENT_UUID)).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'job_not_found', + }); +}); + +it('refuses an edit of a file the project does not hold with source_not_found', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + await expect( + client.edit({ prompt: 'add a hat', source: ABSENT_UUID, filename: 'edited.png', store: false }), + ).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'source_not_found' }); +}); diff --git a/packages/ai-image-editor/tests/contract/setup.ts b/packages/ai-image-editor/tests/contract/setup.ts new file mode 100644 index 0000000..14e7138 --- /dev/null +++ b/packages/ai-image-editor/tests/contract/setup.ts @@ -0,0 +1,32 @@ +import { setupEmulator } from '@uploadcare/api-emulator/node'; +import { afterAll, beforeEach } from 'vitest'; +import { failOnSchemaDrift } from '../schema-drift'; + +/** + * The contract tests run the same assertions against one of two targets: by default the emulator (part of + * `npm test`), or with `UC_CONTRACT_TARGET=live` the real `upload.uploadcare.com`, so a difference between the two + * fails one of them. A live run needs `UC_CONTRACT_PUBLIC_KEY`, a project with AI derivatives enabled and signed + * uploads off; without it the run fails rather than skipping, so missing credentials never look like a pass. + */ +export const LIVE = process.env.UC_CONTRACT_TARGET === 'live'; + +function livePublicKey(): string { + const key = process.env.UC_CONTRACT_PUBLIC_KEY; + if (!key) throw new Error('UC_CONTRACT_TARGET=live needs UC_CONTRACT_PUBLIC_KEY'); + return key; +} + +export const PUBLIC_KEY = LIVE ? livePublicKey() : 'demopublickey'; + +/** Live, a job takes seconds to finish; the emulator walks it in four instant polls. */ +export const POLL_INTERVAL_MS = LIVE ? 1500 : 0; + +if (!LIVE) { + const emulator = setupEmulator(); + beforeEach(async () => { + await emulator.reset(); + }); + afterAll(() => emulator.stop()); +} + +failOnSchemaDrift(); diff --git a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx index c7cb9f0..c565d34 100644 --- a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx @@ -1,125 +1,124 @@ +import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it, vi } from 'vitest'; -import { clickSend, editorMode, mount, SAMPLE_UUID, STAGING, stubFetch, typePrompt } from './harness'; - -/** The ratio picker: what it offers per mode, what it sends, what it restores. */ +import { page, userEvent } from 'vitest/browser'; +import { session } from '../emulator'; +import { + clickDone, + editorRegion, + expectCanvasToShow, + generatedUuid, + historyChips, + mount, + SAMPLE_UUID, + STAGING, + sendPrompt, + type UcAiImageEditorType, +} from './harness'; + +/** The picker's trigger, named "Pick aspect ratio: ". */ +const ratioTrigger = () => page.getByRole('button', { name: /^Pick aspect ratio/ }); +const ratioOptions = () => page.getByRole('listbox', { name: 'Pick aspect ratio' }).getByRole('option'); + +/** Opens the picker and picks the option named `name`. */ +async function pickRatio(name: string): Promise { + await userEvent.click(ratioTrigger()); + await userEvent.click(ratioOptions().filter({ hasText: name })); +} + +/** + * Waits for the run's result on the canvas, commits it, and returns the image info of the file `uc:done` hands the + * host. The emulator draws a result at the requested ratio (or keeps the source's size when none is sent), so the + * dimensions show which ratio the editor asked for. + */ +async function committedImage(el: UcAiImageEditorType): Promise { + await expectCanvasToShow(await generatedUuid()); + const onDone = vi.fn(); + el.addEventListener('uc:done', onDone); + await clickDone(); + expect(onDone).toHaveBeenCalledOnce(); + return (onDone.mock.calls[0]![0] as CustomEvent<{ file: UploadcareFile }>).detail.file.imageInfo; +} + +/** A result's width over its height. */ +const ratioOf = (image: UploadcareFile['imageInfo']) => (image?.width ?? Number.NaN) / (image?.height ?? Number.NaN); + +/** The ratio picker: what it offers per mode, the shape of the image it produces, what it restores. */ describe(' aspect ratio', () => { - it('renders the aspect-ratio picker in generate mode (no Auto) and sends the selected ratio', async () => { - const stub = stubFetch(); - const el = mount({ ...STAGING, 'aspect-ratios': '16:9 1:1' }); - await el.updateComplete; - - const ratio = el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; - expect(ratio).toBeTruthy(); + it('renders the aspect-ratio picker in generate mode (no Auto) and generates at the selected ratio', async () => { + // 16:9: neither the generate default (3:2) nor the provider's fallback (1:1). + const el = mount({ ...STAGING, 'aspect-ratios': '3:2 16:9' }); // Generate mode offers only the standard ratios — no "Auto". - (ratio.shadowRoot!.querySelector('.trigger') as HTMLButtonElement).click(); - await el.updateComplete; - const options = Array.from(ratio.shadowRoot!.querySelectorAll('.option')) as HTMLButtonElement[]; - expect(options.length).toBe(2); - - // Pick the second option (1:1) before the only generate — sending flips to edit. - options[1]!.click(); - await el.updateComplete; - typePrompt(el, 'mountain'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); - expect(stub.generateBodies[0]!.aspect_ratio).toEqual([1, 1]); + await userEvent.click(ratioTrigger()); + await expect.poll(() => ratioOptions().elements()).toHaveLength(2); + await expect.element(ratioOptions().first()).toHaveTextContent('3:2'); + await expect.element(ratioOptions().last()).toHaveTextContent('16:9'); + + // Pick 16:9 before the only generate — sending flips to edit. + await userEvent.click(ratioOptions().last()); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 16:9'); + await sendPrompt('mountain'); + expect(ratioOf(await committedImage(el))).toBeCloseTo(16 / 9, 2); }); - it('defaults edit mode to "Auto" and omits aspect_ratio (preserving the source AR)', async () => { - const stub = stubFetch({ uuid: 'edited' }); + it('defaults edit mode to "Auto", which keeps the dimensions of the source image', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); - - // The picker leads with an "Auto" entry (square icon, no w:h numbers). - const ratio = el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; - (ratio.shadowRoot!.querySelector('.trigger') as HTMLButtonElement).click(); - await el.updateComplete; - const options = Array.from(ratio.shadowRoot!.querySelectorAll('.option')) as HTMLButtonElement[]; - expect(options[0]!.textContent).toContain('Auto'); - // "Auto" reserves the ratio column but shows no "w:h" numbers. - expect(options[0]!.querySelector('.option-ratio')?.textContent).toBe(''); - - typePrompt(el, 'add a hat'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); - // Auto is the default → no aspect_ratio on the wire; backend preserves it. - expect(stub.generateBodies[0]!.aspect_ratio).toBeUndefined(); - expect(stub.generateBodies[0]!.source).toBe(SAMPLE_UUID); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: Auto'); + + // The picker leads with an "Auto" entry, named without any w:h numbers. + await userEvent.click(ratioTrigger()); + await expect.element(ratioOptions().first()).toHaveAccessibleName('Auto'); + await expect.element(ratioOptions().first()).toHaveAttribute('aria-selected', 'true'); + await userEvent.keyboard('{Escape}'); + + await sendPrompt('add a hat'); + // Auto is the default → no aspect_ratio is sent, so the result keeps the source's size. + const source = session.files.get(SAMPLE_UUID)!.image!; + expect(await committedImage(el)).toMatchObject({ width: source.width, height: source.height }); }); it('renders the aspect-ratio picker in edit mode too', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); - expect(el.shadowRoot!.querySelector('uc-ai-aspect-ratio')).toBeTruthy(); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(ratioTrigger()).toBeVisible(); }); - it('sends an explicit ratio when the user reshapes in edit mode', async () => { - const stub = stubFetch({ uuid: 'edited' }); + it('reshapes the result to a ratio the user picks in edit mode', async () => { const el = mount({ ...STAGING, 'aspect-ratios': '1:1' }); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; + await expect.element(editorRegion('edit')).toBeVisible(); - const ratio = el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; - (ratio.shadowRoot!.querySelector('.trigger') as HTMLButtonElement).click(); - await el.updateComplete; // [Auto, 1:1] in edit mode — pick the concrete ratio to reshape. - const options = Array.from(ratio.shadowRoot!.querySelectorAll('.option')) as HTMLButtonElement[]; - expect(options.length).toBe(2); - options[1]!.click(); - await el.updateComplete; - - typePrompt(el, 'make it square'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); - expect(stub.generateBodies[0]!.aspect_ratio).toEqual([1, 1]); + await userEvent.click(ratioTrigger()); + await expect.poll(() => ratioOptions().elements()).toHaveLength(2); + await userEvent.click(ratioOptions().filter({ hasText: '1:1' })); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 1:1'); + + await sendPrompt('make it square'); + // The source is not square, so a square result means the 1:1 was sent rather than Auto. + expect(ratioOf(await committedImage(el))).toBe(1); }); it('records the aspect ratio on a history entry and restores it when re-selected', async () => { - stubFetch({ uuid: 'r1' }); const el = mount({ ...STAGING, 'aspect-ratios': '16:9 1:1' }); - await el.updateComplete; - - const ratioEl = () => el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; - const selected = () => (ratioEl() as unknown as { selected: unknown }).selected; - const pickOption = async (i: number) => { - (ratioEl().shadowRoot!.querySelector('.trigger') as HTMLButtonElement).click(); - await el.updateComplete; - (ratioEl().shadowRoot!.querySelectorAll('.option')[i] as HTMLButtonElement).click(); - await el.updateComplete; - }; // Generate with 1:1 (generate options are [16:9, 1:1]). - await pickOption(1); - expect(selected()).toEqual([1, 1]); - typePrompt(el, 'a cat'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); - await el.updateComplete; - - const history = el.shadowRoot!.querySelector('uc-ai-history') as unknown as { - entries: Array<{ ratio: unknown }>; - }; - expect(history.entries[0]!.ratio).toEqual([1, 1]); + await pickRatio('1:1'); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 1:1'); + await sendPrompt('a cat'); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(historyChips().first()).toHaveAccessibleName('a cat'); + expect(ratioOf(await committedImage(el))).toBe(1); // Change the ratio after the fact (edit options are [Auto, 16:9, 1:1]). - await pickOption(1); - expect(selected()).toEqual([16, 9]); + await pickRatio('16:9'); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 16:9'); // Re-selecting the history entry restores the 1:1 ratio it was made with. - const entry = history.entries[0]; - el.shadowRoot!.querySelector('uc-ai-history')!.dispatchEvent( - new CustomEvent('uc:select', { detail: { entry }, bubbles: true, composed: true }), - ); - await el.updateComplete; - expect(selected()).toEqual([1, 1]); + await userEvent.click(historyChips().first()); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 1:1'); }); }); diff --git a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx index d53239e..548a359 100644 --- a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx @@ -1,128 +1,115 @@ -import { describe, expect, it, vi } from 'vitest'; +import { mintAuthToken } from '@uploadcare/api-emulator'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { session } from '../emulator'; import type { UcAiImageEditorType } from './harness'; -import { clickSend, installFetch, jsonResponse, mount, STAGING, successFrame, typePrompt } from './harness'; - +import { historyChip, mount, STAGING, sendPrompt } from './harness'; + +/** + * The project here has Signed Uploads on, so the emulator refuses every + * derivative request (the POST and each status poll) that carries no valid + * Bearer token: a run that finishes is itself proof every request was signed. + * What the header looks like is pinned by the `uploadcareApiClient` and + * `uploadcareDerivativeApi` specs. + */ describe(' authToken', () => { - /** Like `stubFetch`, but records the Authorization header of every request. */ - function stubFetchCapturingAuth(): { auth: Array } { - const auth: Array = []; - installFetch((async (_input: RequestInfo | URL, init?: RequestInit) => { - auth.push(new Headers(init?.headers).get('Authorization')); - if ((init?.method ?? 'GET').toUpperCase() === 'POST') { - return jsonResponse({ type: 'job', job_id: 'job-1' }); - } - return jsonResponse(successFrame('result')); - }) as typeof fetch); - return { auth }; - } + beforeEach(() => { + session.use('signedUploads'); + }); /** - * Runs one generation and waits for its requests, counted rather than - * observed through the canvas: after the first run the canvas already holds - * the result URL, so waiting on it would return immediately and the second - * generation would never be awaited. + * Runs one generation and waits for its chip, named after the prompt, in the + * history: after the first run the canvas already holds a result, so waiting + * on it would return immediately and the second generation would never be + * awaited. */ - const generate = async (el: UcAiImageEditorType, prompt: string, auth: Array) => { - const before = auth.length; - typePrompt(el, prompt); - await el.updateComplete; - clickSend(el); - // The POST that starts the job, then at least one status GET. - await vi.waitFor(() => { - expect(auth.length).toBeGreaterThanOrEqual(before + 2); - }); - await el.updateComplete; + const generate = async (prompt: string) => { + await sendPrompt(prompt); + await expect.element(historyChip(prompt)).toBeVisible(); }; - it.each([undefined, null, ''])('sends no Authorization header when authToken is %p', async (value) => { - // The common case: a project without signing. The provider gets no token - // function, so nothing has to stand in for a missing token. - const { auth } = stubFetchCapturingAuth(); + /** Runs one generation and answers the `uc:error` the signing gate's refusal raises. */ + const generateRefused = async (el: UcAiImageEditorType, prompt: string) => { + const onError = vi.fn(); + el.addEventListener('uc:error', onError); + await sendPrompt(prompt); + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()); + el.removeEventListener('uc:error', onError); + return onError.mock.calls[0]![0].detail.error; + }; + + it.each([undefined, null, ''])('signs nothing when authToken is %p', async (value) => { + // No token function reaches the provider, so no header is sent at all: a + // missing header is SignatureRequiredError, while an empty 'Bearer ' would + // be AccessTokenInvalidError. const el = mount(STAGING); el.authToken = value as unknown as undefined; await el.updateComplete; - await generate(el, 'a tiger', auth); - - expect(auth.length).toBeGreaterThan(1); - expect(auth.every((value) => value === null)).toBe(true); + expect((await generateRefused(el, 'a tiger')).code).toBe('SignatureRequiredError'); }); it('signs once authToken is set and stops once it is unset', async () => { - const { auth } = stubFetchCapturingAuth(); const el = mount(STAGING); - el.authToken = 'eyJ.plain.sig'; await el.updateComplete; - await generate(el, 'a tiger', auth); - const signed = auth.length; + expect((await generateRefused(el, 'a tiger')).code).toBe('SignatureRequiredError'); - el.authToken = undefined; + el.authToken = await mintAuthToken({ tokenId: 'plain' }); await el.updateComplete; - await generate(el, 'a lion', auth); + await generate('a lion'); - expect(auth.slice(0, signed).every((value) => value === 'Bearer eyJ.plain.sig')).toBe(true); - expect(auth.slice(signed).every((value) => value === null)).toBe(true); - }); - - it('sends a plain token on every request', async () => { - const { auth } = stubFetchCapturingAuth(); - const el = mount(STAGING); - el.authToken = 'eyJ.plain.sig'; + el.authToken = undefined; await el.updateComplete; - - await generate(el, 'a tiger', auth); - - expect(auth.length).toBeGreaterThan(1); - expect(auth.every((value) => value === 'Bearer eyJ.plain.sig')).toBe(true); + expect((await generateRefused(el, 'a bear')).code).toBe('SignatureRequiredError'); }); it('calls an authToken function once and reuses the token across requests', async () => { // Standalone, the editor owns the cache: a generate makes several // authenticated requests and must not ask the app for a token each time. - const { auth } = stubFetchCapturingAuth(); - const fetchToken = vi.fn(async () => 'eyJ.fetched.sig'); + const token = await mintAuthToken({ tokenId: 'fetched' }); + const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate('a tiger'); - expect(auth.length).toBeGreaterThan(1); - expect(auth.every((value) => value === 'Bearer eyJ.fetched.sig')).toBe(true); - expect(fetchToken).toHaveBeenCalledTimes(1); + expect(fetchToken).toHaveBeenCalledOnce(); }); it('keeps the cached token when the function identity changes', async () => { // A React parent hands over a new closure on every render. - const { auth } = stubFetchCapturingAuth(); - const first = vi.fn(async () => 'eyJ.first.sig'); + const [firstToken, secondToken] = [ + await mintAuthToken({ tokenId: 'first' }), + await mintAuthToken({ tokenId: 'second' }), + ]; + const first = vi.fn(async () => firstToken); const el = mount(STAGING); el.authToken = first; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate('a tiger'); - const second = vi.fn(async () => 'eyJ.second.sig'); + const second = vi.fn(async () => secondToken); el.authToken = second; await el.updateComplete; - await generate(el, 'a lion', auth); + await generate('a lion'); + expect(first).toHaveBeenCalledOnce(); expect(second).not.toHaveBeenCalled(); - expect(auth.every((value) => value === 'Bearer eyJ.first.sig')).toBe(true); }); it('refetches after invalidateAuthToken()', async () => { // The escape hatch for a sign-out, and the only recovery when a token's // `exp` cannot be read and so never goes stale on its own. - const { auth } = stubFetchCapturingAuth(); - const fetchToken = vi.fn(async () => 'eyJ.fetched.sig'); + const token = await mintAuthToken({ tokenId: 'fetched' }); + const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', auth); - expect(fetchToken).toHaveBeenCalledTimes(1); + await generate('a tiger'); + expect(fetchToken).toHaveBeenCalledOnce(); el.invalidateAuthToken(); - await generate(el, 'a lion', auth); + await generate('a lion'); expect(fetchToken).toHaveBeenCalledTimes(2); }); @@ -130,14 +117,14 @@ describe(' authToken', () => { it('does not cache when cacheAuthToken is false', async () => { // What the file-uploader plugin sets: the uploader already caches, so the // editor must resolve through to it rather than hold its own copy. - const { auth } = stubFetchCapturingAuth(); - const fetchToken = vi.fn(async () => 'eyJ.fetched.sig'); + const token = await mintAuthToken({ tokenId: 'fetched' }); + const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.cacheAuthToken = false; el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate('a tiger'); expect(fetchToken.mock.calls.length).toBeGreaterThan(1); }); @@ -145,16 +132,15 @@ describe(' authToken', () => { it('accepts a provider and lets it own the caching', async () => { // What the File Uploader plugin hands over: the uploader's cache, which // the editor must use rather than wrap in a second one. - const { auth } = stubFetchCapturingAuth(); - const getToken = vi.fn(async () => 'eyJ.from.provider'); + const token = await mintAuthToken({ tokenId: 'provider' }); + const getToken = vi.fn(async () => token); const invalidate = vi.fn(); const el = mount(STAGING); el.authToken = { getToken, invalidate }; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate('a tiger'); - expect(auth.every((value) => value === 'Bearer eyJ.from.provider')).toBe(true); // Called per request: the provider decides what to cache, not the editor. expect(getToken.mock.calls.length).toBeGreaterThan(1); }); @@ -168,6 +154,6 @@ describe(' authToken', () => { el.invalidateAuthToken(); - expect(invalidate).toHaveBeenCalledTimes(1); + expect(invalidate).toHaveBeenCalledOnce(); }); }); diff --git a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx index 99bdaca..15c1f99 100644 --- a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx @@ -1,11 +1,27 @@ import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it, vi } from 'vitest'; -import { clickSend, editorMode, mount, SAMPLE_UUID, STAGING, stubFetch, typePrompt } from './harness'; +import type { UcAiImageEditorType } from './harness'; +import { clickDone, doneButton, editorRegion, mount, SAMPLE_UUID, STAGING, sendPrompt } from './harness'; -/** What the result is named: the source's name, a resolver, or a fixed string. */ +/** + * What the result is named: the source's name, a resolver, or a fixed string. + * The emulator stores a result under the name the editor asked for, so the + * committed file's `originalFilename` is the name the run produced. + */ describe(' result filename', () => { + /** Runs the test's one generation, commits it, and answers the committed file's name. */ + const producedFilename = async (el: UcAiImageEditorType, prompt: string) => { + await sendPrompt(prompt); + // The primary commits a result, so it is enabled once the run has one. + await expect.element(doneButton()).toBeEnabled(); + const onDone = vi.fn(); + el.addEventListener('uc:done', onDone); + await clickDone(); + expect(onDone).toHaveBeenCalledOnce(); + return (onDone.mock.calls[0]![0].detail.file as UploadcareFile).originalFilename; + }; + it('names the result after the source file in edit mode (preserves the original name)', async () => { - const stub = stubFetch({ uuid: 'edited' }); const el = mount(STAGING); // Inject the source's file info (as the plugin does) — it carries the uuid // (→ edit mode) and its originalFilename is the default output name. @@ -14,23 +30,17 @@ describe(' result filename', () => { originalFilename: 'holiday-photo.jpg', imageInfo: null, } as unknown as UploadcareFile; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); + await expect.element(editorRegion('edit')).toBeVisible(); - typePrompt(el, 'add a hat'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); // The edit result is named after the source, not the provider's default. - expect(stub.generateBodies[0]!.filename).toBe('holiday-photo.jpg'); + expect(await producedFilename(el, 'add a hat')).toBe('holiday-photo.jpg'); }); it('names the result via the outputFilename resolver (original + counter)', async () => { - const stub = stubFetch({ uuid: 'r1' }); const el = mount(STAGING); - // A fresh uuid with no persisted lineage → counter starts at 1. + // History is cleared after every test, so the source has no persisted lineage → counter starts at 1. el.sourceFileInfo = { - uuid: 'resolver-test-uuid-0001', + uuid: SAMPLE_UUID, originalFilename: 'cat.png', imageInfo: null, } as unknown as UploadcareFile; @@ -38,23 +48,14 @@ describe(' result filename', () => { el.outputFilename = (original, counter) => `${original ?? 'ai'}-edit-${counter}`; await el.updateComplete; - typePrompt(el, 'add a hat'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); - expect(stub.generateBodies[0]!.filename).toBe('cat.png-edit-1'); + expect(await producedFilename(el, 'add a hat')).toBe('cat.png-edit-1'); }); it('uses a static outputFilename string verbatim', async () => { - const stub = stubFetch({ uuid: 'r1' }); const el = mount(STAGING); el.outputFilename = 'my-art.png'; await el.updateComplete; - typePrompt(el, 'a sunset'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); - expect(stub.generateBodies[0]!.filename).toBe('my-art.png'); + expect(await producedFilename(el, 'a sunset')).toBe('my-art.png'); }); }); diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index cb4296c..ae757ae 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -1,15 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { session } from '../emulator'; import { - canvasUrl, - clickPrimary, - clickSend, - editorMode, + canvasImage, + clickDone, + doneButton, + editorRegion, + expectCanvasToShow, + generatedUuid, mount, - primaryBtn, + promptBox, SAMPLE_UUID, + SECOND_SAMPLE_UUID, STAGING, - stubFetch, - typePrompt, + sendPrompt, } from './harness'; /** @@ -18,104 +22,77 @@ import { */ describe(' generation', () => { it('auto-enters edit mode after the first successful generation', async () => { - stubFetch({ uuid: 'result' }); - const el = mount(STAGING); - await el.updateComplete; - expect(editorMode(el)).toBe('generate'); - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toBe('https://cdn.example.com/result/')); - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); + mount(STAGING); + await expect.element(editorRegion('generate')).toBeVisible(); + await sendPrompt('a tiger'); + await expectCanvasToShow(await generatedUuid()); + await expect.element(editorRegion('edit')).toBeVisible(); }); it('clears the prompt after a successful generation', async () => { - stubFetch({ uuid: 'result' }); - const el = mount(STAGING); - await el.updateComplete; - const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toBe('https://cdn.example.com/result/')); - await el.updateComplete; - expect(input.value).toBe(''); + mount(STAGING); + await sendPrompt('a tiger'); + await expectCanvasToShow(await generatedUuid()); + await expect.element(promptBox()).toHaveValue(''); }); - it('generates via the send button, then the primary commits the result with uc:done', async () => { - stubFetch({ uuid: 'result' }); + it('commits the generated result with uc:done, carrying its url, uuid and UploadcareFile', async () => { const el = mount(STAGING); - await el.updateComplete; - - // Primary is disabled until there is a result to commit. - expect(primaryBtn(el).disabled).toBe(true); - - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => { - expect(canvasUrl(el)).toBe('https://cdn.example.com/result/'); - }); - await el.updateComplete; - - // Now the primary commits the generated result (it never generates). - expect(primaryBtn(el).disabled).toBe(false); const onDone = vi.fn(); el.addEventListener('uc:done', onDone); - clickPrimary(el); - expect(onDone).toHaveBeenCalledTimes(1); - const detail = onDone.mock.calls[0]![0].detail; - expect(detail.url).toBe('https://cdn.example.com/result/'); - expect(detail.file.uuid).toBe('result'); - expect(detail.file.cdnUrl).toBe('https://cdn.example.com/result/'); + + await sendPrompt('a tiger'); + const uuid = await generatedUuid(); + await expectCanvasToShow(uuid); + // The primary commits the result; it never generates. + await clickDone(); + + expect(onDone).toHaveBeenCalledOnce(); + expect(onDone.mock.calls[0]![0].detail).toMatchObject({ + url: `https://cdn.example.com/${uuid}/`, + uuid, + file: { uuid, cdnUrl: `https://cdn.example.com/${uuid}/` }, + }); }); - it('includes the UploadcareFile and its uuid in uc:done after a generation', async () => { - stubFetch({ uuid: 'result-123' }); + it('reports a refused run with uc:error and its message, and keeps the prompt', async () => { + // derivativesInstant again, so it wraps the failure and the error comes on the first poll. + session.use('derivativeFailure', { code: 'content_moderated' }).use('derivativesInstant'); const el = mount(STAGING); - await el.updateComplete; - const onDone = vi.fn(); - el.addEventListener('uc:done', onDone); - - typePrompt(el, 'make it pop'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toBe('https://cdn.example.com/result-123/')); - await el.updateComplete; - - clickPrimary(el); - expect(onDone).toHaveBeenCalledTimes(1); - expect(onDone.mock.calls[0]![0].detail.file.uuid).toBe('result-123'); - expect(onDone.mock.calls[0]![0].detail.uuid).toBe('result-123'); + const onError = vi.fn(); + el.addEventListener('uc:error', onError); + + await sendPrompt('a tiger'); + await expect + .element(page.getByRole('alert')) + .toHaveTextContent("That prompt isn't allowed. Try describing it differently."); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0]![0].detail.error.code).toBe('content_moderated'); + await expect.element(canvasImage()).not.toBeInTheDocument(); + await expect.element(editorRegion('generate')).toBeVisible(); + await expect.element(promptBox()).toHaveValue('a tiger'); }); - it.skip('fires uc:change as the current result appears and clears', async () => { - stubFetch({ uuid: 'result' }); + it('fires uc:change with the result when a run lands, and with null when a new source clears it', async () => { const el = mount(STAGING); - await el.updateComplete; const onChange = vi.fn(); el.addEventListener('uc:change', onChange); - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => { - expect(onChange).toHaveBeenCalledTimes(1); + await sendPrompt('a tiger'); + const uuid = await generatedUuid(); + await expectCanvasToShow(uuid); + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange.mock.calls[0]![0].detail.result).toMatchObject({ + url: `https://cdn.example.com/${uuid}/`, + file: { uuid }, }); - const first = onChange.mock.calls[0]![0].detail; - expect(first.result.url).toBe('https://cdn.example.com/result/'); - expect(first.result.file.uuid).toBe('result'); - // Start over clears the current result -> uc:change with null. - await el.updateComplete; - const history = el.shadowRoot!.querySelector('uc-ai-history')!; - const startOver = history.shadowRoot!.querySelector('.startover__btn') as HTMLButtonElement; - startOver.click(); - await vi.waitFor(() => { - expect(onChange).toHaveBeenCalledTimes(2); - }); - expect(onChange.mock.calls[1]![0].detail.result).toBeNull(); + // A host handing over another image drops the result: that source has no history to resume. + el.sourceUuid = SAMPLE_UUID; + await expectCanvasToShow(SAMPLE_UUID); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange.mock.calls[1]![0].detail).toEqual({ result: null }); }); it('dispatches uc:cancel when the cancel button is clicked', async () => { @@ -123,70 +100,59 @@ describe(' generation', () => { await el.updateComplete; const onCancel = vi.fn(); el.addEventListener('uc:cancel', onCancel); - const footer = el.shadowRoot!.querySelector('uc-ai-footer')!; - (footer.shadowRoot!.querySelector('.btn--ghost') as HTMLButtonElement).click(); - expect(onCancel).toHaveBeenCalledTimes(1); + await userEvent.click(page.getByRole('button', { name: 'Cancel', exact: true })); + expect(onCancel).toHaveBeenCalledOnce(); }); it('keeps the primary disabled and fires no uc:done until a result exists (edit mode with a source)', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); + await expect.element(editorRegion('edit')).toBeVisible(); + await expectCanvasToShow(SAMPLE_UUID); const onDone = vi.fn(); el.addEventListener('uc:done', onDone); // A source image alone is not a result — the primary commits results only. - expect(primaryBtn(el).disabled).toBe(true); - clickPrimary(el); + await expect.element(doneButton()).toBeDisabled(); + await userEvent.click(doneButton(), { force: true }); expect(onDone).not.toHaveBeenCalled(); }); it('does not dispatch uc:done when there is no result (generate mode)', async () => { const el = mount(); - await el.updateComplete; const onDone = vi.fn(); el.addEventListener('uc:done', onDone); - expect(primaryBtn(el).disabled).toBe(true); - primaryBtn(el).click(); + await expect.element(doneButton()).toBeDisabled(); + await userEvent.click(doneButton(), { force: true }); expect(onDone).not.toHaveBeenCalled(); }); it('applies an async secure-delivery resolver to the canvas preview', async () => { - stubFetch({ uuid: 'result' }); const el = mount(STAGING); - el.secureDeliveryProxyUrlResolver = async (url: string) => `https://signed.example/${encodeURIComponent(url)}`; - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - const raw = 'https://cdn.example.com/result/'; - await vi.waitFor(() => expect(canvasUrl(el)).toBe(`https://signed.example/${encodeURIComponent(raw)}`)); + // The signed URL stays on the emulated CDN, so the preview loads instead of reaching for a proxy that doesn't exist. + el.secureDeliveryProxyUrlResolver = async (url: string) => `${url}?token=signed`; + await sendPrompt('a tiger'); + const uuid = await generatedUuid(); + await expect + .element(canvasImage()) + .toHaveAttribute( + 'src', + expect.stringMatching(new RegExp(`^https://cdn\\.example\\.com/${uuid}/.*\\?token=signed$`)), + ); }); it('aborts in-flight generation and shows the new source when source changes', async () => { - // Status hangs until the request is aborted. - stubFetch({ - status: (signal) => - new Promise((_res, rej) => { - signal?.addEventListener('abort', () => rej(new DOMException('Aborted', 'AbortError')), { once: true }); - }), - }); - // Non-UUID-shaped ids keep the CDN preview helper from rewriting the URL, - // so the canvas URL is the bare resolved source. - const el = mount({ ...STAGING, 'source-uuid': 'first-uuid' }); - await el.updateComplete; - - typePrompt(el, 'try'); - await el.updateComplete; - clickSend(el); + // The status poll never answers, so the job is still running when the source changes. + session.on('GET /derivative/status/', () => new Promise(() => {})); + const el = mount({ ...STAGING, 'source-uuid': SAMPLE_UUID }); + await sendPrompt('try'); + // The prompt box locks while the run is in flight. + await expect.element(promptBox()).toBeDisabled(); // Change source mid-flight — this aborts the in-flight generation. - el.sourceUuid = 'second-uuid'; - await el.updateComplete; + el.sourceUuid = SECOND_SAMPLE_UUID; // After the abort, the displayed image should be the new source (no result override). - await vi.waitFor(() => { - expect(canvasUrl(el)).toBe('https://cdn.example.com/second-uuid/'); - }); + await expectCanvasToShow(SECOND_SAMPLE_UUID); + await expect.element(promptBox()).toBeEnabled(); }); }); diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index fc8f358..2297890 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -1,90 +1,39 @@ -import { afterEach } from 'vitest'; -import { page } from 'vitest/browser'; +import { ADAPTIVE_IMAGE_UUID, DEMO_FILES, DEMO_IMAGE_UUID } from '@uploadcare/api-emulator'; +import { afterEach, beforeEach, expect } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; import type { UcAiImageEditor as UcAiImageEditorType } from '../../src/index'; import { UcAiImageEditor } from '../../src/index'; +import { session } from '../emulator'; import { cleanup } from '../test-renderer'; /** * Shared setup for the `` browser tests, which are split by * subject (mounting, generation, history, layout, …) and all need the same - * three things: the elements registered, a stubbed Upload API, and a mounted - * editor. Importing this module registers the custom elements and the teardown - * for whichever test file pulls it in. + * three things: the elements registered, the Upload API (the emulator, see + * `tests/emulator.ts`), and a mounted editor. Importing this module registers + * the custom elements and the teardown for whichever test file pulls it in. */ export { UcAiImageEditor }; export type { UcAiImageEditorType }; -let restoreFetch: (() => void) | null = null; +/** + * Generations finish on their first status poll, so a test doesn't sit through the editor's 1.5s interval. + * + * Order matters: a preset or `session.on` answers before the ones registered earlier, and derivativesInstant only + * speeds up the scenarios registered *before* it (see the emulator README's preset table). A test that adds its own + * status scenario, `derivativeFailure` say, applies derivativesInstant again after it to keep instant answers. + */ +beforeEach(() => { + session.use('derivativesInstant'); +}); afterEach(() => { - restoreFetch?.(); - restoreFetch = null; // Persisted history is namespaced by pubkey in localStorage; clear it so a // seeded/recorded lineage in one test can't leak into the next. localStorage.clear(); cleanup(); }); -export const jsonResponse = (body: unknown): Response => - new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); - -/** A finished job frame, as `derivative/status/` returns it. */ -export const successFrame = (uuid: string): Record => ({ - status: 'success', - uuid, - file_id: uuid, - size: 1, - done: 1, - total: 1, - original_filename: 'generated.png', - filename: 'generated.png', - mime_type: 'image/png', - is_image: true, - is_stored: false, - is_ready: true, - image_info: null, - video_info: null, - content_info: null, - metadata: {}, -}); - -/** - * Swap `globalThis.fetch` for the rest of the test. The provider binds - * `globalThis.fetch` at construction, so install a stub BEFORE setting `pubkey`. - */ -export function installFetch(handler: typeof fetch): void { - // Put back a stub this test already installed, so `real` is always the real - // one — otherwise teardown would restore a stub and every later test in the - // file would run against it. - restoreFetch?.(); - const real = globalThis.fetch; - globalThis.fetch = handler; - restoreFetch = () => { - globalThis.fetch = real; - }; -} - -/** - * Drive the internal UploadcareDerivativeApi: the generate POST returns a job, - * the status GET returns success (or a custom handler). Captures the POST - * bodies for assertions. - */ -export function stubFetch(opts: { uuid?: string; status?: (signal?: AbortSignal) => Promise } = {}): { - generateBodies: Array>; -} { - const generateBodies: Array> = []; - installFetch((async (_input: RequestInfo | URL, init?: RequestInit) => { - const method = (init?.method ?? 'GET').toUpperCase(); - if (method === 'POST') { - generateBodies.push(JSON.parse((init?.body as string) ?? '{}')); - return jsonResponse({ type: 'job', job_id: 'job-1' }); - } - if (opts.status) return opts.status(init?.signal ?? undefined); - return jsonResponse(successFrame(opts.uuid ?? 'result')); - }) as typeof fetch); - return { generateBodies }; -} - export function mount(attrs: Record = {}): UcAiImageEditorType { const el = document.createElement('uc-ai-image-editor') as UcAiImageEditorType; for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); @@ -100,35 +49,58 @@ export function mount(attrs: Record = {}): UcAiImageEditorType { export const STAGING = { pubkey: 'demopublickey', 'cdn-cname': 'https://cdn.example.com' }; -export const SAMPLE_UUID = '11111111-2222-3333-4444-555555555555'; +/** An image every fresh emulator session already holds: a stored 136×150 JPEG (not square). */ +export const SAMPLE_UUID = DEMO_IMAGE_UUID; -export function typePrompt(el: UcAiImageEditorType, value: string): void { - const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; - input.value = value; - input.dispatchEvent(new Event('input', { bubbles: true })); -} +/** A second seeded image, for a test that switches sources: the same bytes as `SAMPLE_UUID` under another uuid. */ +export const SECOND_SAMPLE_UUID = ADAPTIVE_IMAGE_UUID; -export function primaryBtn(el: UcAiImageEditorType): HTMLButtonElement { - return el.shadowRoot!.querySelector('uc-ai-footer')!.shadowRoot!.querySelector('.btn--primary') as HTMLButtonElement; -} +/** The file the test's run produced: the one in the session that isn't a seeded `DEMO_FILES` image. */ +export const resultUuid = (): string | undefined => + [...session.files.keys()].find((uuid) => !DEMO_FILES.includes(uuid)); -/** Footer primary commits the result (fires uc:done). */ -export function clickPrimary(el: UcAiImageEditorType): void { - primaryBtn(el).click(); -} +/* + * The editor as a user finds it: by role and accessible name, through its shadow roots (locators pierce open ones). + * The names are the English strings, written out so a changed label fails here rather than following the source. + */ -/** The prompt row's send button triggers generation. */ -export function clickSend(el: UcAiImageEditorType): void { - const promptRow = el.shadowRoot!.querySelector('uc-ai-prompt-row')!; - (promptRow.shadowRoot!.querySelector('.send') as HTMLButtonElement).click(); -} +/** The prompt box, named after the mode's placeholder. */ +export const promptBox = () => page.getByRole('textbox'); + +/** The prompt row's send button. It is hidden until the prompt holds text. */ +export const sendButton = () => page.getByRole('button', { name: 'Generate', exact: true }); + +/** The footer's primary, which commits the result (fires `uc:done`). */ +export const doneButton = () => page.getByRole('button', { name: 'Done', exact: true }); + +/** The editor's region, named after its mode. */ +export const editorRegion = (mode: 'generate' | 'edit') => + page.getByRole('region', { name: mode === 'edit' ? 'Edit image' : 'Generate image', exact: true }); -export const historyEl = (el: UcAiImageEditorType) => - el.shadowRoot!.querySelector('uc-ai-history') as (HTMLElement & { entries: unknown[] }) | null; +/** The canvas's picture. Thumbnails and preloads are hidden from the accessibility tree, so it is the only image. */ +export const canvasImage = () => page.getByRole('img'); -export const canvasUrl = (el: UcAiImageEditorType): string | null => - (el.shadowRoot!.querySelector('uc-ai-canvas') as unknown as { url: string | null }).url; +/** The result chips in the history strip, each named after the prompt that made it. */ +export const historyChips = () => page.getByRole('toolbar', { name: 'Recent prompts' }).getByRole('button'); + +/** The history chip of the run that `prompt` made. */ +export const historyChip = (prompt: string) => + page.getByRole('toolbar', { name: 'Recent prompts' }).getByRole('button', { name: prompt, exact: true }); + +export const clickDone = () => userEvent.click(doneButton()); + +/** Types the prompt and sends it. */ +export async function sendPrompt(value: string): Promise { + await userEvent.fill(promptBox(), value); + await userEvent.click(sendButton()); +} + +/** Waits for the run's result to land in the session and answers its uuid. */ +export async function generatedUuid(): Promise { + await expect.poll(resultUuid).toBeDefined(); + return resultUuid()!; +} -/** The derived editor mode, read off the prompt-row child the editor feeds. */ -export const editorMode = (el: UcAiImageEditorType): string => - (el.shadowRoot!.querySelector('uc-ai-prompt-row') as unknown as { mode: string }).mode; +/** Waits for the canvas to show the CDN rendition of `uuid`. */ +export const expectCanvasToShow = (uuid: string) => + expect.element(canvasImage()).toHaveAttribute('src', expect.stringContaining(`https://cdn.example.com/${uuid}/`)); diff --git a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx index b0fe7d4..6634eae 100644 --- a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx @@ -1,15 +1,15 @@ import type { UploadcareFile } from '@uploadcare/upload-client'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { - canvasUrl, - clickSend, - editorMode, - historyEl, + canvasImage, + editorRegion, + expectCanvasToShow, + historyChips, mount, SAMPLE_UUID, + SECOND_SAMPLE_UUID, STAGING, - stubFetch, - typePrompt, + sendPrompt, } from './harness'; /** @@ -18,32 +18,17 @@ import { */ describe(' history', () => { it('populates the history strip after a successful generation', async () => { - stubFetch(); - const el = mount(STAGING); - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => { - expect(historyEl(el)?.entries.length).toBe(1); - }); + mount(STAGING); + await sendPrompt('a tiger'); + await expect.poll(() => historyChips().elements()).toHaveLength(1); }); - it('shows the generated result as a selectable history chip', async () => { - stubFetch(); - const el = mount(STAGING); - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(historyEl(el)?.entries.length).toBe(1)); - await el.updateComplete; - - const history = historyEl(el)!; - const chips = history.shadowRoot!.querySelectorAll('.chip'); - expect(chips.length).toBe(1); + it('shows the generated result as a selected history chip named after its prompt', async () => { + mount(STAGING); + await sendPrompt('a tiger'); // The current result's chip is marked selected. - expect(history.shadowRoot!.querySelector('.chip--selected')).toBeTruthy(); + await expect.element(historyChips().first()).toHaveAccessibleName('a tiger'); + await expect.element(historyChips().first()).toHaveAttribute('aria-pressed', 'true'); }); it('shows and persists the original image in the history strip when editing (plugin path)', async () => { @@ -51,16 +36,14 @@ describe(' history', () => { const el = mount(STAGING); // The plugin hands the editor the source's file info directly. el.sourceFileInfo = { uuid: SAMPLE_UUID } as UploadcareFile; - await el.updateComplete; - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); + await expect.element(editorRegion('edit')).toBeVisible(); // The strip mounts with the original as its base entry — the starting point - // you can revert to — once the source's display url has resolved. - await vi.waitFor(() => { - const strip = historyEl(el) as (HTMLElement & { entries: Array<{ file: { uuid: string } }> }) | null; - expect(strip).not.toBeNull(); - expect(strip!.entries.some((entry) => entry.file.uuid === SAMPLE_UUID)).toBe(true); - }); + // you can revert to — once the source's display url has resolved: one + // selected chip showing the source's thumbnail. + await expect.poll(() => historyChips().elements()).toHaveLength(1); + await expect.element(historyChips().first()).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(() => historyChips().element().querySelector('img')?.src).toContain(`/${SAMPLE_UUID}/`); // …and the original is persisted as a root node so it survives a reload. const stored = JSON.parse(localStorage.getItem(`uc-ai-image-editor/history/${STAGING.pubkey}`) ?? '{}'); @@ -68,7 +51,8 @@ describe(' history', () => { }); it('resumes on the latest result (not the original) when reopening a lineage', async () => { - const LATEST = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + // A seeded image, so the CDN serves it and the canvas can show it. + const LATEST = SECOND_SAMPLE_UUID; const key = `uc-ai-image-editor/history/${STAGING.pubkey}`; // Seed a prior edit of SAMPLE_UUID into storage (a result whose parent is it). localStorage.setItem( @@ -89,45 +73,10 @@ describe(' history', () => { const el = mount(STAGING); el.sourceFileInfo = { uuid: SAMPLE_UUID } as UploadcareFile; - await el.updateComplete; - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); + await expect.element(editorRegion('edit')).toBeVisible(); // The canvas (and shimmer) resume on the latest result, not the original. - await vi.waitFor(() => expect(canvasUrl(el)).toContain(LATEST)); - expect(canvasUrl(el)).not.toContain(SAMPLE_UUID); - }); - - it('does not render Start over in edit mode opened with a source (uploader AI-edit)', async () => { - const el = mount(STAGING); - el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); - await el.updateComplete; - - // Editing an existing image has nothing to "start over" to — the affordance - // is absent (and, with no generation history yet, the strip isn't mounted). - const history = historyEl(el); - const startOver = history?.shadowRoot?.querySelector('.startover__btn') ?? null; - expect(startOver).toBeNull(); - }); - - it.skip('returns to generate mode after Start over (from the history strip)', async () => { - stubFetch({ uuid: 'result' }); - const el = mount(STAGING); - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); - await el.updateComplete; - - const history = historyEl(el)!; - const startOver = history.shadowRoot!.querySelector('.startover__btn') as HTMLButtonElement; - startOver.click(); - await el.updateComplete; - expect(editorMode(el)).toBe('generate'); - expect(canvasUrl(el)).toBeNull(); - // Start over also clears the prompt history (the strip unmounts). - await vi.waitFor(() => expect(historyEl(el)).toBeNull()); + await expectCanvasToShow(LATEST); + await expect.element(canvasImage()).not.toHaveAttribute('src', expect.stringContaining(SAMPLE_UUID)); }); }); diff --git a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx index a605d01..75d1e30 100644 --- a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx @@ -1,210 +1,204 @@ -import { describe, expect, it, vi } from 'vitest'; -import { canvasUrl, clickSend, editorMode, mount, SAMPLE_UUID, STAGING, stubFetch, typePrompt } from './harness'; +import { describe, expect, it } from 'vitest'; +import { type Locator, page, userEvent } from 'vitest/browser'; +import type { UcAiImageEditorType } from './harness'; +import { + doneButton, + editorRegion, + expectCanvasToShow, + mount, + promptBox, + SAMPLE_UUID, + STAGING, + sendPrompt, +} from './harness'; + +const historyStrip = () => page.getByRole('toolbar', { name: 'Recent prompts' }); + +const rectOf = (locator: Locator) => locator.element().getBoundingClientRect(); + +/** The canvas fills the stage, so its box is the image area the composer and history float over or dock beside. */ +const canvasRect = (el: UcAiImageEditorType) => el.shadowRoot!.querySelector('uc-ai-canvas')!.getBoundingClientRect(); + +/** Where `target` sits against the canvas: wholly above or below it, or over its top or bottom half. */ +function sideOfCanvas(el: UcAiImageEditorType, target: Locator): 'above' | 'below' | 'over-top' | 'over-bottom' { + const box = rectOf(target); + const canvas = canvasRect(el); + if (box.bottom <= canvas.top + 1) return 'above'; + if (box.top >= canvas.bottom - 1) return 'below'; + return (box.top + box.bottom) / 2 < (canvas.top + canvas.bottom) / 2 ? 'over-top' : 'over-bottom'; +} + +/** Where `target` sits against the prompt box: wholly above or below it, or overlapping it. */ +function sideOfPrompt(target: Locator): 'above' | 'below' | 'overlapping' { + const box = rectOf(target); + const prompt = rectOf(promptBox()); + if (box.bottom <= prompt.top) return 'above'; + if (box.top >= prompt.bottom) return 'below'; + return 'overlapping'; +} + +/** Runs one generation so the history strip mounts. */ +async function generateOnce(): Promise { + await sendPrompt('a tiger'); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(historyStrip()).toBeVisible(); +} + +/** Parks the pointer in the canvas's top-left corner, away from the composer and the bottom edge. */ +const parkPointer = (el: UcAiImageEditorType) => + userEvent.hover(page.elementLocator(el.shadowRoot!.querySelector('uc-ai-canvas')!), { + position: { x: 2, y: 2 }, + force: true, + }); + +/** How far the prompt box's top sits below the canvas's bottom edge: about 20px docked and shown, about 110px hidden. */ +const promptDrop = (el: UcAiImageEditorType) => rectOf(promptBox()).top - canvasRect(el).bottom; + +/** + * Waits for the editor's CSS transitions to finish: two frames for a style change to start its transition, then every + * running animation in the editor's shadow root. A negative layout check made before then could pass on the old frame. + */ +async function transitionsDone(el: UcAiImageEditorType): Promise { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await Promise.all(el.shadowRoot!.getAnimations().map((animation) => animation.finished)); +} /** * Where the composer, history strip and toolbar sit for a given placement, and - * how auto-hide docking changes that. + * how auto-hide docking changes that, measured on the rendered boxes. */ describe(' layout', () => { - it('places the composer per composer-placement + canvas-fit', async () => { - const el = mount(STAGING); - el.canvasFit = 'full'; - el.composerPlacement = 'bottom'; - await el.updateComplete; - const shell = el.shadowRoot!.querySelector('.shell')!; - const stage = () => shell.querySelector('.stage')!; - const composer = () => shell.querySelector('.composer')!; - - // canvas-fit full → composer floats over the full canvas (inside the stage). - expect(composer().classList.contains('composer--overlay-bottom')).toBe(true); - expect(stage().contains(composer())).toBe(true); - - el.composerPlacement = 'top'; - await el.updateComplete; - expect(composer().classList.contains('composer--overlay-top')).toBe(true); - expect(stage().contains(composer())).toBe(true); - - // canvas-fit available → composer docked outside the stage so the canvas - // shrinks. `top` sits before the stage… - el.canvasFit = 'available'; - el.composerPlacement = 'top'; - await el.updateComplete; - expect(composer().classList.contains('composer--docked')).toBe(true); - expect(stage().contains(composer())).toBe(false); - let kids = [...shell.children]; - expect(kids.indexOf(composer())).toBeLessThan(kids.indexOf(stage())); - - // …`bottom` after it. - el.composerPlacement = 'bottom'; - await el.updateComplete; - kids = [...shell.children]; - expect(kids.indexOf(composer())).toBeGreaterThan(kids.indexOf(stage())); - }); - - it('defaults to a docked composer at the bottom', async () => { + // canvas-fit full floats the composer over the canvas; available docks it outside, so the canvas shrinks. + it.each([ + ['full', 'bottom', 'over-bottom'], + ['full', 'top', 'over-top'], + ['available', 'top', 'above'], + ['available', 'bottom', 'below'], + ] as const)( + 'with canvas-fit %s and composer-placement %s, puts the composer %s the canvas', + async (fit, placement, side) => { + const el = mount(STAGING); + el.canvasFit = fit; + el.composerPlacement = placement; + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe(side); + }, + ); + + it('defaults to a docked composer and a toolbar, both below the canvas', async () => { const el = mount(STAGING); - await el.updateComplete; - const composer = el.shadowRoot!.querySelector('.composer')!; expect(el.composerPlacement).toBe('bottom'); expect(el.canvasFit).toBe('available'); - expect(composer.classList.contains('composer--docked-bottom')).toBe(true); + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('below'); + await expect.poll(() => sideOfCanvas(el, doneButton())).toBe('below'); }); - it('places the toolbar per toolbar-placement', async () => { + // An unknown value falls back to the default (bottom), like the other placement enums. + it.each([ + ['bottom', 'below'], + ['top', 'above'], + ['junk', 'below'], + ] as const)('with toolbar-placement %s, puts the toolbar %s the canvas', async (placement, side) => { const el = mount(STAGING); - await el.updateComplete; - const shell = el.shadowRoot!.querySelector('.shell')!; - const footer = () => shell.querySelector('uc-ai-footer')!; - const stage = () => shell.querySelector('.stage')!; - - // Default: toolbar at the bottom (after the stage). - let kids = [...shell.children]; - expect(kids.indexOf(footer())).toBeGreaterThan(kids.indexOf(stage())); - - // Top: toolbar before the stage. - el.toolbarPlacement = 'top'; - await el.updateComplete; - kids = [...shell.children]; - expect(kids.indexOf(footer())).toBeLessThan(kids.indexOf(stage())); - - // None: no toolbar at all; the stage still renders. - el.toolbarPlacement = 'none'; - await el.updateComplete; - expect(shell.querySelector('uc-ai-footer')).toBeNull(); - expect(stage()).not.toBeNull(); - - // An unknown value falls back to the default (bottom), like the other - // placement enums. - el.toolbarPlacement = 'junk' as never; - await el.updateComplete; - kids = [...shell.children]; - expect(kids.indexOf(footer())).toBeGreaterThan(kids.indexOf(stage())); + el.toolbarPlacement = placement as UcAiImageEditorType['toolbarPlacement']; + await expect.poll(() => sideOfCanvas(el, doneButton())).toBe(side); }); - it('places the history strip per history-placement (overlay composer)', async () => { - stubFetch({ uuid: 'r' }); + it('renders no toolbar with toolbar-placement none, and still renders the canvas', async () => { const el = mount(STAGING); - el.canvasFit = 'full'; // relative history rides an overlay composer - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); // a result mounts the strip - await el.updateComplete; - - const shell = el.shadowRoot!.querySelector('.shell')!; - const history = () => shell.querySelector('uc-ai-history')!; - const composer = () => shell.querySelector('.composer')!; - const stage = () => shell.querySelector('.stage')!; - const promptRow = () => composer().querySelector('uc-ai-prompt-row')!; - const content = () => composer().querySelector('.composer__content')!; - - // composer-above (default): inside the composer, before the prompt row. - expect(composer().contains(history())).toBe(true); - let kids = [...content().children]; - expect(kids.indexOf(history())).toBeLessThan(kids.indexOf(promptRow())); - - // composer-below: inside the composer, after the prompt row. - el.historyPlacement = 'composer-below'; - await el.updateComplete; - kids = [...content().children]; - expect(kids.indexOf(history())).toBeGreaterThan(kids.indexOf(promptRow())); - - // canvas-top: pinned in the stage, not inside the composer. - el.historyPlacement = 'canvas-top'; - await el.updateComplete; - expect(composer().contains(history())).toBe(false); - const pinned = stage().querySelector('.history-pinned')!; - expect(pinned.classList.contains('history-pinned--canvas-top')).toBe(true); - expect(pinned.contains(history())).toBe(true); + el.toolbarPlacement = 'none'; + await expect.element(doneButton()).not.toBeInTheDocument(); + expect(canvasRect(el).height).toBeGreaterThan(0); }); + // An overlay composer (canvas-fit full), which relative history placements ride. + it.each([ + ['composer-above', 'over-bottom', 'above'], + ['composer-below', 'over-bottom', 'below'], + ['canvas-top', 'over-top', 'above'], + ] as const)( + 'with history-placement %s, puts the history strip %s the canvas and %s the prompt', + async (placement, canvasSide, promptSide) => { + const el = mount(STAGING); + el.canvasFit = 'full'; + el.historyPlacement = placement; + await generateOnce(); + await expect.poll(() => sideOfCanvas(el, historyStrip())).toBe(canvasSide); + await expect.poll(() => sideOfPrompt(historyStrip())).toBe(promptSide); + }, + ); + it('pins the history over the canvas when the composer is docked-out', async () => { - stubFetch({ uuid: 'r' }); const el = mount(STAGING); // docked at the bottom by default - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); - await el.updateComplete; - - const shell = el.shadowRoot!.querySelector('.shell')!; - const stage = shell.querySelector('.stage')!; - const composer = shell.querySelector('.composer')!; - const history = shell.querySelector('uc-ai-history')!; - - // The composer (prompt) is docked outside the canvas; the history chips - // float over the canvas (pinned), not inside the docked composer. - expect(stage.contains(composer)).toBe(false); - expect(composer.contains(history)).toBe(false); - const pinned = stage.querySelector('.history-pinned')!; - expect(pinned.contains(history)).toBe(true); - expect(pinned.classList.contains('history-pinned--canvas-bottom')).toBe(true); + await generateOnce(); + + // The composer (prompt) is docked below the canvas; the history chips + // float over the canvas's bottom edge, not inside the docked composer. + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('below'); + await expect.poll(() => sideOfCanvas(el, historyStrip())).toBe('over-bottom'); }); - it('docks the overlay composer (renders the dock-hotzone) when composer-auto-hide is on', async () => { + it('slides an overlay composer off the canvas under composer-auto-hide and raises it at the edge', async () => { const el = mount(STAGING); el.canvasFit = 'full'; // an overlay (floating) composer el.composerAutoHide = true; el.sourceUuid = SAMPLE_UUID; // edit mode + an image to dock against - await el.updateComplete; - await vi.waitFor(() => expect(canvasUrl(el)).toBeTruthy()); - await el.updateComplete; + await expectCanvasToShow(SAMPLE_UUID); // The CSS docking keys off this reflected host attribute. - expect(el.hasAttribute('composer-auto-hide')).toBe(true); - - // A pointer catch-strip is rendered on the composer's edge (bottom by default). - const hotzone = el.shadowRoot!.querySelector('.dock-hotzone'); - expect(hotzone).toBeTruthy(); - expect(hotzone!.classList.contains('dock-hotzone--bottom')).toBe(true); + await expect.element(el).toHaveAttribute('composer-auto-hide'); + + // Docked: the prompt slides past the canvas's bottom edge, leaving a peek. + await parkPointer(el); + await expect.poll(() => -promptDrop(el)).toBeLessThan(40); + + // The pointer nearing the bottom edge (beside the composer, not on it) raises it wholly back over the canvas. + const canvas = canvasRect(el); + await userEvent.hover(page.elementLocator(el.shadowRoot!.querySelector('uc-ai-canvas')!), { + position: { x: 2, y: canvas.height - 10 }, + force: true, + }); + await expect.poll(() => rectOf(promptBox()).bottom - canvasRect(el).bottom).toBeLessThan(0); }); - it('does not dock (no hotzone) when auto-hide is disabled', async () => { + it('does not dock when auto-hide is disabled', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - await vi.waitFor(() => expect(canvasUrl(el)).toBeTruthy()); + await expectCanvasToShow(SAMPLE_UUID); + await parkPointer(el); + + // Disabled (default): the docked prompt stays shown right below the canvas… + await expect.element(el).not.toHaveAttribute('composer-auto-hide'); + await transitionsDone(el); + expect(sideOfCanvas(el, promptBox())).toBe('below'); + expect(promptDrop(el)).toBeLessThan(40); - // Disabled (default): no dock-hotzone, no reserved space. - expect(el.shadowRoot!.querySelector('.dock-hotzone')).toBeNull(); - expect(el.hasAttribute('composer-auto-hide')).toBe(false); + // …and an overlay one stays wholly over it. + el.canvasFit = 'full'; + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('over-bottom'); + await transitionsDone(el); + expect(rectOf(promptBox()).bottom - canvasRect(el).bottom).toBeLessThan(0); }); - it('keeps a docked composer docked under auto-hide (no overlay, no hotzone), independent of canvas-fit', async () => { + it('keeps a docked composer docked under auto-hide, independent of canvas-fit', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; el.composerAutoHide = true; el.composerPlacement = 'bottom'; el.canvasFit = 'available'; - await el.updateComplete; - await vi.waitFor(() => expect(canvasUrl(el)).toBeTruthy()); - await el.updateComplete; + await expectCanvasToShow(SAMPLE_UUID); + await parkPointer(el); // Auto-hide is orthogonal to canvas-fit: it does NOT force `full`. A docked - // composer stays docked and hides by collapsing in place (so no overlay - // positioning and no dock-hotzone — that chrome is overlay-only). + // composer stays below the canvas and hides by collapsing in place: the + // prompt slides down inside its reserved slot instead of over the canvas… expect(el.canvasFit).toBe('available'); - const composer = el.shadowRoot!.querySelector('.composer')!; - expect(composer.classList.contains('composer--docked')).toBe(true); - expect(composer.classList.contains('composer--overlay')).toBe(false); - expect(el.shadowRoot!.querySelector('.dock-hotzone')).toBeNull(); - }); + await expect.poll(() => promptDrop(el)).toBeGreaterThan(60); + const canvasHeight = canvasRect(el).height; - it('docks an overlay composer with a hotzone when auto-hide is on', async () => { - const el = mount(STAGING); - el.sourceUuid = SAMPLE_UUID; - el.composerAutoHide = true; - el.canvasFit = 'full'; // floating composer - await el.updateComplete; - await vi.waitFor(() => expect(canvasUrl(el)).toBeTruthy()); - await el.updateComplete; - - const composer = el.shadowRoot!.querySelector('.composer')!; - expect(composer.classList.contains('composer--overlay')).toBe(true); - const hotzone = el.shadowRoot!.querySelector('.dock-hotzone'); - expect(hotzone).toBeTruthy(); - expect(hotzone!.classList.contains('dock-hotzone--bottom')).toBe(true); + // …and hovering it raises it in the same slot, so the canvas keeps its size. + await userEvent.hover(promptBox(), { force: true }); + await expect.poll(() => promptDrop(el)).toBeLessThan(40); + expect(canvasRect(el).height).toBe(canvasHeight); }); }); diff --git a/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx b/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx index fb1d4bd..f99c88e 100644 --- a/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx @@ -1,6 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; -import { MODES } from '../../src/internal'; -import { editorMode, mount, SAMPLE_UUID, STAGING, UcAiImageEditor } from './harness'; +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { doneButton, editorRegion, mount, promptBox, SAMPLE_UUID, STAGING, UcAiImageEditor } from './harness'; + +/** The quick-prompt chips, by their labels. */ +const quickPrompts = () => page.getByRole('toolbar', { name: 'Quick prompts' }).getByRole('button'); +const quickPromptLabels = () => + quickPrompts() + .elements() + .map((chip) => chip.textContent!.trim()); /** * What the element renders when it mounts, and the configuration that decides @@ -11,36 +18,23 @@ describe(' mounting and configuration', () => { expect(customElements.get('uc-ai-image-editor')).toBe(UcAiImageEditor); }); - it('mounts in generate mode and renders the canvas + prompt + chips + footer (no history strip yet)', async () => { - const el = mount(); - await el.updateComplete; - const root = el.shadowRoot; - expect(root?.querySelector('uc-ai-canvas')).toBeTruthy(); - expect(root?.querySelector('uc-ai-prompt-row')).toBeTruthy(); - expect(root?.querySelector('uc-ai-chips')).toBeTruthy(); - expect(root?.querySelector('uc-ai-footer')).toBeTruthy(); + it('mounts in generate mode with the prompt, quick prompts and footer (no history strip yet)', async () => { + mount(); + await expect.element(editorRegion('generate')).toBeVisible(); + await expect.element(promptBox()).toHaveAccessibleName('Create image...'); + await expect.element(page.getByRole('toolbar', { name: 'Quick prompts' })).toBeVisible(); + await expect.element(page.getByRole('button', { name: 'Cancel', exact: true })).toBeVisible(); + await expect.element(doneButton()).toBeVisible(); // The history strip only mounts once there are results (or in edit mode). - expect(root?.querySelector('uc-ai-history')).toBeNull(); - expect(editorMode(el)).toBe('generate'); + await expect.element(page.getByRole('toolbar', { name: 'Recent prompts' })).not.toBeInTheDocument(); }); it('derives edit mode from a source uuid, generate mode without one', async () => { const el = mount(); - await el.updateComplete; - expect(editorMode(el)).toBe('generate'); + await expect.element(editorRegion('generate')).toBeVisible(); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); - }); - - it('updates internal prompt state when the user types in the prompt input', async () => { - const el = mount(); - await el.updateComplete; - const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; - input.value = 'a tiger'; - input.dispatchEvent(new Event('input', { bubbles: true })); - await el.updateComplete; - expect(input.value).toBe('a tiger'); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(promptBox()).toHaveAccessibleName('Edit image...'); }); it('renders configurable prompt presets per mode', async () => { @@ -52,52 +46,39 @@ describe(' mounting and configuration', () => { ], edit: [{ label: 'Enhance', prompt: 'Enhance it' }], }; - await el.updateComplete; - - const chipLabels = async (): Promise => { - const chips = el.shadowRoot!.querySelector('uc-ai-chips') as unknown as { - updateComplete: Promise; - shadowRoot: ShadowRoot; - } | null; - await chips?.updateComplete; - return [...(chips?.shadowRoot.querySelectorAll('.chip') ?? [])].map((c) => c.textContent!.trim()); - }; // generate mode uses presets.generate… - expect(await chipLabels()).toEqual(['Logo', 'Sticker']); + await expect.poll(quickPromptLabels).toEqual(['Logo', 'Sticker']); // …and edit mode uses presets.edit. el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(await chipLabels()).toEqual(['Enhance']); + await expect.poll(quickPromptLabels).toEqual(['Enhance']); }); it('falls back to built-in presets for modes left out of the presets map', async () => { const el = mount(); el.presets = { edit: [{ label: 'Enhance', prompt: 'Enhance it' }] }; // generate omitted - await el.updateComplete; - const chips = el.shadowRoot!.querySelector('uc-ai-chips') as unknown as { shadowRoot: ShadowRoot }; - expect(chips.shadowRoot.querySelectorAll('.chip').length).toBe(MODES.generate.presets.length); + await expect + .poll(quickPromptLabels) + .toEqual(['Portrait', 'Cinematic', 'Product', 'Landscape', 'Illustration', '3D character']); }); it('hides the chips toolbar when a mode preset set is empty', async () => { const el = mount(); + await expect.element(page.getByRole('toolbar', { name: 'Quick prompts' })).toBeVisible(); el.presets = { generate: [] }; - await el.updateComplete; - expect(el.shadowRoot!.querySelector('uc-ai-chips')).toBeNull(); + await expect.element(page.getByRole('toolbar', { name: 'Quick prompts' })).not.toBeInTheDocument(); }); it('resolves locale strings from localeName + locale-keyed localeDefinitionOverride', async () => { const el = mount(STAGING); - const cancelLabel = () => el.shadowRoot!.querySelector('uc-ai-footer')!.getAttribute('cancel-label'); // Override is keyed by locale name (same shape as the uploader's config). el.localeDefinitionOverride = { en: { 'ai-image-editor-cancel': 'Dismiss' } }; - await el.updateComplete; - await vi.waitFor(() => expect(cancelLabel()).toBe('Dismiss')); + await expect.element(page.getByRole('button', { name: 'Dismiss', exact: true })).toBeVisible(); // Switching the active locale lazy-loads that locale's built-in strings. el.localeName = 'de'; - await vi.waitFor(() => expect(cancelLabel()).toBe('Abbrechen')); + await expect.element(page.getByRole('button', { name: 'Abbrechen', exact: true })).toBeVisible(); }); }); diff --git a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx index 7b21ec1..1ebe0c6 100644 --- a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx @@ -1,35 +1,47 @@ import { describe, expect, it, vi } from 'vitest'; -import { delay } from '../test-renderer'; import type { UcAiImageEditorType } from './harness'; -import { canvasUrl, clickSend, mount, SAMPLE_UUID, STAGING, stubFetch, typePrompt } from './harness'; +import { canvasImage, editorRegion, mount, STAGING, sendPrompt } from './harness'; + +/** + * Resolves once a ResizeObserver created now has reported `target`'s current size. Observers are notified in creation + * order, so every older one (the host's own) has already handled that layout. + */ +const afterResizeObservers = (target: Element) => + new Promise((resolve) => { + const observer = new ResizeObserver(() => { + observer.disconnect(); + resolve(); + }); + observer.observe(target); + }); /** How the host sizes itself: fill vs content, the clamp, and the CDN rendition it picks. */ describe(' sizing', () => { - const stageEl = (el: UcAiImageEditorType) => el.shadowRoot!.querySelector('.stage') as HTMLElement; + /** The canvas fills the stage, so its box is the stage's. */ + const canvasEl = (el: UcAiImageEditorType) => + el.shadowRoot!.querySelector('uc-ai-canvas') as HTMLElement & { + updateComplete: Promise; + }; it('defaults to fill and reflects the property to the attribute', async () => { const el = mount(STAGING); - await el.updateComplete; expect(el.sizing).toBe('fill'); el.sizing = 'content'; - await el.updateComplete; - expect(el.getAttribute('sizing')).toBe('content'); + await expect.element(el).toHaveAttribute('sizing', 'content'); }); it('keeps an unsized fill host from collapsing (min-height fallback)', async () => { const el = mount(STAGING); - await el.updateComplete; - expect(getComputedStyle(el).minHeight).toBe('480px'); + await expect.element(el).toHaveStyle({ minHeight: '480px' }); // The mode is CSS-driven, so an unknown value behaves as fill too. el.sizing = 'junk' as never; - await el.updateComplete; - expect(getComputedStyle(el).minHeight).toBe('480px'); + await expect.element(el).toHaveAttribute('sizing', 'junk'); + expect(el).toHaveStyle({ minHeight: '480px' }); // Content mode derives its own height instead of guarding a given one. el.sizing = 'content'; - await el.updateComplete; - expect(getComputedStyle(el).minHeight).not.toBe('480px'); + await expect.element(el).not.toHaveStyle({ minHeight: '480px' }); // Plain consumer CSS always beats the :host fallback (shadow-cascade // rule: outer-tree declarations win over :host, specificity aside). @@ -38,8 +50,7 @@ describe(' sizing', () => { consumerCss.textContent = 'uc-ai-image-editor { min-height: 100px; }'; document.head.append(consumerCss); try { - await el.updateComplete; - expect(getComputedStyle(el).minHeight).toBe('100px'); + await expect.element(el).toHaveStyle({ minHeight: '100px' }); } finally { consumerCss.remove(); } @@ -47,20 +58,18 @@ describe(' sizing', () => { it('derives its height from the selected ratio at the given width in content mode', async () => { const el = mount({ ...STAGING, sizing: 'content', 'aspect-ratios': '1:1', style: 'width: 640px' }); - await el.updateComplete; // The stage expresses the frame fit exactly: (640 - 40) / 1 + 40 = 640. - await vi.waitFor(() => expect(stageEl(el).getBoundingClientRect().height).toBeCloseTo(640, 0)); + await expect.poll(() => canvasEl(el).getBoundingClientRect().height).toBeCloseTo(640, 0); // The host wraps its content — stage plus composer/toolbar chrome. - const shell = el.shadowRoot!.querySelector('.shell') as HTMLElement; + const shell = editorRegion('generate').element(); expect(shell.getBoundingClientRect().height).toBeGreaterThan(640); expect(el.getBoundingClientRect().height).toBeCloseTo(shell.getBoundingClientRect().height, 0); // Picking a different ratio re-derives it: (640 - 40) / 2 + 40 = 340. el.setAttribute('aspect-ratios', '2:1'); - await el.updateComplete; - await vi.waitFor(() => expect(stageEl(el).getBoundingClientRect().height).toBeCloseTo(340, 0)); + await expect.poll(() => canvasEl(el).getBoundingClientRect().height).toBeCloseTo(340, 0); }); it('clamps to the consumer max-height and letterboxes the canvas', async () => { @@ -71,15 +80,13 @@ describe(' sizing', () => { 'aspect-ratios': '1:2', style: 'width: 640px; max-height: 600px', }); - await el.updateComplete; - await vi.waitFor(() => expect(el.getBoundingClientRect().height).toBeCloseTo(600, 0)); - expect(stageEl(el).getBoundingClientRect().height).toBeLessThan(600); + await expect.poll(() => el.getBoundingClientRect().height).toBeCloseTo(600, 0); + expect(canvasEl(el).getBoundingClientRect().height).toBeLessThan(600); // The frame letterboxes inside the clamped stage: it keeps the 1:2 // ratio instead of stretching to the full width. - const canvas = el.shadowRoot!.querySelector('uc-ai-canvas')!; - const frame = canvas.shadowRoot!.querySelector('.canvas__frame') as HTMLElement; + const frame = canvasEl(el).shadowRoot!.querySelector('.canvas__frame') as HTMLElement; await vi.waitFor(() => { const rect = frame.getBoundingClientRect(); expect(rect.height).toBeGreaterThan(0); @@ -89,26 +96,32 @@ describe(' sizing', () => { }); it('re-picks a sharper CDN preview when the host grows, keeping it when it shrinks', async () => { - stubFetch({ uuid: SAMPLE_UUID }); const el = mount({ ...STAGING, style: 'width: 500px; height: 480px' }); - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); + await sendPrompt('a tiger'); const dpr = Math.max(window.devicePixelRatio, 1); - const size = (bucket: number) => Math.min(Math.ceil(bucket * dpr), 3000); + const rendition = (bucket: number) => + expect.stringContaining( + `/preview/${Math.min(Math.ceil(bucket * dpr), 3000)}x${Math.min(Math.ceil(bucket * dpr), 3000)}/`, + ); + /** Every image the canvas renders: the one shown, plus a preload of the next rendition or the one fading out. */ + const canvasImageSources = () => + [...canvasEl(el).shadowRoot!.querySelectorAll('img')].map((img) => img.getAttribute('src')); // A 500px-wide host lands in the 600px rendition bucket. - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`/preview/${size(600)}x${size(600)}/`)); + await expect.element(canvasImage()).toHaveAttribute('src', rendition(600)); // Growing past the bucket steps the rendition up… el.style.width = '900px'; - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`/preview/${size(1000)}x${size(1000)}/`)); + await expect.element(canvasImage()).toHaveAttribute('src', rendition(1000)); + // (once the 600px one has faded out, so the canvas renders that image alone) + await expect.poll(canvasImageSources).toEqual([expect.stringContaining('/preview/')]); - // …while shrinking keeps the sharper, already-cached one. + // …while shrinking keeps the sharper, already-cached one: no preload of a smaller rendition starts. el.style.width = '300px'; - await delay(120); - expect(canvasUrl(el)).toContain(`/preview/${size(1000)}x${size(1000)}/`); + await afterResizeObservers(el); + await el.updateComplete; + await canvasEl(el).updateComplete; + expect(canvasImageSources()).toEqual([rendition(1000)]); }); }); diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts new file mode 100644 index 0000000..62ea52c --- /dev/null +++ b/packages/ai-image-editor/tests/emulator.ts @@ -0,0 +1,21 @@ +import type { EmulatorSession } from '@uploadcare/api-emulator'; +import { setupEmulator } from '@uploadcare/api-emulator/browser'; +import { beforeEach } from 'vitest'; +import { failOnSchemaDrift } from './schema-drift'; + +/** + * The Uploadcare emulator, running in the page, for Uploadcare's hosts and `cdn.example.com` (the CDN cname the + * editor tests configure). Every other origin except the page's own fails, so nothing reaches the real network. The + * uploader tests import an Unsplash image from a URL; the emulator's `from_url` answers for it without fetching it. + */ +const emulator = setupEmulator({ cdnHosts: ['cdn.example.com'], unhandled: 'error' }); + +/** The current test's emulator session, for a test that registers a scenario or preset on it. */ +export let session: EmulatorSession; + +/** Every test starts against an empty session. */ +beforeEach(async () => { + session = await emulator.reset(); +}); + +failOnSchemaDrift(); diff --git a/packages/ai-image-editor/tests/fixtures/production-status-success.ts b/packages/ai-image-editor/tests/fixtures/production-status-success.ts new file mode 100644 index 0000000..c75a695 --- /dev/null +++ b/packages/ai-image-editor/tests/fixtures/production-status-success.ts @@ -0,0 +1,54 @@ +/** + * A `derivative/status/` success frame production sent, verbatim: no `type` on success, and for an image file + * `image_info` and `content_info.image` present, `video_info` null. The only frame of the derivative API recorded + * from the real API, so the one fixture the emulator's derivative routes can be held to. + */ +export const PRODUCTION_STATUS_SUCCESS = { + size: 1620930, + total: 1620930, + done: 1620930, + uuid: '2e0c4294-32e0-4999-aed1-e78221224339', + file_id: '2e0c4294-32e0-4999-aed1-e78221224339', + original_filename: 'generated.png', + is_image: true, + is_stored: false, + image_info: { + dpi: null, + width: 1248, + format: 'PNG', + height: 832, + sequence: false, + color_mode: 'RGB', + orientation: null, + geo_location: null, + datetime_original: null, + }, + video_info: null, + content_info: { + mime: { mime: 'image/png', type: 'image', subtype: 'png' }, + image: { + dpi: null, + width: 1248, + format: 'PNG', + height: 832, + sequence: false, + color_mode: 'RGB', + orientation: null, + geo_location: null, + datetime_original: null, + }, + }, + is_ready: true, + filename: 'generated.png', + mime_type: 'image/png', + metadata: {}, + status: 'success', +}; + +/** `value` with every leaf replaced by its JSON type (`'null'` and `'array'` kept apart), so two frames compare by shape. */ +export const shapeOf = (value: unknown): unknown => { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value !== 'object') return typeof value; + return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, shapeOf(inner)])); +}; diff --git a/packages/ai-image-editor/tests/plugin.e2e.test.ts b/packages/ai-image-editor/tests/plugin.e2e.test.ts index 10c16b9..79f6689 100644 --- a/packages/ai-image-editor/tests/plugin.e2e.test.ts +++ b/packages/ai-image-editor/tests/plugin.e2e.test.ts @@ -1,6 +1,8 @@ -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { page } from 'vitest/browser'; -import { cleanup, delay, getCtxName } from './test-renderer'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import type { UcAiImageEditor } from '../src/index'; +import { session } from './emulator'; +import { cleanup, getCtxName } from './test-renderer'; const TEST_IMAGE_URL = 'https://images.unsplash.com/photo-1699102241946-45c5e1937d69?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=640'; @@ -13,6 +15,7 @@ type UploadCtxProvider = HTMLElement & { initFlow: () => void; removeAllFiles: () => void; getOutputCollectionState: () => { allEntries: OutputEntry[] }; + l10n: (key: string) => string; }; }; @@ -23,7 +26,7 @@ async function renderUploader(plugins: unknown[] = []) { `, ); - await delay(0); + await customElements.whenDefined('uc-config'); const config = document.querySelector(`uc-config[ctx-name="${ctxName}"]`) as Config; config.plugins = plugins; return { ctxName, config }; @@ -38,6 +41,16 @@ function addSource(config: Config, sourceId: string) { config.sourceList += `,${sourceId}`; } +/** Waits for the plugin's editor to open in `mode` (its region is named after the mode) and answers the element. */ +async function openedEditor(mode: 'generate' | 'edit'): Promise { + const name = mode === 'edit' ? 'Edit image' : 'Generate image'; + await expect.element(page.getByRole('region', { name, exact: true })).toBeVisible(); + return document.querySelector('uc-ai-image-editor') as UcAiImageEditor; +} + +/** The plugin's editor, for locators scoped to it (the uploader around it has its own Cancel). */ +const editorLocator = () => page.elementLocator(document.querySelector('uc-ai-image-editor')!); + async function openModal() { await page.getByText('Upload files', { exact: true }).click(); } @@ -52,6 +65,23 @@ beforeAll(async () => { await import('../src/index'); }); +/* + * The editor harness forces the dot grid's 2D path with `shimmerConfig = { useWebgl: false }`, but the plugin creates + * its own editor, and the canvas picks its backend before a test can reach it. Headless Chromium's WebGL is software + * (swiftshader): the generating shimmer's per-frame GL work starves the main thread, which is also where the emulator + * answers, so a single edit took ~5s instead of ~0.3s and, under a loaded suite, ran the test past its timeout. With no + * webgl2 context on offer the grid falls back to 2D, as it does in a browser without WebGL. Restored after each test. + */ +beforeEach(() => { + const getContext = HTMLCanvasElement.prototype.getContext; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function ( + this: HTMLCanvasElement, + ...args: Parameters + ) { + return args[0] === 'webgl2' ? null : getContext.apply(this, args); + } as HTMLCanvasElement['getContext']); +}); + describe('AiImageEditorPlugin', () => { it('registers "Generate image" as an upload source', async () => { const { AiImageEditorPlugin } = await import('../src/plugin'); @@ -74,13 +104,7 @@ describe('AiImageEditorPlugin', () => { await openModal(); await page.getByText('Generate image').click(); - await vi.waitFor(() => { - expect(document.querySelector('uc-ai-image-editor')).toBeTruthy(); - }); - const editor = document.querySelector('uc-ai-image-editor') as Element & { - authToken?: unknown; - cacheAuthToken?: boolean; - }; + const editor = await openedEditor('generate'); expect(editor.cacheAuthToken).toBe(false); expect(typeof editor.authToken).toBe('function'); @@ -90,7 +114,7 @@ describe('AiImageEditorPlugin', () => { const resolve = editor.authToken as () => Promise; await expect(resolve()).resolves.toBe('eyJ.uploader.sig'); await expect(resolve()).resolves.toBe('eyJ.uploader.sig'); - expect(fetchToken).toHaveBeenCalledTimes(1); + expect(fetchToken).toHaveBeenCalledOnce(); cleanup(); }); @@ -100,29 +124,14 @@ describe('AiImageEditorPlugin', () => { addSource(config, 'ai-image-editor'); await openModal(); await page.getByText('Generate image').click(); - await vi.waitFor(() => { - const editor = document.querySelector('uc-ai-image-editor') as (Element & { sourceFileInfo?: unknown }) | null; - expect(editor).toBeTruthy(); - // No source → derived generate mode (read off the prompt-row child). - const promptRow = editor?.shadowRoot?.querySelector('uc-ai-prompt-row') as (Element & { mode?: string }) | null; - expect(promptRow?.mode).toBe('generate'); - expect(editor?.sourceFileInfo).toBeFalsy(); - }); + // No source → derived generate mode. + const editor = await openedEditor('generate'); + expect(editor.sourceFileInfo).toBeUndefined(); cleanup(); }); - // The editor renders the active locale's generate-button label as the prompt - // row's `send-aria-label`, and the cancel label as the footer's `cancel-label`. - const generateLabel = () => - document - .querySelector('uc-ai-image-editor') - ?.shadowRoot?.querySelector('uc-ai-prompt-row') - ?.getAttribute('send-aria-label'); - const cancelLabel = () => - document - .querySelector('uc-ai-image-editor') - ?.shadowRoot?.querySelector('uc-ai-footer') - ?.getAttribute('cancel-label'); + /** The editor's button named `name`. */ + const editorButton = (name: string) => editorLocator().getByRole('button', { name, exact: true }); it('feeds editor locale overrides from the uploader config (localeDefinitionOverride)', async () => { const { AiImageEditorPlugin } = await import('../src/plugin'); @@ -134,14 +143,16 @@ describe('AiImageEditorPlugin', () => { addSource(config, 'ai-image-editor'); await openModal(); await page.getByText('Generate image').click(); + // The send button shows once the prompt holds text. + await userEvent.fill(editorLocator().getByRole('textbox'), 'a tiger'); - await vi.waitFor(() => expect(generateLabel()).toBe('Make it!')); + await expect.element(editorButton('Make it!')).toBeVisible(); // Reactive: changing the override after the editor is open updates it. (config as unknown as L10nConfig).localeDefinitionOverride = { en: { 'ai-image-editor-generate-btn': 'Generate now' }, }; - await vi.waitFor(() => expect(generateLabel()).toBe('Generate now')); + await expect.element(editorButton('Generate now')).toBeVisible(); cleanup(); }); @@ -153,11 +164,16 @@ describe('AiImageEditorPlugin', () => { await page.getByText('Generate image').click(); // Defaults to English. - await vi.waitFor(() => expect(cancelLabel()).toBe('Cancel')); + await openedEditor('generate'); + await expect.element(editorButton('Cancel')).toBeVisible(); // Switching localeName lazy-loads and applies the German strings. (config as unknown as { localeName: string }).localeName = 'de'; - await vi.waitFor(() => expect(cancelLabel()).toBe('Abbrechen')); + await expect.element(editorButton('Abbrechen')).toBeVisible(); + // The editor loads its own strings; the uploader loads its locale separately + // and may still be resolving. Tearing it down mid-load is a file-uploader bug + // (fixed upstream, not yet released), so let the uploader finish switching too. + await vi.waitFor(() => expect(getApi().l10n('cancel')).toBe('Abbrechen')); cleanup(); }); @@ -166,18 +182,14 @@ describe('AiImageEditorPlugin', () => { await renderUploader([AiImageEditorPlugin]); const api = getApi(); api.addFileFromUrl(TEST_IMAGE_URL); - (api as unknown as { initFlow?: () => void }).initFlow?.(); + api.initFlow(); await expect.element(page.getByRole('button', { name: 'AI Edit' })).toBeVisible(); await page.getByRole('button', { name: 'AI Edit' }).click(); - await vi.waitFor(() => { - const editor = document.querySelector('uc-ai-image-editor') as (Element & { sourceFileInfo?: unknown }) | null; - expect(editor?.sourceFileInfo).toBeTruthy(); - // A source file → derived edit mode (read off the prompt-row child). - const promptRow = editor?.shadowRoot?.querySelector('uc-ai-prompt-row') as (Element & { mode?: string }) | null; - expect(promptRow?.mode).toBe('edit'); - }); + // A source file → derived edit mode, on the uploaded file. + const editor = await openedEditor('edit'); + expect(editor.sourceFileInfo).toMatchObject({ uuid: api.getOutputCollectionState().allEntries[0]!.uuid }); cleanup(); }); @@ -186,7 +198,7 @@ describe('AiImageEditorPlugin', () => { await renderUploader([AiImageEditorPlugin]); const api = getApi(); api.addFileFromUrl(TEST_IMAGE_URL); - (api as unknown as { initFlow?: () => void }).initFlow?.(); + api.initFlow(); // Wait until the source file finished uploading (the AI Edit action only // renders once the entry has a uuid). @@ -196,45 +208,34 @@ describe('AiImageEditorPlugin', () => { const originalUuid = before[0]!.uuid; const originalInternalId = before[0]!.internalId; const originalName = before[0]!.name; - expect(originalName).toBeTruthy(); + expect(originalName).toMatch(/.+/); await page.getByRole('button', { name: 'AI Edit' }).click(); - const editor = (await vi.waitFor(() => { - const el = document.querySelector('uc-ai-image-editor'); - expect(el).toBeTruthy(); - return el!; - })) as Element & { sourceFileInfo?: { uuid?: string; originalFilename?: string } }; + const editor = await openedEditor('edit'); // The plugin hands the source entry's file info to the editor (so it can // frame the canvas and name the result after the original) — verify it's the // source file's info that was wired through. expect(editor.sourceFileInfo?.uuid).toBe(originalUuid); - // The edit produced this already-uploaded result. Drive uc:done directly so - // the test doesn't depend on the generation backend. - const resultFile = { - uuid: 'edited-result', - cdnUrl: 'https://cdn.example.com/edited-result/', - originalFilename: originalName, - size: 4242, - isImage: true, - mimeType: 'image/png', - contentInfo: { mime: { mime: 'image/png' } }, - }; - editor.dispatchEvent( - new CustomEvent('uc:done', { - detail: { url: resultFile.cdnUrl, file: resultFile }, - bubbles: true, - composed: true, - }), - ); + // Run the edit against the emulator and commit its result. + session.use('derivativesInstant'); + await userEvent.fill(editorLocator().getByRole('textbox'), 'add a hat'); + await userEvent.click(editorButton('Generate')); + await expect.element(editorButton('Done')).toBeEnabled(); + const onDone = vi.fn(); + editor.addEventListener('uc:done', onDone); + await userEvent.click(editorButton('Done')); + expect(onDone).toHaveBeenCalledOnce(); + const resultUuid: string = onDone.mock.calls[0]![0].detail.file.uuid; + expect(resultUuid).not.toBe(originalUuid); + expect(session.files.get(resultUuid)).toBeDefined(); await vi.waitFor(() => { const after = api.getOutputCollectionState().allEntries; // Replaced in place: still a single entry, now carrying the edited result. expect(after).toHaveLength(1); - expect(after[0]!.uuid).toBe('edited-result'); - expect(after[0]!.uuid).not.toBe(originalUuid); + expect(after[0]!.uuid).toBe(resultUuid); // It's a fresh entry (remove + add), so the internalId changed... expect(after[0]!.internalId).not.toBe(originalInternalId); // ...the replacement is attributed to the AI Image Editor... @@ -252,11 +253,7 @@ describe('AiImageEditorPlugin', () => { await openModal(); await page.getByText('Generate image').click(); - const editor = (await vi.waitFor(() => { - const el = document.querySelector('uc-ai-image-editor'); - expect(el).toBeTruthy(); - return el as HTMLElement; - })) as HTMLElement; + const editor = await openedEditor('generate'); // The plugin must NOT map `--uc-ai-floating` to the (undefined) `--uc-floating` // token — doing so resolves to an invalid value and blanks every panel. @@ -264,11 +261,9 @@ describe('AiImageEditorPlugin', () => { // The prompt row's card mixes `--uc-ai-floating`; with a real default it paints. const card = await vi.waitFor(() => { - const c = editor.shadowRoot - ?.querySelector('uc-ai-prompt-row') - ?.shadowRoot?.querySelector('.card') as HTMLElement | null; - expect(c).toBeTruthy(); - return c!; + const c = editor.shadowRoot?.querySelector('uc-ai-prompt-row')?.shadowRoot?.querySelector('.card'); + expect(c).toBeInstanceOf(HTMLElement); + return c as HTMLElement; }); const bg = getComputedStyle(card).backgroundColor; expect(bg).not.toBe('rgba(0, 0, 0, 0)'); diff --git a/packages/ai-image-editor/tests/schema-drift.ts b/packages/ai-image-editor/tests/schema-drift.ts new file mode 100644 index 0000000..1fe9dcb --- /dev/null +++ b/packages/ai-image-editor/tests/schema-drift.ts @@ -0,0 +1,26 @@ +import { afterEach, beforeEach, vi } from 'vitest'; + +/** The prefix `uploadcareApiClient.schemas.dev.ts` puts on every contract-drift report. */ +const DRIFT = '[uploadcare-derivative-api]'; + +/** + * Fails the test in which the dev-only schema validation reports a request or response that breaks the derivative + * API contract. In the app that report is only a `console.error`; here it means the emulator (or the client) drifted + * from the schema, which no assertion would otherwise notice. A test that spies on `console.error` itself replaces + * this check for its own duration. + */ +export function failOnSchemaDrift(): void { + let reports: unknown[][] = []; + beforeEach(() => { + reports = []; + const log = console.error; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].startsWith(DRIFT)) reports.push(args); + log(...args); + }); + }); + afterEach(() => { + if (reports.length) + throw new Error(`The derivative API schema validation failed:\n${reports.map(String).join('\n')}`); + }); +} diff --git a/packages/ai-image-editor/tests/setup.ts b/packages/ai-image-editor/tests/setup.ts new file mode 100644 index 0000000..64d52f4 --- /dev/null +++ b/packages/ai-image-editor/tests/setup.ts @@ -0,0 +1,3 @@ +// The emulator lives in its own module: a test importing `session` from the +// setup file itself would get a second copy of it, and a second emulator. +import './emulator'; diff --git a/packages/ai-image-editor/tests/specs/setup.ts b/packages/ai-image-editor/tests/specs/setup.ts new file mode 100644 index 0000000..99a7bc7 --- /dev/null +++ b/packages/ai-image-editor/tests/specs/setup.ts @@ -0,0 +1,30 @@ +import type { EmulatorSession } from '@uploadcare/api-emulator'; +import { setupEmulator } from '@uploadcare/api-emulator/node'; +import { afterAll, beforeEach } from 'vitest'; +import { failOnSchemaDrift } from '../schema-drift'; + +/** + * The Uploadcare emulator behind this process's `fetch` and `node:http(s)`, for Uploadcare's hosts and + * `upload.example.com` (the specs' custom `baseUrl`; the emulator routes by path, so it answers there too). Every + * other origin fails. + */ +const emulator = setupEmulator({ cdnHosts: ['upload.example.com'] }); + +/** The current test's emulator session, for a spec that registers a scenario or reads `requests`. */ +export let session: EmulatorSession; + +/** The current test's requests to `route` (`'METHOD /path/'`, on any host), oldest first. */ +export const requestsTo = (route: string) => { + const [method, path] = route.split(' '); + return session.requests.filter((request) => request.method === method && new URL(request.url).pathname === path); +}; + +/** Every test starts against an empty session. */ +beforeEach(async () => { + session = await emulator.reset(); +}); + +failOnSchemaDrift(); + +/** Removes the emulator's global patches, so a worker shared with a later file starts clean. */ +afterAll(() => emulator.stop()); diff --git a/packages/ai-image-editor/tests/test-renderer.ts b/packages/ai-image-editor/tests/test-renderer.ts index 891ff26..82ffe1b 100644 --- a/packages/ai-image-editor/tests/test-renderer.ts +++ b/packages/ai-image-editor/tests/test-renderer.ts @@ -40,7 +40,3 @@ declare module 'vitest/browser' { export function getCtxName(): string { return `test-${Math.random().toString(36).slice(2)}`; } - -export function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/ai-image-editor/vite.config.ts b/packages/ai-image-editor/vite.config.ts index a88bc7a..041668a 100644 --- a/packages/ai-image-editor/vite.config.ts +++ b/packages/ai-image-editor/vite.config.ts @@ -2,10 +2,16 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; +// Vite 8's default CSS minifier, Lightning CSS, drops the unprefixed `backdrop-filter` when +// `-webkit-backdrop-filter` follows it (no blur in Chrome/Firefox) and rewrites `light-dark()` +// into vars that only flip on a `color-scheme` it compiled itself, so a host page's +// `color-scheme` stops reaching the editor. esbuild keeps the CSS as Vite 6 shipped it. +const cssMinify = 'esbuild'; + export default defineConfig(({ command, mode }) => { if (command === 'serve') { return { - root: resolve(__dirname, 'demo'), + root: resolve(import.meta.dirname, 'demo'), server: { open: '/standalone.html' }, }; } @@ -13,18 +19,19 @@ export default defineConfig(({ command, mode }) => { // Static demo site build: vite build --mode demo if (mode === 'demo') { return { - root: resolve(__dirname, 'demo'), + root: resolve(import.meta.dirname, 'demo'), build: { - outDir: resolve(__dirname, 'dist-demo'), + outDir: resolve(import.meta.dirname, 'dist-demo'), emptyOutDir: true, - rollupOptions: { + cssMinify, + rolldownOptions: { // shimmer-lab.html is deliberately absent: it is a renderer-tuning // harness, so it stays a `vite dev` page (which serves any HTML under // the demo root) rather than shipping to the published playground. input: { - index: resolve(__dirname, 'demo/index.html'), - standalone: resolve(__dirname, 'demo/standalone.html'), - plugin: resolve(__dirname, 'demo/plugin.html'), + index: resolve(import.meta.dirname, 'demo/index.html'), + standalone: resolve(import.meta.dirname, 'demo/standalone.html'), + plugin: resolve(import.meta.dirname, 'demo/plugin.html'), }, }, }, @@ -33,12 +40,16 @@ export default defineConfig(({ command, mode }) => { return { build: { + // Vite 6's default ('modules'), pinned: Vite 7+ raised it to Safari 16.4 / Chrome 111, + // which would ship unlowered syntax (class static blocks, `??=`) to consumers. + target: ['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14'], cssCodeSplit: false, + cssMinify, lib: { entry: { - 'ai-image-editor': resolve(__dirname, 'src/index.ts'), - plugin: resolve(__dirname, 'src/plugin.ts'), - errors: resolve(__dirname, 'src/errors.ts'), + 'ai-image-editor': resolve(import.meta.dirname, 'src/index.ts'), + plugin: resolve(import.meta.dirname, 'src/plugin.ts'), + errors: resolve(import.meta.dirname, 'src/errors.ts'), }, name: '@uploadcare/ai-image-editor', formats: ['es', 'cjs'], @@ -46,7 +57,7 @@ export default defineConfig(({ command, mode }) => { // (dist/.js for ESM, dist/.cjs for CommonJS). fileName: (format, entryName) => `${entryName}.${format === 'es' ? 'js' : 'cjs'}`, }, - rollupOptions: { + rolldownOptions: { // Not bundled, so the editor shares one copy with File Uploader: the // plugin gets the uploader's token function, and a second bundled copy // of `AuthTokenResolverError` fails `instanceof` and wraps it again. @@ -58,6 +69,9 @@ export default defineConfig(({ command, mode }) => { /^@uploadcare\/upload-client(\/|$)/, ], output: { + // Rolldown drops both by default; Rollup kept them + strict: true, + comments: { legal: true }, globals: { lit: 'lit', '@uploadcare/file-uploader': 'UC', @@ -65,6 +79,6 @@ export default defineConfig(({ command, mode }) => { }, }, }, - plugins: [dts({ rollupTypes: true, insertTypesEntry: true, exclude: ['**/*.dev.ts', '**/*.test.ts'] })], + plugins: [dts({ bundleTypes: true, insertTypesEntry: true, exclude: ['**/*.dev.ts', '**/*.test.ts'] })], }; }); diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index 1c6f543..6f1c440 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -1,8 +1,12 @@ import { playwright } from '@vitest/browser-playwright'; -import { defineConfig } from 'vitest/config'; +import { msw } from 'msw/vite'; +import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Every spy and stubbed global is undone before the next test, so none leaks into a test that runs after it. + restoreMocks: true, + unstubGlobals: true, projects: [ { extends: true, @@ -10,13 +14,32 @@ export default defineConfig({ name: 'specs', include: ['src/**/*.test.ts'], environment: 'happy-dom', + setupFiles: ['tests/specs/setup.ts'], }, }, { extends: true, + test: { + // The derivative API contract: against the emulator by default, against the real API with + // `UC_CONTRACT_TARGET=live` (`npm run test:contract:live`), where a generation takes a while. + name: 'contract', + include: ['tests/contract/**/*.test.ts'], + environment: 'node', + setupFiles: ['tests/contract/setup.ts'], + testTimeout: process.env.UC_CONTRACT_TARGET === 'live' ? 180_000 : undefined, + }, + }, + { + extends: true, + // Serves msw 3's /mockServiceWorker.js, which the browser emulator registers, from this repo's msw + // rather than whichever copy @vitest/browser happens to resolve. + plugins: [msw({ mode: 'worker-only' })], test: { name: 'e2e', include: ['tests/**/*.test.{ts,tsx}'], + // The contract tests run in node, in their own project. + exclude: [...configDefaults.exclude, 'tests/contract/**'], + setupFiles: ['tests/setup.ts'], browser: { provider: playwright(), instances: [{ browser: 'chromium' }], diff --git a/packages/react-ai-image-editor/.gitignore b/packages/react-ai-image-editor/.gitignore index 4200423..ac35091 100644 --- a/packages/react-ai-image-editor/.gitignore +++ b/packages/react-ai-image-editor/.gitignore @@ -1,2 +1,2 @@ -tests/__screenshots__/ +__screenshots__/ .vitest-attachments/ diff --git a/packages/react-ai-image-editor/package.json b/packages/react-ai-image-editor/package.json index 799560a..7f43144 100644 --- a/packages/react-ai-image-editor/package.json +++ b/packages/react-ai-image-editor/package.json @@ -35,6 +35,7 @@ "react": ">= 17.0.0" }, "devDependencies": { + "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@types/react": "^19", "@types/react-dom": "^19", "react": "^19", diff --git a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx index 9b50abf..257cb78 100644 --- a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx +++ b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx @@ -1,26 +1,13 @@ import type { DoneDetail, UcAiImageEditor } from '@uploadcare/ai-image-editor'; import React from 'react'; +import { flushSync } from 'react-dom'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeAll, expect, it, vi } from 'vitest'; +import { afterEach, expect, it, vi } from 'vitest'; import { AiImageEditorError } from '@uploadcare/ai-image-editor/errors'; -import { AiImageEditor, preloadAiImageEditor } from '../../src'; +import { AiImageEditor } from '../../src'; import { setupContainers } from '../support/containers'; -// The real element talks to Uploadcare APIs once connected; stub the network -// so tests are hermetic (same approach as packages/ai-image-editor e2e tests). -const realFetch = globalThis.fetch; -beforeAll(() => { - globalThis.fetch = (async () => - new Response(JSON.stringify({}), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })) as typeof fetch; - return () => { - globalThis.fetch = realFetch; - }; -}); - const makeContainer = setupContainers(); const roots: Root[] = []; @@ -147,8 +134,8 @@ it('updated callback props receive events (no stale handlers)', async () => { ); const root = roots[roots.length - 1]; - root.render(); - await new Promise((r) => setTimeout(r, 50)); + // Commits synchronously, layout effects included, so @lit/react has swapped the listener before the event. + flushSync(() => root.render()); const el = container.querySelector('uc-ai-image-editor') as UcAiImageEditor; el.dispatchEvent(new CustomEvent('uc:cancel')); @@ -156,17 +143,6 @@ it('updated callback props receive events (no stale handlers)', async () => { expect(second).toHaveBeenCalledTimes(1); }); -it('preloadAiImageEditor warms the engine cache', async () => { - preloadAiImageEditor(); - const container = render(); - await vi.waitFor( - () => { - expect(container.querySelector('uc-ai-image-editor')).not.toBeNull(); - }, - { timeout: 10_000 }, - ); -}); - it('shares one AiImageEditorError class identity across package entries', async () => { // both imports resolve to built dist files here, so this guards the // bundling contract: instanceof must work across the main and /errors entries diff --git a/packages/react-ai-image-editor/tests/e2e/setup.ts b/packages/react-ai-image-editor/tests/e2e/setup.ts new file mode 100644 index 0000000..25c5644 --- /dev/null +++ b/packages/react-ai-image-editor/tests/e2e/setup.ts @@ -0,0 +1,13 @@ +import { setupEmulator } from '@uploadcare/api-emulator/browser'; +import { beforeEach } from 'vitest'; + +/** + * The Uploadcare emulator, running in the page. These tests mount the real element and only drive the React + * wrapper, so nothing should reach the network today; if the element starts to, Uploadcare requests are answered + * here and any other origin fails the request loudly instead of going out. + */ +const emulator = setupEmulator(); + +beforeEach(async () => { + await emulator.reset(); +}); diff --git a/packages/react-ai-image-editor/tests/hydration/fallback.test.tsx b/packages/react-ai-image-editor/tests/hydration/fallback.test.tsx new file mode 100644 index 0000000..dc1653b --- /dev/null +++ b/packages/react-ai-image-editor/tests/hydration/fallback.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; + +import { AiImageEditor } from '../../src'; +import { setupContainers } from '../support/containers'; + +// The engine import is gated so the test can observe the fallback before it resolves. A module resolves once per +// file, so this test has a file to itself: sharing one, it would pass only when it happened to run first. +let releaseImport: () => void; +const importGate = new Promise((resolve) => { + releaseImport = resolve; +}); + +vi.mock('@uploadcare/ai-image-editor', async () => { + await importGate; + return (await import('../support/fake-editor')).defineFakeEditor(); +}); + +const makeContainer = setupContainers(); + +it('shows the fallback until the engine loads, then swaps in the element with wired props and events', async () => { + const container = makeContainer(); + const onDone = vi.fn(); + const apiRef = React.createRef(); + + const root = createRoot(container); + root.render( + } + />, + ); + + // engine import is still gated: fallback must be visible + await vi.waitFor(() => { + expect(container.querySelector('[data-testid="skeleton"]')).not.toBeNull(); + }); + expect(container.querySelector('uc-ai-image-editor')).toBeNull(); + + releaseImport(); + + await vi.waitFor(() => { + expect(container.querySelector('uc-ai-image-editor')).not.toBeNull(); + }); + const el = container.querySelector('uc-ai-image-editor') as HTMLElement & { pubkey: string }; + expect(container.querySelector('[data-testid="skeleton"]')).toBeNull(); + expect(el.pubkey).toBe('test-pubkey'); + expect(el.getAttribute('class')).toBe('my-class'); + expect(apiRef.current).toBe(el); + + el.dispatchEvent(new CustomEvent('uc:done', { detail: { some: 'detail' } })); + expect(onDone).toHaveBeenCalledWith({ some: 'detail' }); + + root.unmount(); + expect(container.querySelector('uc-ai-image-editor')).toBeNull(); +}); diff --git a/packages/react-ai-image-editor/tests/hydration/hydration.test.tsx b/packages/react-ai-image-editor/tests/hydration/hydration.test.tsx index ccbde20..24243fb 100644 --- a/packages/react-ai-image-editor/tests/hydration/hydration.test.tsx +++ b/packages/react-ai-image-editor/tests/hydration/hydration.test.tsx @@ -6,77 +6,12 @@ import { expect, it, vi } from 'vitest'; import { AiImageEditor } from '../../src'; import { setupContainers } from '../support/containers'; -// Deterministic stand-in for the real Lit element: accessors live on the -// prototype (like Lit's @property) so the adapter's prop-splitting treats them -// as element properties. The import is gated so the first test can observe the -// pre-resolution fallback state. -let releaseImport: () => void; -const importGate = new Promise((resolve) => { - releaseImport = resolve; -}); - -vi.mock('@uploadcare/ai-image-editor', async () => { - await importGate; - class UcAiImageEditor extends HTMLElement { - #pubkey = ''; - get pubkey() { - return this.#pubkey; - } - set pubkey(value: string) { - this.#pubkey = value; - } - } - customElements.define('uc-ai-image-editor', UcAiImageEditor); - return { UcAiImageEditor }; -}); +vi.mock('@uploadcare/ai-image-editor', async () => (await import('../support/fake-editor')).defineFakeEditor()); const makeContainer = setupContainers(); -it('shows the fallback until the engine loads, then swaps in the element with wired props and events', async () => { - const container = makeContainer(); - const onDone = vi.fn(); - const apiRef = React.createRef(); - - const root = createRoot(container); - root.render( - } - />, - ); - - // engine import is still gated: fallback must be visible - await vi.waitFor(() => { - expect(container.querySelector('[data-testid="skeleton"]')).not.toBeNull(); - }); - expect(container.querySelector('uc-ai-image-editor')).toBeNull(); - - releaseImport(); - - await vi.waitFor(() => { - expect(container.querySelector('uc-ai-image-editor')).not.toBeNull(); - }); - const el = container.querySelector('uc-ai-image-editor') as HTMLElement & { pubkey: string }; - expect(container.querySelector('[data-testid="skeleton"]')).toBeNull(); - expect(el.pubkey).toBe('test-pubkey'); - expect(el.getAttribute('class')).toBe('my-class'); - expect(apiRef.current).toBe(el); - - el.dispatchEvent(new CustomEvent('uc:done', { detail: { some: 'detail' } })); - expect(onDone).toHaveBeenCalledWith({ some: 'detail' }); - - root.unmount(); - expect(container.querySelector('uc-ai-image-editor')).toBeNull(); -}); - it('hydrates server HTML without hydration mismatches', async () => { - releaseImport(); // in case the first test didn't run (order independence) - const ui = ( - loading
} /> - ); + const ui = loading
} />; const serverHtml = renderToString(ui); expect(serverHtml).toContain('skeleton'); @@ -97,7 +32,6 @@ it('hydrates server HTML without hydration mismatches', async () => { }); it('uc:cancel and uc:error events reach their callbacks', async () => { - releaseImport(); // in case the first test didn't run (order independence) const container = makeContainer(); const onCancel = vi.fn(); const onError = vi.fn(); diff --git a/packages/react-ai-image-editor/tests/hydration/preload.test.tsx b/packages/react-ai-image-editor/tests/hydration/preload.test.tsx new file mode 100644 index 0000000..72a8526 --- /dev/null +++ b/packages/react-ai-image-editor/tests/hydration/preload.test.tsx @@ -0,0 +1,15 @@ +import { expect, it, vi } from 'vitest'; + +import { preloadAiImageEditor } from '../../src'; + +// A file of its own: a module resolves once per file, so an earlier render here would have loaded the engine already. +vi.mock('@uploadcare/ai-image-editor', async () => (await import('../support/fake-editor')).defineFakeEditor()); + +it('preloadAiImageEditor loads the engine before anything renders', async () => { + expect(customElements.get('uc-ai-image-editor')).toBeUndefined(); + + preloadAiImageEditor(); + + // Loading the engine registers the element; nothing has been rendered. + await vi.waitFor(() => expect(customElements.get('uc-ai-image-editor')).toBeDefined()); +}); diff --git a/packages/react-ai-image-editor/tests/support/fake-editor.ts b/packages/react-ai-image-editor/tests/support/fake-editor.ts new file mode 100644 index 0000000..e8802ae --- /dev/null +++ b/packages/react-ai-image-editor/tests/support/fake-editor.ts @@ -0,0 +1,17 @@ +/** + * Deterministic stand-in for the real Lit element, for a `vi.mock('@uploadcare/ai-image-editor')` factory: accessors + * live on the prototype (like Lit's `@property`) so the adapter's prop-splitting treats them as element properties. + */ +export function defineFakeEditor() { + class UcAiImageEditor extends HTMLElement { + #pubkey = ''; + get pubkey() { + return this.#pubkey; + } + set pubkey(value: string) { + this.#pubkey = value; + } + } + customElements.define('uc-ai-image-editor', UcAiImageEditor); + return { UcAiImageEditor }; +} diff --git a/packages/react-ai-image-editor/vite.config.ts b/packages/react-ai-image-editor/vite.config.ts index eb74ba5..77d22ae 100644 --- a/packages/react-ai-image-editor/vite.config.ts +++ b/packages/react-ai-image-editor/vite.config.ts @@ -5,23 +5,28 @@ import dts from 'vite-plugin-dts'; export default defineConfig(({ command }) => { if (command === 'serve') { return { - root: resolve(__dirname, 'src/demo'), + root: resolve(import.meta.dirname, 'src/demo'), }; } return { build: { + // Vite 6's default ('modules'), pinned: Vite 7+ raised it to Safari 16.4 / Chrome 111 + target: ['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14'], lib: { - entry: resolve(__dirname, 'src/index.ts'), + entry: resolve(import.meta.dirname, 'src/index.ts'), name: '@uploadcare/react-ai-image-editor', formats: ['es', 'cjs'], fileName: 'react-ai-image-editor', }, - rollupOptions: { + rolldownOptions: { // @lit/react is ESM-only (no require condition) — bundle it so the CJS // build doesn't require() an ESM module (breaks on Node < 22.12) external: ['react', '@uploadcare/ai-image-editor', '@uploadcare/ai-image-editor/errors'], output: { - // Rollup strips module-level directives when bundling; Next.js needs + // Rolldown drops "use strict" and legal comments (the @lit/react license) by default; Rollup kept them + strict: true, + comments: { legal: true }, + // Rolldown strips module-level directives when bundling; Next.js needs // 'use client' at the top of the shipped files to mark the client // boundary (no-op elsewhere) banner: "'use client';", @@ -31,6 +36,6 @@ export default defineConfig(({ command }) => { }, }, }, - plugins: [dts({ rollupTypes: true, insertTypesEntry: true })], + plugins: [dts({ bundleTypes: true, insertTypesEntry: true })], }; }); diff --git a/packages/react-ai-image-editor/vitest.config.ts b/packages/react-ai-image-editor/vitest.config.ts index fee5959..0d89a87 100644 --- a/packages/react-ai-image-editor/vitest.config.ts +++ b/packages/react-ai-image-editor/vitest.config.ts @@ -1,8 +1,12 @@ import { playwright } from '@vitest/browser-playwright'; +import { msw } from 'msw/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Every spy and stubbed global is undone before the next test, so none leaks into a test that runs after it. + restoreMocks: true, + unstubGlobals: true, projects: [ { extends: true, @@ -40,9 +44,12 @@ export default defineConfig({ }, { extends: true, + // Serves msw 3's /mockServiceWorker.js for the in-page emulator (tests/e2e/setup.ts). + plugins: [msw({ mode: 'worker-only' })], test: { name: 'e2e', include: ['tests/e2e/**/*.test.{ts,tsx}'], + setupFiles: ['tests/e2e/setup.ts'], browser: { provider: playwright(), instances: [{ browser: 'chromium' }],