diff --git a/.github/scripts/check-preview-contract.sh b/.github/scripts/check-preview-contract.sh new file mode 100755 index 0000000000..3f11c6c7e9 --- /dev/null +++ b/.github/scripts/check-preview-contract.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Сверяет отпечатки копии Preview Protocol (integration-core/preview-contract) с каноническими из design-system-builder. +# +# Переменные: +# DSB_CONTRACT_REF ветка или тег design-system-builder (по умолчанию dev) +# CONTRACT_URL полный адрес CONTRACT.sha256 (для проверки скрипта) +# MODE warn - расхождение даёт предупреждение GitHub Actions, error - ошибку (по умолчанию error) +set -uo pipefail + +ref="${DSB_CONTRACT_REF:-dev}" +mode="${MODE:-error}" +digest="integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST" +url="${CONTRACT_URL:-https://raw.githubusercontent.com/salute-developers/design-system-builder/${ref}/backend-kt/preview/contract/CONTRACT.sha256}" + +report() { + if [ "$mode" = "warn" ]; then + echo "::warning title=Preview contract::$1" + exit 0 + fi + echo "::error title=Preview contract::$1" + exit 1 +} + +canonical="$(mktemp)" +if ! curl -fsSL --retry 3 "$url" -o "$canonical"; then + report "Не удалось скачать $url" +fi + +# В копии фикстуры лежат в fixtures/v1/shared, в каноническом каталоге - в fixtures/v1. +local_normalized="$(sed -E 's# fixtures/v1/shared/# fixtures/v1/#' "$digest" | LC_ALL=C sort -k2)" +canonical_sorted="$(LC_ALL=C sort -k2 "$canonical")" + +if [ "$local_normalized" = "$canonical_sorted" ]; then + echo "Копия Preview Protocol совпадает с design-system-builder@${ref}" + exit 0 +fi + +changed="$(diff <(echo "$canonical_sorted") <(echo "$local_normalized") | grep '^[<>]' | awk '{print $3}' | sort -u | tr '\n' ' ')" +echo "Расхождение с design-system-builder@${ref}:" +diff <(echo "$canonical_sorted") <(echo "$local_normalized") || true +report "Копия контракта отличается от design-system-builder@${ref}: ${changed}. Обновите: backend-kt/preview/contract/fingerprint.sh --sync-to integration-core/preview-contract/src/commonMain/resources" diff --git a/.github/workflows/check-preview-contract.yml b/.github/workflows/check-preview-contract.yml new file mode 100644 index 0000000000..e2ee7ea840 --- /dev/null +++ b/.github/workflows/check-preview-contract.yml @@ -0,0 +1,40 @@ +name: 'Preview contract check' + +permissions: + contents: read + +# Копия схемы Preview Protocol должна совпадать с каноническим контрактом из design-system-builder. +# В pull request расхождение даёт предупреждение (копия может отставать, пока схема не влита в dev design-system-builder), +# в релизных ветках, main и в ночном запуске - ошибку. +on: + pull_request: + branches: + - main + - develop + - feature/* + - release/* + - hotfix/* + paths: + - 'integration-core/preview-contract/**' + - '.github/scripts/check-preview-contract.sh' + - '.github/workflows/check-preview-contract.yml' + push: + branches: + - main + - release/* + - hotfix/* + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +jobs: + check: + name: Compare with design-system-builder + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Compare fingerprints + env: + DSB_CONTRACT_REF: ${{ vars.DSB_CONTRACT_REF || 'dev' }} + MODE: ${{ github.event_name == 'pull_request' && 'warn' || 'error' }} + run: .github/scripts/check-preview-contract.sh diff --git a/.github/workflows/publish-preview-plugin.yml b/.github/workflows/publish-preview-plugin.yml new file mode 100644 index 0000000000..0bc9f6eb44 --- /dev/null +++ b/.github/workflows/publish-preview-plugin.yml @@ -0,0 +1,155 @@ +name: 'Publish Compose preview plugin' + +# Собирает Compose preview plugin и отправляет его на бэкенд design-system-builder командой CLI `dsbuilder`. +# В хранилище пишет бэкенд, а не этот репозиторий: он же импортирует мету из архива и ведёт версии. +# +# push в develop -> dev-бэкенд (включается переменной репозитория DSB_PUBLISH_DEV_ENABLED=true) +# repository_dispatch (publish-release) -> prom-бэкенд, источник - релизный тег (DSB_PUBLISH_PROM_ENABLED=true) +# workflow_dispatch -> выбранное окружение и ref +permissions: + contents: read + +on: + push: + branches: + - develop + paths: + - 'integration-core/preview-compose-plugin/**' + - 'integration-core/preview-contract/**' + - 'integration-core/preview-sdk-compose/**' + - 'integration-core/sandbox-core/**' + - 'integration-core/uikit-compose-fixtures/**' + - 'sdds-core/uikit-compose/**' + - 'build-system/**' + - 'gradle/libs.versions.toml' + - '.github/workflows/publish-preview-plugin.yml' + repository_dispatch: + types: [publish-preview-plugin] + workflow_dispatch: + inputs: + target: + description: 'Окружение бэкенда DSB' + type: choice + options: + - dev + - prom + default: dev + required: true + ref: + description: 'Ветка, тэг или коммит (по умолчанию выбранный ref запуска)' + required: false + apply: + description: 'Записать билд (без этого выполняется только пробная отправка)' + type: boolean + default: false + +env: + # Версия CLI закрепляется осознанно: релиз `cli-vX.Y.Z` репозитория design-system-builder. + DSB_CLI_VERSION: cli-v0.1.0 + DSB_CLI_REPOSITORY: salute-developers/design-system-builder + +jobs: + prepare: + name: Определить окружение + runs-on: ubuntu-latest + outputs: + target: ${{ steps.target.outputs.target }} + ref: ${{ steps.target.outputs.ref }} + apply: ${{ steps.target.outputs.apply }} + enabled: ${{ steps.target.outputs.enabled }} + steps: + - name: Выбрать окружение, ref и режим записи + id: target + env: + EVENT: ${{ github.event_name }} + DISPATCH_TAG: ${{ github.event.client_payload.tag }} + INPUT_TARGET: ${{ inputs.target }} + INPUT_REF: ${{ inputs.ref }} + INPUT_APPLY: ${{ inputs.apply }} + PROM_ENABLED: ${{ vars.DSB_PUBLISH_PROM_ENABLED }} + DEV_ENABLED: ${{ vars.DSB_PUBLISH_DEV_ENABLED }} + run: | + case "$EVENT" in + push) + target=dev; ref="$GITHUB_SHA"; apply=true ;; + repository_dispatch) + target=prom; ref="$DISPATCH_TAG"; apply=true ;; + *) + target="$INPUT_TARGET"; ref="${INPUT_REF:-$GITHUB_SHA}"; apply="$INPUT_APPLY" ;; + esac + enabled=true + # Пока бэкенд prom не выкатил публикацию, шаг для prom выключен переменной репозитория. + if [ "$target" = "prom" ] && [ "$PROM_ENABLED" != "true" ]; then enabled=false; fi + # Автоматическая публикация на dev по push в develop выключена, пока не заданы секреты и релиз CLI; + # ручной запуск (workflow_dispatch) этой переменной не ограничен, им можно проверить пробную отправку. + if [ "$EVENT" = "push" ] && [ "$DEV_ENABLED" != "true" ]; then enabled=false; fi + { + echo "target=$target" + echo "ref=$ref" + echo "apply=$apply" + echo "enabled=$enabled" + } >> "$GITHUB_OUTPUT" + + publish: + name: Собрать и отправить на ${{ needs.prepare.outputs.target }} + needs: prepare + if: needs.prepare.outputs.enabled == 'true' + runs-on: ubuntu-latest + # Публикации в одно окружение выполняются последовательно и не отменяют друг друга. + concurrency: + group: publish-preview-plugin-${{ needs.prepare.outputs.target }} + cancel-in-progress: false + env: + TARGET: ${{ needs.prepare.outputs.target }} + APPLY: ${{ needs.prepare.outputs.apply }} + DSB_API_URL: ${{ needs.prepare.outputs.target == 'prom' && secrets.DSB_API_URL_PROM || secrets.DSB_API_URL_DEV }} + DSBUILDER_CLIENT_ID: ${{ needs.prepare.outputs.target == 'prom' && secrets.DSB_CLIENT_ID_PROM || secrets.DSB_CLIENT_ID_DEV }} + DSBUILDER_CLIENT_SECRET: ${{ needs.prepare.outputs.target == 'prom' && secrets.DSB_CLIENT_SECRET_PROM || secrets.DSB_CLIENT_SECRET_DEV }} + steps: + - name: Checkout the code + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + fetch-depth: 0 + + - name: Prepare environment + uses: ./.github/actions/prepare-android-env + + - name: Check the preview contract + # На релизном теге расхождение копии контракта с каноническим останавливает публикацию. + env: + MODE: ${{ needs.prepare.outputs.target == 'prom' && 'error' || 'warn' }} + run: .github/scripts/check-preview-contract.sh + + - name: Build and verify the plugin artifact + run: | + ./gradlew -p integration-core \ + :preview-compose-plugin:jvmTest \ + :preview-compose-plugin:previewPluginArtifact \ + :preview-compose-plugin:previewPluginBrowserTest \ + -PpreviewChromePath="$(command -v google-chrome)" + + - name: Install the dsbuilder CLI + env: + # Для закрытого репозитория DSB нужен токен чтения релизов. + GH_TOKEN: ${{ secrets.DSB_CLI_READ_TOKEN || github.token }} + run: | + mkdir -p "$RUNNER_TEMP/dsbuilder" + gh release download "$DSB_CLI_VERSION" --repo "$DSB_CLI_REPOSITORY" \ + --pattern dsbuilder-cli-jvm.zip --dir "$RUNNER_TEMP/dsbuilder" + unzip -q "$RUNNER_TEMP/dsbuilder/dsbuilder-cli-jvm.zip" -d "$RUNNER_TEMP/dsbuilder" + echo "$RUNNER_TEMP/dsbuilder/cli-jvm/bin" >> "$GITHUB_PATH" + + - name: Dry run + # Пробная отправка проверяет архив, мету и учётные данные до записи. + run: | + dsbuilder preview-plugin publish --platform compose \ + --from integration-core/preview-compose-plugin/build/distributions/preview-compose-plugin.zip \ + --api-url "$DSB_API_URL" + + - name: Publish + if: env.APPLY == 'true' + run: | + dsbuilder preview-plugin publish --platform compose \ + --from integration-core/preview-compose-plugin/build/distributions/preview-compose-plugin.zip \ + --api-url "$DSB_API_URL" --apply diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6c23a4d2c1..9f3a82617c 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -90,3 +90,18 @@ jobs: .github/scripts/dispatch-publish.sh "${{ steps.changed.outputs.modules }}" "${{ github.event.inputs.tag_name }}" env: GH_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Dispatch the Compose preview plugin publication + # Модуль плагина исключён из списка изменённых модулей (.ignore-changed-modules), поэтому публикация + # плагина запускается на каждом релизном теге. Шаг выполняется только когда публикация на prom включена + # переменной репозитория; его сбой не должен ронять выпуск релиза, который к этому моменту уже создан. + if: ${{ vars.DSB_PUBLISH_PROM_ENABLED == 'true' }} + continue-on-error: true + run: | + curl --fail-with-body -sS -X POST "https://api.github.com/repos/${{ github.repository }}/dispatches" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -d "{\"event_type\": \"publish-preview-plugin\", \"client_payload\": {\"tag\": \"${TAG}\"}}" + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + TAG: ${{ github.event.inputs.tag_name }} diff --git a/.gitignore b/.gitignore index 1f4813e5bd..03030a6f63 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ google-services.json # Kotlin/JS local dependency store tokens/kotlin-js-store/ **/contents.xcworkspacedata + +# Артефакты сборки Kotlin/JS (Wasm) в integration-core +integration-core/kotlin-js-store/ diff --git a/.ignore-changed-modules b/.ignore-changed-modules index c926e490e3..da1d2ecb2d 100644 --- a/.ignore-changed-modules +++ b/.ignore-changed-modules @@ -8,3 +8,6 @@ :integration-core:uikit-compose-testcases :integration-core:uikit-fixtures :integration-core:uikit-testcases +:integration-core:preview-contract +:integration-core:preview-sdk-compose +:integration-core:preview-compose-plugin diff --git a/build-system/docs-template/compose-template/docs/components/AvatarUsage.md b/build-system/docs-template/compose-template/docs/components/AvatarUsage.md index cb8f394331..246aa85c38 100644 --- a/build-system/docs-template/compose-template/docs/components/AvatarUsage.md +++ b/build-system/docs-template/compose-template/docs/components/AvatarUsage.md @@ -65,3 +65,27 @@ AsyncImage( model = "https://cdn.example.com/test.png", ) ``` + +## Состояния и Motion API + +В `Avatar` и `Modifier.avatar(...)` можно передать `motion: Motion`. +`rememberAvatarMotion()` использует `LocalAvatarMotionStyle`, если стиль переходов не указан явно. +`StatefulValue` задаёт значения для состояний, а `AvatarMotionStyle` — переходы фона, текста, иконки действия и фона иконки действия. +По умолчанию все переходы используют `noMotion()`: значения меняются без анимации. + +```kotlin +// @sample: com/sdds/compose/uikit/fixtures/samples/avatar/Avatar_Motion.kt +``` + +Для передачи взаимодействий используйте `motion.context.interactionSource` также в `clickable`. +Статус `AvatarStatus` публикуется в `motion.context.semanticStateSource` автоматически; +индикатор статуса использует общий с аватаром `MotionContext`. +Форма и размеры переключаются по состояниям без интерполяции. +В `AvatarGroup` у каждого аватара собственный контекст: смена статуса одного элемента не анимирует соседей. + +### Переход со старых свойств + +Существующие вызовы с `interactionSource` сохраняются (перегрузка помечена deprecated) и делегируют motion-реализации. +Для чтения текущих значений используйте stateful-свойства `shapes`, `textStyles`, `colors.textBrushes`, +`colors.backgroundBrushes`, `colors.actionBrushes`, `colors.actionScrimBrushes` +и размерные свойства с суффиксом `Values`, например `dimensionValues.widthValues`. diff --git a/build-system/docs-template/compose-template/docs/components/ListUsage.md b/build-system/docs-template/compose-template/docs/components/ListUsage.md index 83c9e57b87..c8e6ba5567 100644 --- a/build-system/docs-template/compose-template/docs/components/ListUsage.md +++ b/build-system/docs-template/compose-template/docs/components/ListUsage.md @@ -11,6 +11,25 @@ title: List +## StaticList + +`StaticList` — статическая (нескроллируемая) вариация [List](#list) для сгруппированных секций: тот же контент и тот же `ListStyle`, но без собственной прокрутки и с высотой по содержимому. + +Используйте `StaticList` внутри прокручиваемых контейнеров (в `item` компонента `List`, в `verticalScroll`-колонке) и на экранах без прокрутки. Для длинных лент используйте `List`. + +Почему нельзя вложить `List` в прокручиваемый `List`: `List` — это `LazyColumn`, ему нужно конечное «окно» прокрутки; внутри прокрутки он измеряется с бесконечной высотой (крах измерения) + +или требует фиксированной высоты и конфликтует жестами. `StaticList` таких ограничений не имеет — секция с общим фоном, `ListItem` и `Divider` внутри безопасно участвует в одной прокрутке экрана: + +```kotlin +// @sample: com/sdds/compose/uikit/fixtures/samples/list/StaticList_Section.kt +``` + +Отличия от `List`: +- весь контент компонируется сразу (без ленивой раскладки); + +Стилизация — такая же, как у `List` (общий `ListStyle`, готовые бренд-стили применимы без изменений). + ## Стиль List В большинстве случаев можно использовать готовые сгенерированные стили, а при необходимости создать собственный стиль через соответствующий builder. Подробнее о том, как работают `Style`, `StyleBuilder` и stateful-параметры стиля, см. в разделе [Стилизация компонентов](../theme/Styles.md). diff --git a/build-system/docs-template/compose-template/docs/components/NotificationUsage.md b/build-system/docs-template/compose-template/docs/components/NotificationUsage.md index 57d2a48917..011b25a530 100644 --- a/build-system/docs-template/compose-template/docs/components/NotificationUsage.md +++ b/build-system/docs-template/compose-template/docs/components/NotificationUsage.md @@ -43,3 +43,26 @@ OverlayHost { ) {...} } ``` + +### Учёт системных панелей + +По умолчанию `OverlayHost` не учитывает инсеты статус-бара и navigation bar. Для включения автоматического учёта используйте `applySystemBarInsets = true`: + +```kotlin +OverlayHost( + applySystemBarInsets = true, +) { + ... +} +``` + +Дополнительный отступ сверх системных инсетов задаётся через `contentPadding`: + +```kotlin +OverlayHost( + applySystemBarInsets = true, + contentPadding = PaddingValues(top = 16.dp), +) { + ... +} +``` diff --git a/build-system/docs-template/compose-template/docs/components/ToastUsage.md b/build-system/docs-template/compose-template/docs/components/ToastUsage.md index e1d6da4f05..a4001bb330 100644 --- a/build-system/docs-template/compose-template/docs/components/ToastUsage.md +++ b/build-system/docs-template/compose-template/docs/components/ToastUsage.md @@ -47,3 +47,26 @@ OverlayHost { ) {...} } ``` + +### Учёт системных панелей + +По умолчанию `OverlayHost` не учитывает инсеты статус-бара и navigation bar. Для включения автоматического учёта используйте `applySystemBarInsets = true`: + +```kotlin +OverlayHost( + applySystemBarInsets = true, +) { + ... +} +``` + +Дополнительный отступ сверх системных инсетов задаётся через `contentPadding`: + +```kotlin +OverlayHost( + applySystemBarInsets = true, + contentPadding = PaddingValues(top = 16.dp), +) { + ... +} +``` diff --git a/build-system/docs-template/compose-template/docs/graphics/MotionAPI.md b/build-system/docs-template/compose-template/docs/graphics/MotionAPI.md index cf74160f53..fba58c05da 100644 --- a/build-system/docs-template/compose-template/docs/graphics/MotionAPI.md +++ b/build-system/docs-template/compose-template/docs/graphics/MotionAPI.md @@ -288,6 +288,7 @@ semanticStateSource.setExclusive( - `getColorAsState` - `getBrushAsState` +- `getTextStyleAsState` - `getDpAsState` - `getFloatAsState` - `getIntAsState` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ecb846fb6..83a80fe926 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,9 +53,9 @@ glide = "4.16.0" koilVersion-compose = "3.2.0" ktor-client = "3.1.3" -sdds-uikit = "0.52.0" -sdds-uikit-compose = "0.54.0" -sdds-theme-builder = "0.51.0" +sdds-uikit = "0.53.0" +sdds-uikit-compose = "0.55.0" +sdds-theme-builder = "0.52.0" sdds-dsbuilder = "0.46.0" sdds-haze = "0.5.1" diff --git a/integration-core/preview-compose-plugin/POC_RESULTS.md b/integration-core/preview-compose-plugin/POC_RESULTS.md new file mode 100644 index 0000000000..b5bf7afbc2 --- /dev/null +++ b/integration-core/preview-compose-plugin/POC_RESULTS.md @@ -0,0 +1,61 @@ +# Compose Preview Plugin + +## Сборка artifact + +```bash +./gradlew -p integration-core :preview-compose-plugin:previewPluginArtifact +``` + +Результат: `preview-compose-plugin/build/distributions/preview-compose-plugin.zip`. Архив содержит +HTML shell, manifest, production JavaScript и все Kotlin/Skiko Wasm binaries. + +## Подтверждено + +- Wasm executable регистрирует существующие stories через generated `registerStories()`. +- Полный JSON payload декодируется и проходит путь theme preparation → example state → + `BasicButtonPreviewStyleFactory` → `BasicButtonStory.Content`. +- Общий `ComposePreviewRuntime` связывает story и style factory через `previewComponent(...)`; + отдельные runtime/renderer/prepared-классы для каждого компонента не требуются. +- Экспортированная `submitPreviewPayload(json)` принимает следующий полный payload и заменяет + Compose state без перезапуска Wasm application. +- JVM test подтверждает замену `requestId` и `ButtonUiState` последовательными payload. +- Wasm target с byte-backed TTF/OTF factory успешно компилируется на Compose Multiplatform 1.8.2. + +## Поддержанные форматы + +- Font assets: TTF и OTF, integer weight, normal/italic. +- Font-family fallback: только явный `allowFallback=true`, результат — `FontFamily.Default`. +- BasicButton: поддержанный вручную набор root, color и dimension properties SDK. +- Example props: boolean, int, float, string и single-choice значения sandbox contracts. + +## Production browser-проверка + +`previewPluginBrowserTest` обслуживает распакованный production zip по HTTP и загружает plugin в +iframe Headless Chrome. Canonical полный `BasicButton` payload использует реальный +`s_b_sans_text_regular.otf`; успешный result подтверждает загрузку bytes, создание runtime +`FontFamily`, подготовку typography и commit Compose render state. + +Проверено: + +- `sdds.preview.ready` с Preview Protocol v1; +- коррелированные success/failure envelopes и direct Promise API; +- замена token/property/example state вторым полным payload без reload; +- latest-request-wins и `superseded`; +- сохранение последнего успешного preview после invalid payload и недоступного обязательного font. + +Контрольный запуск 23 июля 2026: macOS arm64, Node.js 24.4.0, Headless Chrome 150.0.0.0, +Kotlin 2.1.10 / Kotlin-Wasm stdlib 2.1.21, Compose Multiplatform 1.8.2. First-render latency от +отправки canonical payload после ready до коррелированного success составила **6 ms**. Значение +диагностическое и зависит от browser/toolchain/cache; task печатает актуальное значение каждого +запуска. + +## Известные ограничения + +- PoC типизирован только для BasicButton и не является универсальным registry factories. +- WOFF/WOFF2 и variable-font axes не поддерживаются. +- HTML shell предоставляет Promise-based `submitPreviewPayload(payload)` и двусторонние + `sdds.preview.payload` / `sdds.preview.result` messages. +- HTTP resolver использует browser `fetch`; внешний asset URL должен быть доступен согласно CORS. +- Byte-backed fonts поддерживают TTF/OTF, integer weight и normal/italic; WOFF/WOFF2, + variable-font axes и проверка glyph rasterization не входят в PoC. +- Artifact не включает publication, подпись и production trust policy. diff --git a/integration-core/preview-compose-plugin/README.md b/integration-core/preview-compose-plugin/README.md new file mode 100644 index 0000000000..2e72eb6f4c --- /dev/null +++ b/integration-core/preview-compose-plugin/README.md @@ -0,0 +1,328 @@ +# Compose Preview Plugin + +Self-contained Wasm-плагин для отображения SDDS Compose-компонентов из полного +`PreviewPayload`. Плагин поддерживает все компоненты `uikit-compose` (79 компонентов api-меты); список лежит +в `components` manifest. + +## Сборка + +Из корня репозитория: + +```bash +./gradlew -p integration-core :preview-compose-plugin:previewPluginArtifact +``` + +Готовый архив: + +```text +integration-core/preview-compose-plugin/build/distributions/preview-compose-plugin.zip +``` + +Архив содержит: + +- `index.html` — browser entrypoint; +- `preview-plugin.json` — manifest (собирается при сборке, см. «Как добавить компонент»); +- `api-meta.json` — api-мета `uikit-compose`, по которой сгенерированы фабрики плагина; её импортирует бэкенд DSB; +- `preview-compose-plugin.js` — загрузчик приложения; +- Kotlin/Wasm и Skiko Wasm binaries; +- `composeResources/` — Compose-ресурсы историй (иллюстрации и т.п.); без них истории, использующие ресурсы, + падают при отрисовке. + +## Публикация + +Плагин публикует не этот репозиторий, а бэкенд design-system-builder (DSB): CI отправляет ему архив командой CLI, +а бэкенд за одну операцию кладёт файлы в хранилище, импортирует мету из `api-meta.json` архива и записывает билд как +активный. В `plasma-android` нет ключей хранилища и знания о нём. + +```text +push в develop -> dev-бэкенд +релиз (publish-release, тег) -> prom-бэкенд +workflow_dispatch (окружение и ref) -> выбранное окружение +``` + +Workflow `publish-preview-plugin.yml` выполняет: проверку контракта (на релизном теге ошибка расхождения +останавливает публикацию), `jvmTest`, `previewPluginArtifact`, `previewPluginBrowserTest` (включая проверку состава и +идентификации артефакта), пробную отправку и отправку с `--apply`. Публикации в одно окружение идут последовательно. + +Нужные секреты репозитория (отдельно для dev и prom): `DSB_API_URL_`, `DSB_CLIENT_ID_`, +`DSB_CLIENT_SECRET_` (служебная учётка DSB с ролью публикации; выдаёт команда DSB). Если репозиторий DSB закрыт, +нужен ещё `DSB_CLI_READ_TOKEN` с правом чтения релизов. Публикация выключена, пока не заданы переменные +репозитория: на dev по push в `develop` — `DSB_PUBLISH_DEV_ENABLED=true`, на prom — `DSB_PUBLISH_PROM_ENABLED=true` +(когда бэкенд готов принимать плагин). Ручной запуск `workflow_dispatch` на dev переменной не ограничен. Версия CLI закреплена в workflow +(`DSB_CLI_VERSION`, релиз `cli-vX.Y.Z` репозитория DSB). + +Повторный запуск для того же коммита безопасен: бэкенд отвечает «без изменений». Откат на прежний билд — +повторная публикация его коммита (`workflow_dispatch` с нужным `ref`): бэкенд снова делает его активным. + +### Идентификация билда + +Manifest содержит поля, которые бэкенд сверяет с архивом: + +| Поле | Значение | +|---|---| +| `commit` | полный sha коммита сборки | +| `version` | первые 12 символов `commit`, идентификатор билда | +| `uikitComposeVersion` | версия из `sdds-core/uikit-compose/gradle.properties` | +| `apiMetaSha256` | sha256 файла `api-meta.json` из архива | + +Версия `uikit-compose` — метка: плагин собирается из локальных исходников коммита, на `develop` они могут +опережать последний релиз. Точная привязка к мете — `apiMetaSha256`. Коммит определяется `git rev-parse HEAD`, +значение можно задать явно: `-PpreviewCommit=`. + +### Локальная проверка публикации + +Из корня репозитория: + +```bash +./gradlew -p integration-core :preview-compose-plugin:previewPluginArtifact +``` + +Поднимите локальный контур DSB (`./setup-local.sh` в репозитории design-system-builder), затем отправьте архив. +Пробная отправка ничего не записывает; запись выполняется с `--apply`. Служебная учётка локального контура +задана в realm (`dsbuilder-preview-publisher`): + +```bash +export DSBUILDER_CLIENT_ID=dsbuilder-preview-publisher +export DSBUILDER_CLIENT_SECRET=dev-preview-publisher-secret +dsbuilder preview-plugin publish --platform compose \ + --from integration-core/preview-compose-plugin/build/distributions/preview-compose-plugin.zip \ + --api-url http://localhost:8080 --apply +``` + +Файлы плагина попадают в MinIO контура (бакет `compose-preview`), а клиент DSB берёт адрес активного билда у +бэкенда. Для проверки плагина без бэкенда используйте раздачу архива и `VITE_COMPOSE_PREVIEW_PLUGIN_URL` клиента +(см. README клиента DSB). + +## Размещение вручную + +Для локальной отладки без бэкенда архив можно раздать любым статическим сервером. Распакуйте его в отдельный +каталог и публикуйте содержимое из одного URL: + +```text +https://preview.example.com/compose/ +├── index.html +├── preview-plugin.json +├── api-meta.json +├── preview-compose-plugin.js +├── composeResources/ +└── *.wasm +``` + +Плагин необходимо открывать по HTTP/HTTPS. Запуск `index.html` через `file://` не поддерживается, +поскольку браузеру требуется загружать Wasm-файлы и font assets. + +Для локальной проверки можно распаковать архив и запустить любой статический HTTP-сервер: + +```bash +unzip integration-core/preview-compose-plugin/build/distributions/preview-compose-plugin.zip \ + -d /tmp/preview-compose-plugin +python3 -m http.server 8080 --directory /tmp/preview-compose-plugin +``` + +После этого плагин доступен по адресу `http://localhost:8080/`. + +## Как добавить компонент + +Компонент попадает в preview без кода в плагине: достаточно истории и записи в api-мете. + +1. Компонент должен быть в `uikit-compose-api-meta.json` (его строит `uikit-compose`). Для каждой записи меты при сборке + плагина генерируется фабрика стиля (задача `generateComposePreview` плагина Design System Builder, блок `dsBuilder.preview`): + метод билдера выбирается по тем же правилам, что и в Theme Builder (общий файл `OverloadRules.kt`), вызовы порождаются + как обычный код. Набор компонентов можно ограничить свойством `-PpreviewComponents=A,B`. +2. В `uikit-compose-fixtures` для компонента должна быть история `@Story(componentId = "")`. + `componentId` совпадает с именем компонента в мете и может отличаться от `ComponentKey.value` + (`CheckBox` и `Checkbox`). История без `componentId` остаётся в демо, но в preview не участвует. +3. `previewComponentsFrom` собирает компоненты плагина из записей `StoryRegistry.previewEntries()` и сгенерированных + фабрик; `components` в `preview-plugin.json` генерируется из списка фабрик (шаблон в `src/manifest`). + +Проверки: `StyleFactoryCoverageTest` сверяет мету, фабрики и истории (исключения перечислены в тесте с причиной), +браузерный тест рисует каждый компонент (галерея) и сверяет manifest с описаниями компонентов. + +## Передача payload + +Плагин принимает полный `PreviewPayload`, а не patch. Поддерживаются два способа подключения. + +### Прямой вызов + +Если host и плагин работают в одном window: + +```javascript +const result = await window.submitPreviewPayload(payload); +if (result.type === "failure") { + console.error(result.code, result.message); +} +``` + +`payload` может быть JavaScript-объектом или сериализованной JSON-строкой: + +```javascript +await window.submitPreviewPayload(JSON.stringify(payload)); +``` + +Функция становится доступна после загрузки `index.html`, самостоятельно ожидает Wasm runtime и +завершает Promise только после подготовки payload и применения актуального Compose state. +Результат — существующий `PreviewResult.Success` или `PreviewResult.Failure` с тем же +`requestId`. + +Описание controls зарегистрированной story доступно независимо от render state: + +```javascript +const result = await window.describePreviewComponent("BasicButton"); +if (result.type === "success") { + console.log(result.description.properties); +} +``` + +### iframe и postMessage + +Host может загрузить плагин в iframe: + +```html + +``` + +Полный payload передаётся сообщением типа `sdds.preview.payload`: + +```javascript +const iframe = document.getElementById("compose-preview"); + +iframe.contentWindow.postMessage( + { + type: "sdds.preview.payload", + payload, + }, + "https://preview.example.com", +); +``` + +Событие iframe `load` не означает готовность Wasm. Host должен дождаться lifecycle message: + +```javascript +window.addEventListener("message", (event) => { + if ( + event.source === iframe.contentWindow && + event.data?.type === "sdds.preview.ready" && + event.data.protocolVersion === 1 + ) { + // Plugin готов принять первый полный payload. + } +}); +``` + +После обработки plugin отправляет результат в исходный `event.source`, используя исходный +`event.origin` как `targetOrigin`: + +```javascript +window.addEventListener("message", (event) => { + if (event.source !== iframe.contentWindow) return; + if (event.data?.type !== "sdds.preview.result") return; + const result = event.data.result; + console.log(result.requestId, result.type); +}); +``` + +Для получения description через iframe host отправляет отдельный коррелированный request: + +```javascript +iframe.contentWindow.postMessage( + { + type: "sdds.preview.describe", + requestId: "describe-1", + componentId: "BasicButton", + }, + "https://preview.example.com", +); + +window.addEventListener("message", (event) => { + if (event.source !== iframe.contentWindow) return; + if (event.data?.type !== "sdds.preview.description") return; + console.log(event.data.result.requestId, event.data.result); +}); +``` + +Describe requests имеют собственный lifecycle и не отменяют render requests. + +Каждый host request должен иметь уникальный непустой `requestId`. Следующий полный payload +заменяет предыдущие theme, component properties и example state без перезапуска Wasm application. +Если более старый запрос заканчивает подготовку после нового, он получает failure с code +`superseded` и не заменяет актуальный render. + +## Font assets + +Font-family tokens могут ссылаться на TTF/OTF assets из payload: + +```json +{ + "assets": [ + { + "id": "font.body.regular", + "type": "TTF", + "url": "https://assets.example.com/fonts/body-regular.ttf", + "digest": "sha256:..." + } + ] +} +``` + +URL загружается браузером через `fetch`. Сервер assets должен: + +- разрешать CORS для origin плагина; +- возвращать успешный HTTP status; +- отдавать непустое содержимое TTF/OTF. + +Если обязательный font недоступен и `allowFallback=false`, подготовка payload завершается ошибкой. +При `allowFallback=true` используется `FontFamily.Default`. + +## Manifest + +`preview-plugin.json` описывает entrypoint, protocol version, bridge и поддержанные компоненты. +Перед подключением host может прочитать manifest: + +```javascript +const manifest = await fetch( + "https://preview.example.com/compose/preview-plugin.json", +).then((response) => response.json()); +``` + +Текущий идентификатор plugin: `sdds.compose.preview`, версия Preview Protocol: `1`. +Список `components` формируется при сборке из сгенерированных фабрик, вручную его править не нужно. +`payloadBridge` объявляет direct render/describe API и transport message types: +`sdds.preview.payload`, `sdds.preview.ready`, `sdds.preview.result`, +`sdds.preview.describe` и `sdds.preview.description`. + +## Проверка production artifact + +Focused JVM tests, artifact и browser lifecycle запускаются из корня репозитория: + +```bash +./gradlew -p integration-core \ + :preview-compose-plugin:jvmTest \ + :preview-compose-plugin:previewPluginArtifact \ + :preview-compose-plugin:previewPluginBrowserTest +``` + +Browser test использует headless Chrome (с программным WebGL) и настоящий OTF asset. Путь к Chrome можно +переопределить через `-PpreviewChromePath=/path/to/chrome`. + +Кроме функциональных проверок тест запускает галерею: каждый компонент из manifest отрисовывается с пробными +значениями свойств из api-меты, проверяются успех, отсутствие предупреждений и непустой кадр, ошибки страницы +и застывший рендер. Скриншоты и страница `index.html` для просмотра лежат в +`integration-core/preview-compose-plugin/build/browser-test/gallery/`. Тест падает со списком всех проблемных компонентов. + +## Ограничения + +- Иконки (`ImageSource`) и тени (`ShadowAppearance`) в preview пока не поддерживаются: такие свойства + пропускаются с предупреждением `unsupported_property`. +- Исключение при композиции компонента (например, нехватка ресурса) останавливает рендер до перезагрузки + страницы: результат `success` отправляется после применения состояния, до отрисовки. +- Каждый update передаёт полный payload. +- WOFF/WOFF2 и variable-font axes не поддерживаются. +- Проверка доверенного origin, подпись artifact и publication pipeline пока не реализованы. +- Ready message временно отправляется parent с `targetOrigin="*"`, поскольку production origin + allowlist относится к отдельному trust-policy change. Payload results всегда направляются + только исходному sender/origin. +- Host отвечает за sandbox/CSP iframe, проверку `event.source`, `event.origin` и совместимости + manifest. diff --git a/integration-core/preview-compose-plugin/build.gradle.kts b/integration-core/preview-compose-plugin/build.gradle.kts new file mode 100644 index 0000000000..a577f4f313 --- /dev/null +++ b/integration-core/preview-compose-plugin/build.gradle.kts @@ -0,0 +1,305 @@ +import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinJsTargetDsl +import org.gradle.api.tasks.bundling.Zip +import org.gradle.api.tasks.Delete +import org.gradle.api.tasks.Sync +import org.gradle.api.attributes.Usage +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import utils.addDefaultTargets +import java.security.MessageDigest +import java.util.Properties + +plugins { + id("convention.cmp-lib") + id("io.github.salute-developers.design-system-builder") +} + +val cleanPreviewPluginWebpackOutput by tasks.registering(Delete::class) { + delete(layout.buildDirectory.dir("kotlin-webpack/wasmJs/productionExecutable")) +} + +val generatedManifestDir = layout.buildDirectory.dir("generated/preview-manifest") +val generatedFactoriesDir = layout.buildDirectory.dir("generated/preview-factories") +val generatedIconsDir = layout.buildDirectory.dir("generated/preview-icons") +val generatedGroupsFile = layout.buildDirectory.file("generated/preview-groups/preview-groups.json") +val generatePreviewFactories = tasks.named("generateComposePreview") +val generatedApiMetaDir = layout.buildDirectory.dir("generated/preview-api-meta") +val uikitComposeProperties = layout.projectDirectory.file("../../sdds-core/uikit-compose/gradle.properties") + +// Мета, по которой `generateComposePreview` строит фабрики плагина: тот же ресурс JVM-артефакта uikit-compose. Она +// входит в артефакт, и бэкенд DSB импортирует из неё компоненты, поэтому плагин и мета одного билда совпадают. +val extractPreviewApiMeta by tasks.registering(Copy::class) { + group = "build" + description = "Извлекает api-meta.json uikit-compose, из которой собран плагин, для включения в артефакт." + from(provider { zipTree(previewApiMeta.files.single { it.name.startsWith("uikit-compose-") || it.name.startsWith("uikit-compose.") }) }) { + include("sdds/api/uikit-compose-api-meta.json") + eachFile { path = "api-meta.json" } + includeEmptyDirs = false + } + into(generatedApiMetaDir) +} + +// Группы компонентов берутся из реестра историй песочницы (`ComponentKey.group`): их знает только Kotlin-код, +// поэтому JSON строит главная функция JVM-цели модуля. +val generatePreviewGroups by tasks.registering(JavaExec::class) { + group = "build" + description = "Формирует группы компонентов Compose preview по реестру историй песочницы." + dependsOn("compileKotlinJvm") + val jvmMain = provider { kotlin.targets.getByName("jvm").compilations.getByName("main") } + classpath = files( + provider { jvmMain.get().output.allOutputs }, + provider { jvmMain.get().runtimeDependencyFiles }, + ) + mainClass.set("com.sdds.preview.compose.plugin.PreviewComponentGroupKt") + val output = generatedGroupsFile + outputs.file(output) + javaLauncher.set(javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(17)) }) + doFirst { setArgs(listOf(output.get().asFile.absolutePath)) } +} + +// Список `components` manifest берётся из генератора фабрик, а не пишется вручную; тест покрытия и браузерный тест +// сверяют его с реестром историй плагина. +val generatePreviewManifest by tasks.registering { + group = "build" + description = "Собирает preview-plugin.json из шаблона, списка компонентов фабрик и идентификации билда." + dependsOn(generatePreviewFactories, generatePreviewGroups, extractPreviewApiMeta) + val apiMeta = generatedApiMetaDir.map { it.file("api-meta.json") } + val versionProperties = uikitComposeProperties + val commitOverride = providers.gradleProperty("previewCommit") + // Фактический HEAD разрешается при конфигурации и входит в задачу: после перехода на другой коммит manifest + // пересоздаётся, даже если остальные входы не изменились. + val resolvedCommit = commitOverride.orElse( + providers.exec { + commandLine("git", "rev-parse", "HEAD") + workingDir = layout.projectDirectory.asFile + isIgnoreExitValue = true + }.standardOutput.asText.map { it.trim() }, + ) + inputs.file(apiMeta) + inputs.file(versionProperties) + inputs.property("commit", resolvedCommit) + val template = layout.projectDirectory.file("src/manifest/preview-plugin.template.json") + val components = generatedFactoriesDir.map { it.file("components.txt") } + val output = generatedManifestDir + inputs.file(template) + val groupsFile = generatedGroupsFile + inputs.file(components) + inputs.file(groupsFile) + outputs.dir(output) + doLast { + @Suppress("UNCHECKED_CAST") + val manifest = groovy.json.JsonSlurper().parse(template.asFile) as MutableMap + val componentNames = components.get().asFile.readLines().filter { it.isNotBlank() } + manifest["components"] = componentNames + // Идентификация билда: бэкенд сверяет её с архивом, а клиент показывает версию плагина. + val commit = resolvedCommit.get() + check(Regex("^[0-9a-f]{40}$").matches(commit)) { + "Не удалось определить коммит сборки (git rev-parse HEAD вернул '$commit'); задайте -PpreviewCommit=" + } + val versionValues = Properties().apply { versionProperties.asFile.inputStream().use { load(it) } } + val uikitComposeVersion = listOf("versionMajor", "versionMinor", "versionPatch").joinToString(".") { + requireNotNull(versionValues.getProperty(it)) { "В ${versionProperties.asFile} нет $it" } + } + manifest["commit"] = commit + manifest["version"] = commit.take(12) + manifest["uikitComposeVersion"] = uikitComposeVersion + manifest["apiMetaSha256"] = MessageDigest.getInstance("SHA-256") + .digest(apiMeta.get().asFile.readBytes()) + .joinToString("") { "%02x".format(it) } + @Suppress("UNCHECKED_CAST") + val groups = (groovy.json.JsonSlurper().parse(groupsFile.get().asFile) as Map)["groups"] + as List> + // Каждый компонент манифеста входит ровно в одну группу, и групп без компонентов манифеста нет. + val grouped = groups.flatMap { (it["components"] as List<*>).map(Any?::toString) } + val missing = componentNames - grouped.toSet() + val unknown = grouped.toSet() - componentNames.toSet() + val duplicated = grouped.groupBy { it }.filterValues { it.size > 1 }.keys + check(missing.isEmpty() && unknown.isEmpty() && duplicated.isEmpty()) { + "Группы манифеста расходятся с компонентами: без группы $missing, вне манифеста $unknown, в нескольких группах $duplicated" + } + manifest["groups"] = groups + val directory = output.get().asFile + directory.mkdirs() + File(directory, "preview-plugin.json") + .writeText(groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(manifest)) + "\n") + } +} + +val previewPluginArtifact by tasks.registering(Zip::class) { + group = "distribution" + description = "Собирает self-contained Compose preview plugin artifact." + dependsOn("wasmJsBrowserProductionWebpack", "wasmJsProcessResources", generatePreviewManifest, extractPreviewApiMeta) + from(generatedManifestDir) + from(generatedApiMetaDir) { + include("api-meta.json") + } + // Compose-ресурсы историй (иллюстрации, шрифты) загружаются во время работы относительно страницы плагина. + from(layout.buildDirectory.dir("processedResources/wasmJs/main")) { + include("composeResources/**") + } + from(layout.buildDirectory.dir("kotlin-webpack/wasmJs/productionExecutable")) { + include("*.js", "*.wasm") + } + from("src/wasmJsMain/resources") + // Бэкенд DSB сравнивает sha256 всего архива: пересборка того же коммита обязана давать тот же zip. + isPreserveFileTimestamps = false + isReproducibleFileOrder = true + archiveFileName.set("preview-compose-plugin.zip") + destinationDirectory.set(layout.buildDirectory.dir("distributions")) +} + +val unpackPreviewPluginArtifact by tasks.registering(Sync::class) { + dependsOn(previewPluginArtifact) + from(previewPluginArtifact.map { zipTree(it.archiveFile) }) + into(layout.buildDirectory.dir("browser-test/artifact")) +} + +// Api-мета для галереи браузерного теста: тот же ресурс JVM-артефакта uikit-compose, что и при генерации фабрик. +val extractApiMetaForBrowserTest by tasks.registering(Copy::class) { + from(provider { zipTree(previewApiMeta.files.single { it.name.startsWith("uikit-compose-") || it.name.startsWith("uikit-compose.") }) }) { + include("sdds/api/uikit-compose-api-meta.json") + eachFile { path = "api-meta.json" } + includeEmptyDirs = false + } + into(layout.buildDirectory.dir("browser-test/api-meta")) +} + +// Состав и идентификация артефакта: те же поля бэкенд DSB сверяет при публикации. +val verifyPreviewPluginArtifact by tasks.registering(Exec::class) { + group = "verification" + description = "Проверяет состав и идентификацию (commit, version, uikitComposeVersion, apiMetaSha256) артефакта." + dependsOn(unpackPreviewPluginArtifact) + val versionProperties = uikitComposeProperties + val artifactDirectory = layout.buildDirectory.dir("browser-test/artifact") + doFirst { + val values = Properties().apply { versionProperties.asFile.inputStream().use { load(it) } } + val version = listOf("versionMajor", "versionMinor", "versionPatch").joinToString(".") { + requireNotNull(values.getProperty(it)) { "В ${versionProperties.asFile} нет $it" } + } + commandLine( + "node", + "src/browserTest/check-artifact.mjs", + artifactDirectory.get().asFile.absolutePath, + version, + ) + } +} + +val previewPluginBrowserTest by tasks.registering(Exec::class) { + group = "verification" + description = "Проверяет production Compose preview artifact в headless Chrome." + dependsOn(unpackPreviewPluginArtifact, extractApiMetaForBrowserTest, verifyPreviewPluginArtifact) + val chromePath = providers.gradleProperty("previewChromePath") + .orElse("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") + commandLine( + "node", + "src/browserTest/run-browser-test.mjs", + layout.buildDirectory.dir("browser-test/artifact").get().asFile.absolutePath, + file("src/browserTest/resources").absolutePath, + file("../sandbox-compose/src/commonMain/composeResources/font/s_b_sans_text_regular.otf").absolutePath, + chromePath.get(), + layout.buildDirectory.dir("browser-test/gallery").get().asFile.absolutePath, + layout.buildDirectory.dir("browser-test/api-meta").get().asFile.absolutePath, + ) +} + +group = "integration-core" + +// Фабрики стилей генерируются по api-мете, которая лежит ресурсом в JVM-артефакте uikit-compose. +val previewApiMeta: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(KotlinPlatformType.attribute, KotlinPlatformType.jvm) + } +} +dependencies { + previewApiMeta(libs.sdds.uikit.compose) +} + +// Фабрики стилей порождает задача `generateComposePreview` плагина Design System Builder. +// Ограничить набор компонентов можно свойством `-PpreviewComponents=A,B`. +dsBuilder { + preview { + metaClasspath.from(previewApiMeta) + providers.gradleProperty("previewComponents").orNull?.let { components.set(it) } + outputDirectory.set(generatedFactoriesDir) + } +} + +// Иконки, которые плагин рисует по имени из конфигурации компонента (`disclosure.down.fill.16`). Список лежит в +// репозитории: в wasm попадают только перечисленные иконки, а несуществующее имя ломает компиляцию реестра. +val generatePreviewIcons by tasks.registering { + group = "build" + description = "Генерирует реестр иконок Compose preview по src/icons/preview-icons.txt." + val list = layout.projectDirectory.file("src/icons/preview-icons.txt") + val output = generatedIconsDir + inputs.file(list) + outputs.dir(output) + doLast { + val members = list.asFile.readLines() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { name -> + name.split('.', '-', '_').filter { it.isNotEmpty() } + .joinToString("") { part -> part.replaceFirstChar { it.uppercaseChar() } } + } + .distinct() + .sorted() + val source = buildString { + appendLine("package com.sdds.preview.compose.generated") + appendLine() + appendLine("import com.sdds.icons.compose.SddsIcons") + members.forEach { appendLine("import com.sdds.icons.compose.$it") } + appendLine("import com.sdds.preview.compose.PreviewIconRegistry") + appendLine() + appendLine("/** Сгенерировано из `src/icons/preview-icons.txt`: иконки, доступные Compose preview по имени. */") + appendLine("internal object GeneratedPreviewIcons {") + appendLine(" val all: PreviewIconRegistry = PreviewIconRegistry(") + appendLine(" mapOf(") + members.forEach { appendLine(" \"$it\" to { SddsIcons.$it },") } + appendLine(" ),") + appendLine(" )") + appendLine("}") + } + val directory = output.get().asFile + directory.mkdirs() + File(directory, "GeneratedPreviewIcons.kt").writeText(source) + } +} + +android { namespace = "com.sdds.preview.compose.plugin" } + +kotlin { + addDefaultTargets(publishLibraryVariants = false) + targets.named("wasmJs") { + binaries.executable() + } + + sourceSets { + commonMain { + kotlin.srcDir(generatePreviewFactories.map { generatedFactoriesDir.get().asFile }) + kotlin.srcDir(generatePreviewIcons.map { generatedIconsDir.get().asFile }) + } + commonMain.dependencies { + implementation(project(":preview-contract")) + implementation(project(":preview-sdk-compose")) + implementation(project(":sandbox-core")) + implementation(project(":uikit-compose-fixtures")) + implementation("sdds-core:icons-compose") + implementation(libs.sdds.uikit.compose) + implementation(compose.foundation) + implementation(compose.runtime) + implementation(compose.ui) + implementation(libs.base.kotlin.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + } + } +} + +tasks.named("wasmJsProductionExecutableCompileSync") { + dependsOn(cleanPreviewPluginWebpackOutput) +} diff --git a/integration-core/preview-compose-plugin/src/browserTest/check-artifact.mjs b/integration-core/preview-compose-plugin/src/browserTest/check-artifact.mjs new file mode 100644 index 0000000000..74b49b2077 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/check-artifact.mjs @@ -0,0 +1,58 @@ +// Проверяет состав и идентификацию распакованного production artifact без браузера: бэкенд DSB сверяет те же +// поля при публикации, поэтому расхождение лучше увидеть до отправки. +import {createHash} from "node:crypto"; +import {existsSync, readFileSync, readdirSync} from "node:fs"; +import {join} from "node:path"; + +const [artifactDirectory, expectedUikitComposeVersion] = process.argv.slice(2); +const problems = []; +const problem = (text) => problems.push(text); + +const read = (name) => { + const path = join(artifactDirectory, name); + if (!existsSync(path)) { + problem(`В артефакте нет файла ${name}`); + return null; + } + return readFileSync(path); +}; + +const files = existsSync(artifactDirectory) ? readdirSync(artifactDirectory) : []; +if (!files.some((file) => file.endsWith(".wasm"))) problem("В артефакте нет ни одного .wasm файла"); +if (!files.some((file) => file.endsWith(".js"))) problem("В артефакте нет JavaScript загрузчика"); +read("index.html"); + +const manifestBytes = read("preview-plugin.json"); +const metaBytes = read("api-meta.json"); +if (manifestBytes && metaBytes) { + const manifest = JSON.parse(manifestBytes.toString("utf8")); + if (!/^[0-9a-f]{40}$/.test(manifest.commit ?? "")) problem(`commit не является sha коммита: ${manifest.commit}`); + if (manifest.version !== String(manifest.commit).slice(0, 12)) { + problem(`version ${manifest.version} не равна первым 12 символам commit ${manifest.commit}`); + } + if (!/^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/.test(manifest.uikitComposeVersion ?? "")) { + problem(`uikitComposeVersion не является семантической версией: ${manifest.uikitComposeVersion}`); + } + if (expectedUikitComposeVersion && manifest.uikitComposeVersion !== expectedUikitComposeVersion) { + problem(`uikitComposeVersion ${manifest.uikitComposeVersion}, а gradle.properties uikit-compose даёт ${expectedUikitComposeVersion}`); + } + const actual = createHash("sha256").update(metaBytes).digest("hex"); + if (manifest.apiMetaSha256 !== actual) { + problem(`apiMetaSha256 ${manifest.apiMetaSha256} не совпадает с sha256 файла api-meta.json ${actual}`); + } + try { + const meta = JSON.parse(metaBytes.toString("utf8")); + if (!Array.isArray(meta) || meta.length === 0) problem("api-meta.json пуст или не является массивом"); + } catch (error) { + problem(`api-meta.json не является JSON: ${error.message}`); + } + if (manifest.entrypoint && !files.includes(manifest.entrypoint)) { + problem(`entrypoint ${manifest.entrypoint} отсутствует в артефакте`); + } +} + +if (problems.length) { + console.error(`Artifact check failed:\n- ${problems.join("\n- ")}`); + process.exit(1); +} +console.log("Artifact check passed"); diff --git a/integration-core/preview-compose-plugin/src/browserTest/png.mjs b/integration-core/preview-compose-plugin/src/browserTest/png.mjs new file mode 100644 index 0000000000..fa34e80456 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/png.mjs @@ -0,0 +1,74 @@ +import {inflateSync} from "node:zlib"; + +/** + * Минимальный декодер PNG (8 бит на канал, без чересстрочности) для проверки скриншотов галереи: + * достаточно знать, сколько различных цветов в кадре. + */ +export function decodePng(buffer) { + const signature = buffer.subarray(0, 8).toString("hex"); + if (signature !== "89504e470d0a1a0a") throw new Error("Not a PNG"); + let offset = 8; + let header; + const data = []; + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.toString("ascii", offset + 4, offset + 8); + const body = buffer.subarray(offset + 8, offset + 8 + length); + if (type === "IHDR") { + header = { + width: body.readUInt32BE(0), + height: body.readUInt32BE(4), + depth: body[8], + colorType: body[9], + interlace: body[12], + }; + } else if (type === "IDAT") { + data.push(body); + } else if (type === "IEND") { + break; + } + offset += 12 + length; + } + if (!header || header.depth !== 8 || header.interlace !== 0) throw new Error("Unsupported PNG"); + const channels = {2: 3, 6: 4}[header.colorType]; + if (!channels) throw new Error(`Unsupported PNG colour type ${header.colorType}`); + const stride = header.width * channels; + const raw = inflateSync(Buffer.concat(data)); + const pixels = Buffer.alloc(stride * header.height); + for (let row = 0; row < header.height; row += 1) { + const filter = raw[row * (stride + 1)]; + const source = row * (stride + 1) + 1; + for (let i = 0; i < stride; i += 1) { + const left = i >= channels ? pixels[row * stride + i - channels] : 0; + const up = row > 0 ? pixels[(row - 1) * stride + i] : 0; + const upLeft = row > 0 && i >= channels ? pixels[(row - 1) * stride + i - channels] : 0; + const value = raw[source + i]; + let predicted = 0; + if (filter === 1) predicted = left; + else if (filter === 2) predicted = up; + else if (filter === 3) predicted = (left + up) >> 1; + else if (filter === 4) { + const p = left + up - upLeft; + const pa = Math.abs(p - left); + const pb = Math.abs(p - up); + const pc = Math.abs(p - upLeft); + predicted = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + } + pixels[row * stride + i] = (value + predicted) & 0xff; + } + } + return {width: header.width, height: header.height, channels, pixels}; +} + +/** Число различных цветов в кадре и доля пикселей, отличающихся от самого частого цвета (фона). */ +export function colourStatistics(image) { + const counts = new Map(); + for (let i = 0; i < image.pixels.length; i += image.channels) { + const key = image.pixels.readUIntBE(i, 3); + counts.set(key, (counts.get(key) || 0) + 1); + } + const total = image.width * image.height; + let background = 0; + for (const count of counts.values()) background = Math.max(background, count); + return {colours: counts.size, nonBackgroundShare: (total - background) / total}; +} diff --git a/integration-core/preview-compose-plugin/src/browserTest/resources/browser-test.html b/integration-core/preview-compose-plugin/src/browserTest/resources/browser-test.html new file mode 100644 index 0000000000..24635f26bd --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/resources/browser-test.html @@ -0,0 +1,341 @@ + + + + +
running
+ + + diff --git a/integration-core/preview-compose-plugin/src/browserTest/resources/canonical-basic-button.json b/integration-core/preview-compose-plugin/src/browserTest/resources/canonical-basic-button.json new file mode 100644 index 0000000000..79cfef070e --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/resources/canonical-basic-button.json @@ -0,0 +1,70 @@ +{ + "protocolVersion": 1, + "requestId": "button-first", + "platform": "COMPOSE", + "assets": [ + { + "id": "font.body.regular", + "type": "OTF", + "url": "__BASE_URL__/test-assets/font.otf" + } + ], + "theme": { + "color.background": {"type": "color", "value": "#2A72E5FF"}, + "color.background.pressed": {"type": "color", "value": "#1959B8FF"}, + "color.content": {"type": "color", "value": "#FFFFFFFF"}, + "size.height": {"type": "dimension", "value": 48.0}, + "size.padding": {"type": "dimension", "value": 20.0}, + "size.icon": {"type": "dimension", "value": 24.0}, + "shape.button": {"type": "shape", "cornerRadii": [12.0, 12.0, 12.0, 12.0]}, + "font.body": { + "type": "fontFamily", + "faces": [{"assetId": "font.body.regular", "weight": 400, "style": "NORMAL"}], + "allowFallback": false + }, + "typography.button": { + "type": "typography", + "fontFamilyTokenId": "font.body", + "fontSize": 16.0, + "lineHeight": 20.0, + "letterSpacing": 0.0, + "weight": 400 + } + }, + "component": { + "id": "BasicButton", + "variations": {"size": "l", "view": "primary"}, + "properties": { + "shape": {"base": {"type": "tokenRef", "tokenId": "shape.button"}}, + "labelStyle": {"base": {"type": "tokenRef", "tokenId": "typography.button"}}, + "contentColor": {"base": {"type": "tokenRef", "tokenId": "color.content", "alpha": 0.9}}, + "backgroundColor": { + "base": {"type": "tokenRef", "tokenId": "color.background"}, + "states": [ + {"when": ["pressed"], "source": {"type": "tokenRef", "tokenId": "color.background.pressed"}}, + {"when": ["pressed", "selected"], "source": {"type": "tokenRef", "tokenId": "color.background.pressed", "alpha": 0.5}}, + {"when": ["fancy"], "source": {"type": "tokenRef", "tokenId": "color.background.pressed"}} + ] + }, + "height": {"base": {"type": "tokenRef", "tokenId": "size.height"}}, + "paddingStart": {"base": {"type": "tokenRef", "tokenId": "size.padding"}}, + "paddingEnd": {"base": {"type": "tokenRef", "tokenId": "size.padding"}}, + "iconSize": {"base": {"type": "tokenRef", "tokenId": "size.icon"}}, + "disableAlpha": {"base": {"type": "literal", "value": 0.4}}, + "loadingAlpha": {"base": {"type": "literal", "value": 0.6}} + } + }, + "example": { + "id": "BasicButton", + "props": { + "label": "Canonical button", + "value": "Value", + "icon": "No", + "spacing": "Packed", + "hasFixedWidth": true, + "enabled": true, + "loading": false + } + }, + "surface": {"width": 360, "height": 160, "background": "#FFFFFFFF"} +} diff --git a/integration-core/preview-compose-plugin/src/browserTest/resources/gallery.html b/integration-core/preview-compose-plugin/src/browserTest/resources/gallery.html new file mode 100644 index 0000000000..210f60faab --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/resources/gallery.html @@ -0,0 +1,200 @@ + + + + +
running
+ + + diff --git a/integration-core/preview-compose-plugin/src/browserTest/run-browser-test.mjs b/integration-core/preview-compose-plugin/src/browserTest/run-browser-test.mjs new file mode 100644 index 0000000000..b5bcbff37c --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/run-browser-test.mjs @@ -0,0 +1,409 @@ +import {createServer} from "node:http"; +import {mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {extname, join, normalize} from "node:path"; +import {spawn} from "node:child_process"; +import {colourStatistics, decodePng} from "./png.mjs"; + +const [artifactDirectory, testResources, fontPath, chromePath, galleryDirectory, apiMetaDirectory] = + process.argv.slice(2); +const roots = [artifactDirectory, testResources, apiMetaDirectory].filter(Boolean); +const mimeTypes = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".wasm": "application/wasm", + ".otf": "font/otf", +}; + +function resolveFile(pathname) { + if (pathname === "/test-assets/font.otf" || pathname === "/slow-font.otf") return fontPath; + const relative = normalize(decodeURIComponent(pathname)).replace(/^(\.\.(\/|\\|$))+/, "").replace(/^\//, ""); + for (const root of roots) { + const candidate = join(root, relative || "index.html"); + try { + if (statSync(candidate).isFile()) return candidate; + } catch (_) { + // Continue with the next root. + } + } + return null; +} + +// Сколько раз скачан шрифт галереи: плагин должен переиспользовать его между payload. +let fontRequests = 0; + +const server = createServer((request, response) => { + const pathname = new URL(request.url, "http://localhost").pathname; + if (pathname === "/test-assets/font.otf") fontRequests += 1; + const file = resolveFile(pathname); + if (!file) { + response.writeHead(404); + response.end("Not found"); + return; + } + const send = () => { + response.writeHead(200, { + "Content-Type": mimeTypes[extname(file)] || "application/octet-stream", + "Access-Control-Allow-Origin": "*", + }); + response.end(readFileSync(file)); + }; + if (pathname === "/slow-font.otf") setTimeout(send, 500); + else send(); +}); + +function listen(server) { + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function freePort() { + const probe = createServer(); + await listen(probe); + const port = probe.address().port; + await new Promise((resolve) => probe.close(resolve)); + return port; +} + +async function connectToPage(debugPort) { + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + const targets = await fetch(`http://127.0.0.1:${debugPort}/json`).then((response) => response.json()); + const page = targets.find((target) => target.type === "page"); + if (page) return new WebSocket(page.webSocketDebuggerUrl); + } catch (_) { + // Chrome is still starting. + } + await delay(250); + } + throw new Error("Chrome DevTools endpoint did not become ready"); +} + +function open(socket) { + return new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, {once: true}); + socket.addEventListener("error", reject, {once: true}); + }); +} + +let commandId = 0; +function evaluate(socket, expression) { + return new Promise((resolve, reject) => { + const id = ++commandId; + const onMessage = (event) => { + const message = JSON.parse(event.data); + if (message.id !== id) return; + socket.removeEventListener("message", onMessage); + if (message.error) reject(new Error(message.error.message)); + else resolve(message.result.result.value); + }; + socket.addEventListener("message", onMessage); + socket.send(JSON.stringify({ + id, + method: "Runtime.evaluate", + params: {expression, returnByValue: true}, + })); + }); +} + +function cdp(socket, method, params = {}, awaitPromise = false) { + return new Promise((resolve, reject) => { + const id = ++commandId; + const onMessage = (event) => { + const message = JSON.parse(event.data); + if (message.id !== id) return; + socket.removeEventListener("message", onMessage); + if (message.error) reject(new Error(`${method}: ${message.error.message}`)); + else resolve(message.result); + }; + socket.addEventListener("message", onMessage); + socket.send(JSON.stringify({id, method, params: awaitPromise ? {...params, awaitPromise: true} : params})); + }); +} + +async function evaluateAsync(socket, expression) { + const result = await cdp(socket, "Runtime.evaluate", {expression, returnByValue: true}, true); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || "evaluate failed"); + return result.result.value; +} + +/** Истории, которые рисуют кнопку-триггер: содержимое (попап, шторка, уведомление) появляется после клика. */ +const TRIGGER_COMPONENTS = new Set(["Modal", "ModalBottomSheet", "Popover", "Tooltip", "Toast", "Notification"]); + +/** Группы компонентов с одинаковым кадром по умолчанию: общий стиль (TextArea и TextField, CheckBox и RadioBox). */ +const IDENTICAL_FRAMES = [ + ["TextArea", "TextField"], + ["CheckBox", "RadioBox"], + // Истории Modal, Notification, Toast и Tooltip до клика показывают одну и ту же кнопку «show». + ["Modal", "Notification", "Toast", "Tooltip"], +]; + +function escapeHtml(text) { + return String(text).replace(/[&<>"]/g, (char) => ({"&": "&", "<": "<", ">": ">", '"': """}[char])); +} + +/** + * Галерея: отрисовывает каждый компонент из manifest с пробными значениями свойств из api-меты, проверяет успех, + * отсутствие предупреждений и непустой кадр, сохраняет скриншоты и страницу для просмотра. + * Падает со списком ВСЕХ проблемных компонентов, а не только первого. + */ +async function runGallery(socket, port) { + mkdirSync(galleryDirectory, {recursive: true}); + await cdp(socket, "Page.navigate", {url: `http://127.0.0.1:${port}/gallery.html`}); + let componentIds; + for (let attempt = 0; attempt < 240 && !Array.isArray(componentIds); attempt += 1) { + await delay(250); + try { + componentIds = await evaluateAsync(socket, "window.__galleryReady || null"); + } catch (_) { + componentIds = undefined; + } + const pageState = await evaluate(socket, "document.getElementById('result')?.textContent || ''"); + if (pageState.startsWith("failed")) throw new Error(`Gallery page failed to start: ${pageState}`); + } + if (!Array.isArray(componentIds)) throw new Error("Gallery page did not become ready"); + // Исключение при композиции не возвращается результатом (успех отправляется после применения состояния), + // поэтому ошибки страницы собираются отдельно и приписываются компоненту, который рисовался. + await cdp(socket, "Runtime.enable"); + let pageErrors = []; + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.method === "Runtime.exceptionThrown") { + const details = message.params.exceptionDetails; + pageErrors.push(details.exception?.description || details.text); + } else if (message.method === "Runtime.consoleAPICalled" && message.params.type === "error") { + pageErrors.push(message.params.args.map((arg) => arg.value ?? arg.description).join(" ")); + } + }); + const fontRequestsBeforeGallery = fontRequests; + const cards = []; + const issues = []; + let previousShot = null; + let identicalInRow = 0; + const shots = new Map(); + + const capture = async () => { + const shot = await cdp(socket, "Page.captureScreenshot", { + format: "png", + clip: {x: 0, y: 0, width: 480, height: 320, scale: 1}, + }); + return Buffer.from(shot.data, "base64"); + }; + // `success` приходит до отрисовки: ждём, пока кадр перестанет меняться (два одинаковых снимка подряд), но не дольше 6 с. + const stableShot = async () => { + let previous = await capture(); + for (let attempt = 0; attempt < 30; attempt += 1) { + await delay(200); + const next = await capture(); + if (next.equals(previous)) return next; + previous = next; + } + return previous; + }; + // Истории попап-компонентов рисуют только кнопку-триггер: содержимое появляется после клика в центр. + const click = async (x, y) => { + for (const type of ["mousePressed", "mouseReleased"]) { + await cdp(socket, "Input.dispatchMouseEvent", {type, x, y, button: "left", clickCount: 1}); + } + }; + + // Для отладки одной проблемы: GALLERY_ONLY=Accordion,CheckBox ограничивает галерею перечисленными компонентами. + const only = process.env.GALLERY_ONLY?.split(",").filter(Boolean); + for (const componentId of only ? componentIds.filter((id) => only.includes(id)) : componentIds) { + const rendered = await evaluateAsync(socket, `window.__galleryRender(${JSON.stringify(componentId)})`); + const problems = []; + const {result} = rendered; + if (result.type !== "success") { + problems.push(`${result.code}: ${result.message}`); + } else if (result.warnings?.length) { + problems.push(`предупреждения: ${result.warnings.map((w) => `${w.code} ${w.path ?? ""}`).join("; ")}`); + } + const png = await stableShot(); + // Ошибки страницы, пришедшие с прошлой проверки, принадлежат компоненту, который рисуется сейчас. + const errors = pageErrors.splice(0); + if (errors.length) problems.push(`ошибки страницы: ${errors.slice(0, 2).join(" | ").slice(0, 400)}`); + // Два соседних компонента могут выглядеть одинаково (TextField и TextArea), но три подряд означают, что рендер встал. + identicalInRow = previousShot !== null && previousShot.equals(png) ? identicalInRow + 1 : 0; + if (identicalInRow >= 2) problems.push("три одинаковых кадра подряд (рендер мог остановиться)"); + previousShot = png; + writeFileSync(join(galleryDirectory, `${componentId}.png`), png); + shots.set(componentId, png); + const statistics = colourStatistics(decodePng(png)); + if (result.type === "success" && statistics.colours < 2) problems.push("пустой кадр (один цвет)"); + + let opened = null; + if (TRIGGER_COMPONENTS.has(componentId) && result.type === "success") { + await click(240, 160); + await delay(600); + opened = await stableShot(); + const openErrors = pageErrors.splice(0); + if (openErrors.length) problems.push(`ошибки страницы после клика: ${openErrors.slice(0, 2).join(" | ").slice(0, 400)}`); + writeFileSync(join(galleryDirectory, `${componentId}-open.png`), opened); + if (opened.equals(png)) problems.push("после клика по триггеру кадр не изменился (содержимое не показано)"); + const openStatistics = colourStatistics(decodePng(opened)); + if (openStatistics.colours < 3) problems.push("после клика в кадре меньше трёх цветов"); + } + // Пользователь нажимает на компонент прямо в превью: исключение в обработчике или при перерисовке + // останавливает Compose, и дальше плагин не рисует. Поэтому после клика плагин должен оставаться живым. + if (result.type === "success" && !TRIGGER_COMPONENTS.has(componentId)) { + pageErrors.splice(0); + await click(240, 160); + await delay(500); + const clickErrors = pageErrors.splice(0); + if (clickErrors.length) problems.push(`ошибки страницы после клика: ${clickErrors.slice(0, 2).join(" | ").slice(0, 400)}`); + const again = await evaluateAsync(socket, `window.__galleryRender(${JSON.stringify(componentId)})`); + if (again.result.type !== "success") { + problems.push(`после клика плагин перестал отвечать: ${again.result.code}: ${again.result.message}`); + } + pageErrors.splice(0); + } + if (problems.length) issues.push(`${componentId}: ${problems.join(" | ")}`); + cards.push({componentId, problems, statistics, opened: opened !== null, propertyCount: rendered.propertyCount, styleCount: rendered.styleCount}); + } + + // Все payload галереи ссылаются на один и тот же шрифт: после первой загрузки его не скачивают повторно. + const galleryFontRequests = fontRequests - fontRequestsBeforeGallery; + if (galleryFontRequests > 1) { + issues.push(`шрифт скачан ${galleryFontRequests} раз за ${cards.length} payload (ожидалась одна загрузка)`); + } + + // Стили и текст по умолчанию из payload не ломают render истории со вспомогательными элементами. + if (!only || only.includes("DropZone")) { + const withDefaults = await evaluateAsync(socket, `window.__galleryRenderWithHostDefaults("DropZone")`); + if (withDefaults.type !== "success" || withDefaults.warnings?.length) { + issues.push(`DropZone со стилями по умолчанию: ожидался success без предупреждений, получено ${JSON.stringify(withDefaults)}`); + } + } + // Иконка, которой нет в реестре плагина, не останавливает Compose: компонент рисуется, а результат предупреждает. + if (!only || only.includes("AccordionItem")) { + const unknownIcon = await evaluateAsync( + socket, + `window.__galleryRenderWithProperty("AccordionItem", "closedIcon", {type: "literal", value: "no.such.icon.16"})`, + ); + if (unknownIcon.type !== "success" || !unknownIcon.warnings?.some((w) => w.code === "unknown_icon")) { + issues.push(`AccordionItem с неизвестной иконкой: ожидались success и unknown_icon, получено ${JSON.stringify(unknownIcon)}`); + } + } + // Конфигурация может не содержать `textStyle`: TextSkeleton всё равно должен нарисоваться, а не остановить Compose. + if (!only || only.includes("TextSkeleton")) { + const withoutTextStyle = await evaluateAsync(socket, `window.__galleryRenderWithout("TextSkeleton", "textStyle")`); + if (withoutTextStyle.type !== "success") { + issues.push(`TextSkeleton без textStyle: ${withoutTextStyle.type}/${withoutTextStyle.code ?? ""} ${withoutTextStyle.message ?? ""}`); + } + } + + // Остановившийся Compose не должен сообщать успех (последний шаг: после него плагин мёртв). + const crash = await evaluateAsync(socket, "window.__galleryCrash()"); + if (crash.first.type !== "failure" || crash.first.code !== "render_crashed") { + issues.push(`сбой layout: ожидался отказ render_crashed, получено ${crash.first.type}/${crash.first.code ?? ""} ${crash.first.message ?? ""}`); + } + if (crash.second.type !== "failure" || crash.second.code !== "plugin_crashed") { + issues.push(`после сбоя: ожидался отказ plugin_crashed, получено ${crash.second.type}/${crash.second.code ?? ""}`); + } + // Причина сбоя доходит до хоста: stack или запись консоли, одинаковые у первого и последующих отказов. + for (const [name, result] of [["render_crashed", crash.first], ["plugin_crashed", crash.second]]) { + if (!result.details || (!result.details.stack && !result.details.consoleError)) { + issues.push(`${name}: в отказе нет details.stack и details.consoleError (${result.message ?? ""})`); + } + } + if (!/Constraints/.test(crash.first.message) || !/Constraints/.test(crash.first.details?.consoleError ?? "")) { + issues.push(`render_crashed: ожидалась причина сбоя layout (Constraints), получено ${crash.first.message}`); + } + if (JSON.stringify(crash.first.details) !== JSON.stringify(crash.second.details)) { + issues.push("plugin_crashed отдаёт details, отличные от первого сбоя"); + } + + // Одинаковые кадры у разных компонентов допустимы только из списка (общий стиль или общая история-триггер). + const byContent = new Map(); + for (const [componentId, png] of shots) { + const key = png.toString("base64"); + byContent.set(key, [...(byContent.get(key) ?? []), componentId]); + } + for (const group of byContent.values()) { + if (group.length < 2) continue; + const allowed = IDENTICAL_FRAMES.some((expected) => expected.length === group.length && expected.every((id) => group.includes(id))); + if (!allowed) issues.push(`${group.join(", ")}: одинаковые кадры у разных компонентов вне списка допустимых`); + } + const html = `Compose preview gallery + +

Compose preview: ${cards.length} компонентов, проблем: ${issues.length}

+
${cards.map((card) => `
+${escapeHtml(card.componentId)} +свойств: ${card.propertyCount}, вложенных стилей: ${card.styleCount}, цветов в кадре: ${card.statistics.colours} +${card.opened ? `после клика по триггеру` : ""} +${card.problems.map((problem) => `${escapeHtml(problem)}`).join("")}
`).join("")}
`; + writeFileSync(join(galleryDirectory, "index.html"), html); + writeFileSync(join(galleryDirectory, "results.json"), JSON.stringify({cards, issues}, null, 2)); + console.log(JSON.stringify({gallery: {components: cards.length, issues: issues.length, directory: galleryDirectory}})); + if (issues.length) throw new Error(`Gallery found problems in ${issues.length} components:\n${issues.join("\n")}`); +} + +await listen(server); +const userDataDirectory = mkdtempSync(join(tmpdir(), "preview-browser-test-")); +let chrome; +try { + const {port} = server.address(); + const debugPort = await freePort(); + chrome = spawn(chromePath, [ + "--headless=new", + // Compose рисует в WebGL-холст; без программного рендерера он не попадает в скриншот. + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "--ignore-gpu-blocklist", + "--no-sandbox", + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${userDataDirectory}`, + `http://127.0.0.1:${port}/browser-test.html`, + ]); + let stderr = ""; + chrome.stderr.on("data", (chunk) => stderr += chunk); + + const socket = await connectToPage(debugPort); + await open(socket); + let resultText = "running"; + for (let attempt = 0; attempt < 120 && resultText === "running"; attempt += 1) { + resultText = await evaluate( + socket, + "document.getElementById('result')?.textContent || 'running'", + ); + if (resultText === "running") await delay(250); + } + if (resultText === "running") { + const diagnostics = await evaluate( + socket, + `JSON.stringify({ + iframeBody: document.getElementById("plugin")?.contentDocument?.body?.innerText, + directApi: typeof document.getElementById("plugin")?.contentWindow?.submitPreviewPayload, + readyCallback: typeof document.getElementById("plugin")?.contentWindow?.__sddsPreviewReady, + kotlinApi: typeof document.getElementById("plugin")?.contentWindow?.["preview-compose-plugin"] + })`, + ); + socket.close(); + throw new Error(`Browser test timed out: ${diagnostics}\n${stderr}`); + } + const result = JSON.parse(resultText); + console.log(JSON.stringify(result)); + if (result.status !== "passed") { + socket.close(); + throw new Error(result.message || "Browser test failed"); + } + try { + if (galleryDirectory) await runGallery(socket, port); + } finally { + socket.close(); + } +} finally { + if (chrome && chrome.exitCode === null) { + const closed = new Promise((resolve) => chrome.once("close", resolve)); + chrome.kill(); + await closed; + } + await new Promise((resolve) => server.close(resolve)); + rmSync(userDataDirectory, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); +} diff --git a/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PendingRenderResults.kt b/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PendingRenderResults.kt new file mode 100644 index 0000000000..d3251ed3ff --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PendingRenderResults.kt @@ -0,0 +1,37 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.contract.PreviewResult + +/** + * Откладывает успешный результат до тех пор, пока новое содержимое не нарисовано. + * + * Состояние рендера применяется сразу, а композиция и layout выполняются в следующих кадрах. Исключение + * в них останавливает Compose, но хост уже получил бы `success`. Поэтому результат уходит хосту только после + * [confirm], а если кадр не состоялся, хост не получит результата и сам завершит запрос по таймауту. + */ +internal class PendingRenderResults( + private val deliver: (PreviewResult) -> Unit, +) { + private var pending: PreviewResult.Success? = null + + /** Запоминает успешный результат до подтверждения отрисовки; предыдущий неподтверждённый вытесняется. */ + fun await(result: PreviewResult.Success) { + pending?.let { previous -> + deliver( + PreviewResult.Failure( + requestId = previous.requestId, + code = "superseded", + message = "Preview request superseded by a newer payload", + ), + ) + } + pending = result + } + + /** Отдаёт отложенный результат запроса [requestId], если он всё ещё ожидается. */ + fun confirm(requestId: String) { + val result = pending?.takeIf { it.requestId == requestId } ?: return + pending = null + deliver(result) + } +} diff --git a/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinator.kt b/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinator.kt new file mode 100644 index 0000000000..e6701dd41a --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinator.kt @@ -0,0 +1,94 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.compose.PreparedComposePreview +import com.sdds.preview.compose.PreviewPreparationException +import com.sdds.preview.contract.PREVIEW_PROTOCOL_VERSION +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewResult +import com.sdds.preview.contract.TokenValue +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString + +internal class PreviewRequestCoordinator( + private val prepare: suspend (String) -> PreparedComposePreview, + private val apply: (PreparedComposePreview) -> Unit, +) { + private var latestGeneration: Long = 0 + + @Suppress("ReturnCount", "TooGenericExceptionCaught") + suspend fun submit(json: String): PreviewResult { + val generation = ++latestGeneration + val payload = try { + PreviewContractJson.decodeFromString(json) + } catch (error: SerializationException) { + return decodeFailure(json, error) + } catch (error: IllegalArgumentException) { + return decodeFailure(json, error) + } + + if (payload.protocolVersion != PREVIEW_PROTOCOL_VERSION) { + return PreviewResult.Failure( + requestId = payload.requestId, + code = "unsupported_protocol_version", + message = "Preview Protocol ${payload.protocolVersion} не поддерживается", + path = "$.protocolVersion", + ) + } + + val preview = try { + prepare(json) + } catch (error: PreviewPreparationException) { + return PreviewResult.Failure(payload.requestId, error.code, error.message.orEmpty(), error.path) + } catch (error: Throwable) { + return PreviewResult.Failure( + requestId = payload.requestId, + code = classifyPreparationFailure(payload, error), + message = error.message ?: "Preview preparation failed", + ) + } + + if (generation != latestGeneration) { + return PreviewResult.Failure( + requestId = payload.requestId, + code = "superseded", + message = "Preview request superseded by a newer payload", + ) + } + + return try { + apply(preview) + PreviewResult.Success(payload.requestId, preview.warnings) + } catch (error: Throwable) { + PreviewResult.Failure( + requestId = payload.requestId, + code = "render_state_apply_failed", + message = error.message ?: "Preview render state could not be applied", + ) + } + } + + private fun decodeFailure(json: String, error: Throwable): PreviewResult.Failure = + PreviewResult.Failure( + requestId = extractRequestId(json).orEmpty(), + code = "invalid_payload", + message = error.message ?: "Preview payload could not be decoded", + ) + + private fun extractRequestId(json: String): String? = + REQUEST_ID_REGEX.find(json)?.groupValues?.getOrNull(1) + + private fun classifyPreparationFailure(payload: PreviewPayload, error: Throwable): String { + val message = error.message.orEmpty().lowercase() + return when { + "не зарегистрирован" in message || "not registered" in message -> "component_not_found" + payload.theme.values.any { it is TokenValue.FontFamily } -> "asset_loading_failed" + "asset" in message || "font" in message || "http" in message -> "asset_loading_failed" + else -> "preview_preparation_failed" + } + } + + private companion object { + val REQUEST_ID_REGEX = Regex(""""requestId"\s*:\s*"([^"]+)"""") + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/BasicButtonDescriptionTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/BasicButtonDescriptionTest.kt new file mode 100644 index 0000000000..fe983c9e66 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/BasicButtonDescriptionTest.kt @@ -0,0 +1,44 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.ButtonStyle +import com.sdds.compose.uikit.fixtures.stories.button.BasicButtonStory +import com.sdds.preview.compose.GeneratedStyleFactory +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.previewComponent +import com.sdds.preview.contract.PreviewExamplePropertyDescription +import kotlin.test.Test +import kotlin.test.assertEquals + +class BasicButtonDescriptionTest { + @Suppress("UNCHECKED_CAST") + @Test + fun `BasicButton description exactly matches generated story properties`() { + val description = previewComponent( + BasicButtonStory, + GeneratedPreviewStyleFactories.all.getValue("BasicButton") as GeneratedStyleFactory, + ).description + + assertEquals("BasicButton", description.componentId) + assertEquals("BasicButton", description.storyId) + assertEquals( + listOf( + PreviewExamplePropertyDescription.StringProperty("label", "label"), + PreviewExamplePropertyDescription.StringProperty("value", ""), + PreviewExamplePropertyDescription.SingleChoiceProperty( + "icon", + "Start", + listOf("Start", "End", "No"), + ), + PreviewExamplePropertyDescription.SingleChoiceProperty( + "spacing", + "Packed", + listOf("Packed", "SpaceBetween"), + ), + PreviewExamplePropertyDescription.BooleanProperty("hasFixedWidth", false), + PreviewExamplePropertyDescription.BooleanProperty("enabled", true), + PreviewExamplePropertyDescription.BooleanProperty("loading", false), + ), + description.properties, + ) + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/ComponentNotFoundTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/ComponentNotFoundTest.kt new file mode 100644 index 0000000000..031001fa9c --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/ComponentNotFoundTest.kt @@ -0,0 +1,44 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.ui.text.font.FontFamily +import com.sdds.preview.compose.ComposePreviewRuntime +import com.sdds.preview.compose.InMemoryPreviewAssetResolver +import com.sdds.preview.compose.LoadedFontFamilyFactory +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewResult +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ComponentNotFoundTest { + private val runtime = ComposePreviewRuntime( + assetResolver = InMemoryPreviewAssetResolver(emptyMap()), + fontFamilyFactory = LoadedFontFamilyFactory { FontFamily.Default }, + components = emptyList(), + ) + private val coordinator = PreviewRequestCoordinator(prepare = runtime::prepare, apply = {}) + + @Test + fun `payload for an unregistered component fails with component_not_found`() = runTest { + val json = PreviewContractJson.encodeToString( + PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = emptyMap(), + component = PreviewComponent("Text"), + example = PreviewExample("default"), + ), + ) + + val result = assertIs(coordinator.submit(json)) + + assertEquals("component_not_found", result.code) + assertEquals("r", result.requestId) + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PendingRenderResultsTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PendingRenderResultsTest.kt new file mode 100644 index 0000000000..39b029b9db --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PendingRenderResultsTest.kt @@ -0,0 +1,57 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.contract.PreviewResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PendingRenderResultsTest { + + private val delivered = mutableListOf() + private val pending = PendingRenderResults { delivered += it } + + @Test + fun `result is held until the render is confirmed`() { + pending.await(PreviewResult.Success("a")) + + assertTrue(delivered.isEmpty()) + + pending.confirm("a") + + assertEquals(listOf(PreviewResult.Success("a")), delivered) + } + + @Test + fun `result is delivered once`() { + pending.await(PreviewResult.Success("a")) + + pending.confirm("a") + pending.confirm("a") + + assertEquals(1, delivered.size) + } + + @Test + fun `confirmation of another request does not release the result`() { + pending.await(PreviewResult.Success("b")) + + pending.confirm("a") + + assertTrue(delivered.isEmpty()) + } + + @Test + fun `an unconfirmed result is superseded by the next one`() { + pending.await(PreviewResult.Success("a")) + pending.await(PreviewResult.Success("b")) + + assertEquals(1, delivered.size) + val failure = delivered.single() as PreviewResult.Failure + assertEquals("a", failure.requestId) + assertEquals("superseded", failure.code) + + pending.confirm("b") + + assertEquals(PreviewResult.Success("b"), delivered.last()) + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinatorTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinatorTest.kt new file mode 100644 index 0000000000..4c28dea698 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinatorTest.kt @@ -0,0 +1,151 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.runtime.Composable +import com.sdds.preview.compose.PreparedComposePreview +import com.sdds.preview.compose.PreviewPreparationException +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewResult +import com.sdds.preview.contract.PreviewWarning +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class PreviewRequestCoordinatorTest { + @Test + fun `successful preparation applies preview before returning success`() = runTest { + val applied = mutableListOf() + val coordinator = PreviewRequestCoordinator( + prepare = { FakePreview("first") }, + apply = { applied += it.requestId }, + ) + + val result = coordinator.submit(payloadJson("first")) + + assertEquals(listOf("first"), applied) + assertEquals(PreviewResult.Success("first"), result) + } + + @Test + fun `warnings of the prepared preview are returned with success`() = runTest { + val warning = PreviewWarning("unknown_state", "fancy", "$.component.properties.a.states[0]") + val coordinator = PreviewRequestCoordinator( + prepare = { FakePreview("first", listOf(warning)) }, + apply = {}, + ) + + assertEquals(PreviewResult.Success("first", listOf(warning)), coordinator.submit(payloadJson("first"))) + } + + @Test + fun `reference errors keep their stable code and path and leave state untouched`() = runTest { + var applied = "previous" + val coordinator = PreviewRequestCoordinator( + prepare = { throw PreviewPreparationException("style_ref_cycle", "cycle", "$.styles.a") }, + apply = { applied = it.requestId }, + ) + + val result = assertIs(coordinator.submit(payloadJson("first"))) + + assertEquals("style_ref_cycle", result.code) + assertEquals("$.styles.a", result.path) + assertEquals("previous", applied) + } + + @Test + fun `invalid payload returns correlated stable failure and keeps state`() = runTest { + var applied = "previous" + val coordinator = PreviewRequestCoordinator( + prepare = { error("must not prepare") }, + apply = { applied = it.requestId }, + ) + + val result = coordinator.submit("""{"requestId":"broken","platform":"COMPOSE"}""") + + assertEquals("previous", applied) + assertEquals("broken", result.requestId) + assertEquals("invalid_payload", assertIs(result).code) + } + + @Test + fun `sequential payload replaces applied preview`() = runTest { + var applied = "" + val coordinator = PreviewRequestCoordinator( + prepare = { json -> FakePreview(requestId(json)) }, + apply = { applied = it.requestId }, + ) + + coordinator.submit(payloadJson("first")) + val result = coordinator.submit(payloadJson("second")) + + assertEquals("second", applied) + assertEquals(PreviewResult.Success("second"), result) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `older overlapping request is superseded and cannot replace newer preview`() = runTest { + val releaseFirst = CompletableDeferred() + var applied = "" + val coordinator = PreviewRequestCoordinator( + prepare = { json -> + requestId(json).also { if (it == "first") releaseFirst.await() }.let(::FakePreview) + }, + apply = { applied = it.requestId }, + ) + + val first = async { coordinator.submit(payloadJson("first")) } + runCurrent() + val second = coordinator.submit(payloadJson("second")) + releaseFirst.complete(Unit) + + assertEquals(PreviewResult.Success("second"), second) + assertEquals("superseded", assertIs(first.await()).code) + assertEquals("second", applied) + } + + @Test + fun `preparation failures use stable category and preserve previous preview`() = runTest { + var applied = "previous" + val coordinator = PreviewRequestCoordinator( + prepare = { error("Font asset response was HTTP 404") }, + apply = { applied = it.requestId }, + ) + + val result = assertIs(coordinator.submit(payloadJson("font"))) + + assertEquals("asset_loading_failed", result.code) + assertEquals("font", result.requestId) + assertEquals("previous", applied) + } + + private fun payloadJson(requestId: String): String = PreviewContractJson.encodeToString( + PreviewPayload( + requestId = requestId, + platform = PreviewPlatform.COMPOSE, + theme = emptyMap(), + component = PreviewComponent(id = "BasicButton"), + example = PreviewExample(id = "BasicButton"), + ), + ) + + private fun requestId(json: String): String = + PreviewContractJson.decodeFromString(json).requestId + + private class FakePreview( + override val requestId: String, + override val warnings: List = emptyList(), + ) : PreparedComposePreview { + @Composable + override fun Content() = Unit + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/StoryRegistryEntriesTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/StoryRegistryEntriesTest.kt new file mode 100644 index 0000000000..ca03cecefb --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/StoryRegistryEntriesTest.kt @@ -0,0 +1,76 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.sandbox.ComponentKey +import com.sdds.sandbox.StoryRegistry +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Проверяет записи реестра историй, сгенерированные `sandbox-ksp` для реальных историй `uikit-compose-fixtures`. */ +class StoryRegistryEntriesTest { + @BeforeTest + fun register() { + StoryRegistry.clear() + registerStories() + } + + @Test + fun `entries are unique and ordered by component id`() { + val ids = StoryRegistry.previewEntries().map { it.componentId } + + assertTrue(ids.isNotEmpty()) + assertEquals(ids.distinct(), ids) + assertEquals(ids.sorted(), ids) + } + + @Test + fun `entry uses the api meta name and exposes the style class`() { + val entries = StoryRegistry.previewEntries().associateBy { it.componentId } + + val checkBox = assertNotNull(entries["CheckBox"]) + assertEquals("com.sdds.compose.uikit.CheckBoxStyle", checkBox.styleQualifiedName) + // ComponentKey.CheckBox.value это "Checkbox": идентификатор preview берётся из меты, а не из ключа. + assertEquals("Checkbox", ComponentKey.CheckBox.value) + assertEquals(ComponentKey.CheckBox, checkBox.story.component) + assertEquals("com.sdds.compose.uikit.ButtonStyle", entries.getValue("BasicButton").styleQualifiedName) + } + + @Test + fun `stories without component id stay in the registry but are not preview entries`() { + val ids = StoryRegistry.previewEntries().map { it.componentId }.toSet() + + listOf("Icon", "IconTabs", "Mask", "Text").forEach { id -> + assertTrue(id !in ids, "$id не должна участвовать в preview") + } + assertNotNull(StoryRegistry.get(ComponentKey.Text)) + assertNotNull(StoryRegistry.get(ComponentKey.IconTabs)) + assertNull(StoryRegistry.previewEntries().firstOrNull { it.story === StoryRegistry.get(ComponentKey.Text) }) + } + + @Test + fun `bottom sheet story is registered under the api meta name of its style`() { + val entry = assertNotNull(StoryRegistry.previewEntries().firstOrNull { it.componentId == "ModalBottomSheet" }) + + assertEquals("com.sdds.compose.uikit.ModalBottomSheetStyle", entry.styleQualifiedName) + assertEquals(ComponentKey.BottomSheet, entry.story.component) + } + + @Test + fun `item components have their own stories`() { + val ids = StoryRegistry.previewEntries().map { it.componentId }.toSet() + + listOf( + "AccordionItem", "DropdownEmptyState", "DropdownLoadingState", "IconTabItem", "ListItem", + "NavigationDrawerItem", "SelectItem", "TabBarItem", "TabItem", + ).forEach { id -> assertTrue(id in ids, "Нет истории для $id") } + } + + @Test + fun `all stories with a component id are registered`() { + assertEquals(79, StoryRegistry.previewEntries().size) + } +} diff --git a/integration-core/preview-compose-plugin/src/icons/preview-icons.txt b/integration-core/preview-compose-plugin/src/icons/preview-icons.txt new file mode 100644 index 0000000000..2b8026aa22 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/icons/preview-icons.txt @@ -0,0 +1,26 @@ +arrow.left.24 +chevron.left.24 +chevron.right.24 +close.16 +close.24 +close.36 +disclosure.down.fill.16 +disclosure.down.fill.24 +disclosure.down.outline.24 +disclosure.down.outline.36 +disclosure.left.outline.24 +disclosure.right.outline.16 +disclosure.right.outline.24 +disclosure.up.fill.16 +disclosure.up.fill.24 +disclosure.up.outline.24 +disclosure.up.outline.36 +done.16 +done.24 +dots.horizontal.outline.24 +info.circle.outline.16 +info.circle.outline.24 +minus.16 +minus.24 +plus.16 +plus.24 diff --git a/integration-core/preview-compose-plugin/src/jvmMain/kotlin/com/sdds/preview/compose/plugin/PreviewComponentGroup.kt b/integration-core/preview-compose-plugin/src/jvmMain/kotlin/com/sdds/preview/compose/plugin/PreviewComponentGroup.kt new file mode 100644 index 0000000000..161209c87f --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmMain/kotlin/com/sdds/preview/compose/plugin/PreviewComponentGroup.kt @@ -0,0 +1,56 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.sandbox.StoryEntry +import com.sdds.sandbox.StoryRegistry +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.io.File + +/** Группа компонентов песочницы: имя и компоненты по алфавиту. */ +internal data class PreviewComponentGroup( + val name: String, + val components: List, +) + +/** + * Группы компонентов по реестру историй: порядок групп как в песочнице (`ComponentGroup.ordinal`), внутри группы + * компоненты по алфавиту. Группа компонента — группа ключа его истории. + */ +internal fun previewComponentGroups(entries: List): List = + entries.groupBy { it.story.component.group } + .toSortedMap(compareBy({ it.ordinal }, { it.displayName })) + .map { (group, members) -> + PreviewComponentGroup(group.displayName, members.map { it.componentId }.sorted()) + } + +/** JSON `{"groups": [{"name": ..., "components": [...]}]}`, который `generatePreviewManifest` кладёт в манифест. */ +internal fun List.toManifestJson(): String = + Json { prettyPrint = true }.encodeToString( + JsonObject.serializer(), + JsonObject( + mapOf( + "groups" to JsonArray( + map { group -> + JsonObject( + mapOf( + "name" to JsonPrimitive(group.name), + "components" to JsonArray(group.components.map(::JsonPrimitive)), + ), + ) + }, + ), + ), + ), + ) + +/** Запускается Gradle-задачей `generatePreviewGroups`: первый аргумент — путь выходного файла. */ +public fun main(args: Array) { + require(args.size == 1) { "Ожидается путь выходного файла групп" } + StoryRegistry.clear() + registerStories() + File(args.single()).apply { parentFile.mkdirs() } + .writeText(previewComponentGroups(StoryRegistry.previewEntries()).toManifestJson() + "\n") +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ComposeTestSupport.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ComposeTestSupport.kt new file mode 100644 index 0000000000..e48c9811fa --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ComposeTestSupport.kt @@ -0,0 +1,30 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import kotlin.coroutines.EmptyCoroutineContext + +private class UnitApplier : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun onClear() = Unit +} + +/** Выполняет composable один раз без UI и возвращает его результат. */ +internal fun composeOnce(content: @Composable () -> T): T { + var result: Any? = null + val recomposer = Recomposer(EmptyCoroutineContext) + val composition = Composition(UnitApplier(), recomposer) + try { + composition.setContent { result = content() } + } finally { + composition.dispose() + recomposer.cancel() + } + @Suppress("UNCHECKED_CAST") + return result as T +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValidationTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValidationTest.kt new file mode 100644 index 0000000000..4eded20771 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValidationTest.kt @@ -0,0 +1,74 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.compose.PreviewPreparationException +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewPropertySource +import com.sdds.preview.contract.PreviewPropertyValue +import com.sdds.preview.contract.PreviewStyle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** Проверка типов вложенных стилей на настоящих сгенерированных фабриках `Tabs`, `TabItem` и `Avatar`. */ +class GeneratedFactoryValidationTest { + private fun ref(styleId: String) = PreviewPropertyValue(PreviewPropertySource.StyleReference(styleId)) + + private fun validate( + componentId: String, + properties: Map, + styles: Map, + ) { + val payload = PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = emptyMap(), + component = PreviewComponent(componentId, properties = properties), + styles = styles, + example = PreviewExample("default"), + ) + GeneratedPreviewStyleFactories.all.getValue(componentId) + .validateNestedStyles(payload, properties, "$.component.properties") + } + + @Test + fun `tabs accept item and divider styles of the matching components`() { + validate( + "Tabs", + mapOf("tabItemStyle" to ref("item"), "dividerStyle" to ref("divider")), + mapOf("item" to PreviewStyle("TabItem"), "divider" to PreviewStyle("Divider")), + ) + } + + @Test + fun `item style of the icon tab component is accepted because it shares the style class`() { + validate("Tabs", mapOf("tabItemStyle" to ref("item")), mapOf("item" to PreviewStyle("IconTabItem"))) + } + + @Test + fun `divider style given to an item property is rejected`() { + val error = assertFailsWith { + validate("Tabs", mapOf("tabItemStyle" to ref("s")), mapOf("s" to PreviewStyle("Divider"))) + } + + assertEquals("style_type_mismatch", error.code) + assertEquals("$.component.properties.tabItemStyle", error.path) + } + + @Test + fun `style of an unknown component is rejected`() { + val error = assertFailsWith { + validate("Avatar", mapOf("badgeStyle" to ref("s")), mapOf("s" to PreviewStyle("Nope"))) + } + + assertEquals("style_component_not_found", error.code) + } + + @Test + fun `nested badge style is accepted for avatar`() { + validate("Avatar", mapOf("badgeStyle" to ref("b")), mapOf("b" to PreviewStyle("Badge"))) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValueKindsTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValueKindsTest.kt new file mode 100644 index 0000000000..b827bedb3e --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValueKindsTest.kt @@ -0,0 +1,120 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import com.sdds.compose.uikit.ButtonStyle +import com.sdds.compose.uikit.CodeFieldStyle +import com.sdds.compose.uikit.TabsStyle +import com.sdds.compose.uikit.graphics.Gradients +import com.sdds.compose.uikit.interactions.InteractiveState +import com.sdds.preview.compose.PreviewStateMap +import com.sdds.preview.compose.ResolvedProperty +import com.sdds.preview.compose.ResolvedState +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.prepareComposeTheme +import com.sdds.preview.compose.toInteractiveColor +import com.sdds.preview.contract.GradientLayer +import com.sdds.preview.contract.GradientStop +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewPropertySource +import com.sdds.preview.contract.PreviewPropertyValue +import com.sdds.preview.contract.TokenValue +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Виды значений, которые не покрывает сравнение с эталоном: градиент, literal-цвет, `InteractiveColor`. */ +class GeneratedFactoryValueKindsTest { + private val gradient = TokenValue.Gradient( + listOf( + GradientLayer(90.0, listOf(GradientStop("#FF0000FF", 0.0), GradientStop("#0000FFFF", 1.0))), + ), + ) + + private fun payload(componentId: String, properties: Map) = PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = mapOf("gradient.main" to gradient, "color.red" to TokenValue.Color("#FF0000FF")), + component = PreviewComponent(componentId, properties = properties), + example = PreviewExample("default"), + ) + + @Suppress("UNCHECKED_CAST") + private fun build(payload: PreviewPayload): S { + val factory = GeneratedPreviewStyleFactories.all.getValue(payload.component.id) + val theme = payload.prepareComposeTheme(mapOf("font.body" to FontFamily.Default)) + return composeOnce { factory.create(payload, theme) } as S + } + + private fun token(id: String, alpha: Double? = null) = PreviewPropertyValue( + PreviewPropertySource.TokenReference(id, alpha), + ) + + @Test + fun `gradient token goes to the Brush overload of a colour property`() { + val style = build(payload("BasicButton", mapOf("backgroundColor" to token("gradient.main")))) + + val expected = Gradients.Linear(listOf(Color(0xFFFF0000), Color(0xFF0000FF)), listOf(0f, 1f), 90f) + assertEquals(expected, style.colors.backgroundBrush.getDefaultValue()) + } + + @Test + fun `gradient switches to the Brush overload when a solid colour uses another overload`() { + // У CodeField.valueColor для сплошного цвета выбирается StatefulValue, а для градиента только Brush. + val solid = build(payload("CodeField", mapOf("valueColor" to token("color.red")))) + val gradientStyle = build(payload("CodeField", mapOf("valueColor" to token("gradient.main")))) + + assertEquals(Color.Red, solid.colors.valueColor.getDefaultValue()) + val expected = Gradients.Linear(listOf(Color(0xFFFF0000), Color(0xFF0000FF)), listOf(0f, 1f), 90f) + assertEquals(expected, gradientStyle.colors.valueBrush.getDefaultValue()) + } + + @Test + fun `gradient for a colour property without a Brush overload is skipped and builds anyway`() { + val factory = GeneratedPreviewStyleFactories.all.getValue("Tabs") + assertTrue("backgroundColor" in factory.gradientUnsupportedProperties) + + val style = build(payload("Tabs", mapOf("backgroundColor" to token("gradient.main")))) + + assertTrue(style.shape.toString().isNotEmpty()) + } + + @Test + fun `literal colour and its alpha are applied`() { + val literal = PreviewPropertySource.Literal(JsonPrimitive("#112233FF")) + val withAlpha = PreviewPropertySource.Literal(JsonPrimitive("#112233FF"), alpha = 0.5) + + val plain = build(payload("BasicButton", mapOf("labelColor" to PreviewPropertyValue(literal)))) + val translucent = + build(payload("BasicButton", mapOf("labelColor" to PreviewPropertyValue(withAlpha)))) + + assertEquals(SolidColor(Color(0xFF112233)), plain.colors.labelBrush.getDefaultValue()) + assertEquals(SolidColor(Color(0xFF112233).copy(alpha = 0.5f)), translucent.colors.labelBrush.getDefaultValue()) + } + + @Test + fun `interactive colour picks the largest matching state set and the later entry among equal sets`() { + val property = ResolvedProperty( + base = Color.Red, + states = listOf( + ResolvedState(setOf("pressed"), Color.Green), + ResolvedState(setOf("pressed"), Color.Blue), + ResolvedState(setOf("pressed", "selected"), Color.White), + ), + ) + val color = property.toInteractiveColor(PreviewStateMap()) + val source = MutableInteractionSource() + + fun probe(vararg states: InteractiveState) = composeOnce { color.colorForInteraction(source, states.toSet()) } + + assertEquals(Color.Red, probe()) + assertEquals(Color.Blue, probe(InteractiveState.Pressed)) + assertEquals(Color.White, probe(InteractiveState.Pressed, InteractiveState.Selected)) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/HostDefaultsTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/HostDefaultsTest.kt new file mode 100644 index 0000000000..a18f09525e --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/HostDefaultsTest.kt @@ -0,0 +1,88 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import com.sdds.compose.uikit.LocalButtonStyle +import com.sdds.compose.uikit.LocalTextStyle +import com.sdds.preview.compose.ProvideHostDefaults +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.prepareComposeTheme +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewDefaultStyles +import com.sdds.preview.contract.PreviewDefaultText +import com.sdds.preview.contract.PreviewDefaults +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewPropertySource +import com.sdds.preview.contract.PreviewPropertyValue +import com.sdds.preview.contract.PreviewStyle +import com.sdds.preview.contract.TokenValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class HostDefaultsTest { + private fun token(id: String) = PreviewPropertyValue(PreviewPropertySource.TokenReference(id)) + + private fun payload(defaults: PreviewDefaults?) = PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = mapOf("color.red" to TokenValue.Color("#FF0000FF"), "color.green" to TokenValue.Color("#00FF00FF")), + component = PreviewComponent("Avatar", properties = emptyMap()), + styles = mapOf("button.default" to PreviewStyle("BasicButton", mapOf("labelColor" to token("color.red")))), + example = PreviewExample("default"), + defaults = defaults, + ) + + /** Значения локалей внутри содержимого: цвет подписи кнопки по умолчанию и цвет текста. */ + private fun provided(payload: PreviewPayload): Pair { + val theme = payload.prepareComposeTheme(mapOf("font.body" to FontFamily.Default)) + return composeOnce { + var result: Pair? = null + ProvideHostDefaults(payload, theme, GeneratedPreviewStyleFactories.all) { + result = LocalButtonStyle.current.colors.labelBrush.getDefaultValue() to LocalTextStyle.current.color + } + result!! + } + } + + @Test + fun `the default button style and text colour from the payload are provided to the content`() { + val (label, text) = provided( + payload( + PreviewDefaults( + text = PreviewDefaultText(color = "color.green"), + styles = PreviewDefaultStyles(button = "button.default"), + ), + ), + ) + + assertEquals(SolidColor(Color.Red), label) + assertEquals(Color(0xFF00FF00), text) + } + + @Test + fun `without defaults the library values stay`() { + val (label, text) = provided(payload(null)) + + assertNotEquals(SolidColor(Color.Red), label) + assertNotEquals(Color(0xFF00FF00), text) + } + + @Test + fun `an unknown style or token is skipped without failing`() { + val (label, text) = provided( + payload( + PreviewDefaults( + text = PreviewDefaultText(color = "color.missing"), + styles = PreviewDefaultStyles(button = "button.missing"), + ), + ), + ) + + assertNotEquals(SolidColor(Color.Red), label) + assertNotEquals(Color(0xFF00FF00), text) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewComponentsFromTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewComponentsFromTest.kt new file mode 100644 index 0000000000..93a670b888 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewComponentsFromTest.kt @@ -0,0 +1,56 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.previewComponentsFrom +import com.sdds.sandbox.StoryEntry +import com.sdds.sandbox.StoryRegistry +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PreviewComponentsFromTest { + @BeforeTest + fun register() { + StoryRegistry.clear() + registerStories() + } + + private val entries get() = StoryRegistry.previewEntries() + private val factories = GeneratedPreviewStyleFactories.all + + @Test + fun `every registry entry becomes a component with its api meta id`() { + val components = previewComponentsFrom(entries, factories) + + assertEquals(entries.map { it.componentId }, components.map { it.componentId }) + assertTrue(components.any { it.componentId == "CheckBox" }) + } + + @Test + fun `entry without a factory is skipped instead of failing`() { + val orphan = entries.first().copy(componentId = "NoSuchComponent") + + val components = previewComponentsFrom(entries + orphan, factories) + + assertEquals(entries.size, components.size) + } + + @Test + fun `style class of the story must match the style class of the factory`() { + val wrong = StoryEntry("CheckBox", entries.first { it.componentId == "CheckBox" }.story, "ui.WrongStyle") + + val error = assertFailsWith { previewComponentsFrom(listOf(wrong), factories) } + + assertTrue("CheckBox" in error.message.orEmpty() && "ui.WrongStyle" in error.message.orEmpty()) + } + + @Test + fun `entry whose style class was not resolved is rejected`() { + val unresolved = entries.first { it.componentId == "CheckBox" }.copy(styleQualifiedName = "") + + assertFailsWith { previewComponentsFrom(listOf(unresolved), factories) } + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewGroupsTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewGroupsTest.kt new file mode 100644 index 0000000000..bb56601e2c --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewGroupsTest.kt @@ -0,0 +1,60 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.sandbox.StoryRegistry +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PreviewGroupsTest { + private val entries get() = StoryRegistry.previewEntries() + + @BeforeTest + fun register() { + StoryRegistry.clear() + registerStories() + } + + @Test + fun `every component of the registry is in exactly one group`() { + val grouped = previewComponentGroups(entries).flatMap { it.components } + + assertEquals(entries.map { it.componentId }.sorted(), grouped.sorted()) + assertEquals(grouped.distinct().size, grouped.size) + } + + @Test + fun `components follow the sandbox groups`() { + val byComponent = previewComponentGroups( + entries, + ).flatMap { group -> group.components.map { it to group.name } }.toMap() + + assertEquals("Data Display", byComponent["CircularProgressBar"]) + assertEquals("Data Display", byComponent["ProgressBar"]) + assertEquals("Navigation", byComponent["DropdownMenu"]) + assertEquals("Data Entry", byComponent["BasicButton"]) + assertEquals("Overlay", byComponent["ModalBottomSheet"]) + } + + @Test + fun `groups keep the sandbox order and components are sorted`() { + val groups = previewComponentGroups(entries) + + assertEquals( + entries.map { + it.story.component.group + }.distinct().sortedBy { it.ordinal }.map { it.displayName }, + groups.map { it.name }, + ) + assertTrue(groups.all { it.components == it.components.sorted() }) + } + + @Test + fun `the manifest json lists groups with their components`() { + val json = listOf(PreviewComponentGroup("Data Display", listOf("Avatar", "Badge"))).toManifestJson() + + assertTrue("\"name\": \"Data Display\"" in json) + assertTrue("\"Avatar\"" in json && "\"Badge\"" in json) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ReferenceStyleComparisonTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ReferenceStyleComparisonTest.kt new file mode 100644 index 0000000000..8e3e325e38 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ReferenceStyleComparisonTest.kt @@ -0,0 +1,244 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sdds.compose.uikit.ButtonStyle +import com.sdds.compose.uikit.CheckBoxStates +import com.sdds.compose.uikit.CheckBoxStyle +import com.sdds.compose.uikit.DividerStyle +import com.sdds.compose.uikit.TabItemStyle +import com.sdds.compose.uikit.TabsStyle +import com.sdds.compose.uikit.basicButtonBuilder +import com.sdds.compose.uikit.interactions.InteractiveState +import com.sdds.compose.uikit.interactions.StatefulValue +import com.sdds.compose.uikit.interactions.ValueState +import com.sdds.compose.uikit.interactions.asStatefulValue +import com.sdds.preview.compose.PreparedComposeTheme +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.prepareComposeTheme +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewPropertySource +import com.sdds.preview.contract.PreviewPropertyValue +import com.sdds.preview.contract.PreviewStateEntry +import com.sdds.preview.contract.PreviewStyle +import com.sdds.preview.contract.TokenValue +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Сгенерированная фабрика должна строить тот же стиль, что и прямой вызов билдера UIKit с теми же значениями. + * Эталон написан вручную и не использует помощники SDK (`toStateful`, выбор перегрузки): сравнивается поведение + * стиля (`getValue` для наборов состояний) и значения, которые видит компонент. По одному компоненту на фазу: + * A (BasicButton), B (CheckBox, кастомные состояния), C (Tabs, вложенные стили). + */ +class ReferenceStyleComparisonTest { + private val background = Color(0xFF2A72E5) + private val pressed = Color(0xFF1959B8) + private val content = Color(0xFF202124) + private val shape = RoundedCornerShape(12.dp, 12.dp, 12.dp, 12.dp) + private val label = TextStyle( + fontFamily = FontFamily.Default, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp, + fontWeight = FontWeight(400), + ) + + private val tokens: Map = mapOf( + "color.background" to TokenValue.Color("#2A72E5FF"), + "color.background.pressed" to TokenValue.Color("#1959B8FF"), + "color.content" to TokenValue.Color("#202124FF"), + "size.height" to TokenValue.Dimension(48.0), + "size.padding" to TokenValue.Dimension(20.0), + "shape.button" to TokenValue.Shape(listOf(12.0, 12.0, 12.0, 12.0)), + "font.body" to TokenValue.FontFamily(emptyList(), allowFallback = true), + "typography.button" to TokenValue.Typography("font.body", 16.0, 20.0, 0.0, 400), + ) + + private fun token(id: String, alpha: Double? = null) = PreviewPropertySource.TokenReference(id, alpha) + + private fun entry(source: PreviewPropertySource, vararg states: String) = PreviewStateEntry(states.toList(), source) + + private fun payload( + componentId: String, + properties: Map, + styles: Map = emptyMap(), + ) = PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = tokens, + component = PreviewComponent(componentId, properties = properties), + styles = styles, + example = PreviewExample("default"), + ) + + private fun theme(payload: PreviewPayload): PreparedComposeTheme = + payload.prepareComposeTheme(mapOf("font.body" to FontFamily.Default)) + + @Suppress("UNCHECKED_CAST") + private fun generated(payload: PreviewPayload): S { + val factory = GeneratedPreviewStyleFactories.all.getValue(payload.component.id) + val theme = theme(payload) + return composeOnce { factory.create(payload, theme) } as S + } + + private fun StatefulValue.probe(vararg sets: Set): List = sets.map { getValue(it) } + + @Test + fun `BasicButton factory matches a style built by hand`() { + val payload = payload( + "BasicButton", + mapOf( + "shape" to PreviewPropertyValue(token("shape.button")), + "labelStyle" to PreviewPropertyValue(token("typography.button")), + "disableAlpha" to PreviewPropertyValue(PreviewPropertySource.Literal(JsonPrimitive(0.4))), + "height" to PreviewPropertyValue(token("size.height")), + "paddingStart" to PreviewPropertyValue(token("size.padding")), + "backgroundColor" to PreviewPropertyValue( + base = token("color.background"), + states = listOf( + entry(token("color.background.pressed"), "pressed"), + entry(token("color.background", alpha = 0.5), "pressed", "selected"), + // Две записи для одного набора: побеждает последняя, как задаёт Preview Protocol. + entry(token("color.content"), "pressed"), + ), + ), + ), + ) + val actual = generated(payload) + + val expected = composeOnce { + ButtonStyle.basicButtonBuilder() + .shape(shape) + .labelStyle(label) + .disableAlpha(0.4f) + .colors { + backgroundColor( + SolidColor(background).asStatefulValue( + setOf(InteractiveState.Pressed) to SolidColor(content), + setOf(InteractiveState.Pressed, InteractiveState.Selected) to + SolidColor(background.copy(alpha = 0.5f)), + ), + ) + } + .dimensions { + height(48.dp) + paddingStart(20.dp) + } + .style() + } + + assertEquals(expected.shapes.getDefaultValue(), actual.shapes.getDefaultValue()) + assertEquals(expected.labelStyles.getDefaultValue(), actual.labelStyles.getDefaultValue()) + assertEquals(expected.disableAlpha, actual.disableAlpha) + assertEquals( + expected.dimensions.heightValues.getDefaultValue(), + actual.dimensions.heightValues.getDefaultValue(), + ) + assertEquals( + expected.dimensions.paddingStartValues.getDefaultValue(), + actual.dimensions.paddingStartValues.getDefaultValue(), + ) + val probes = arrayOf>( + emptySet(), + setOf(InteractiveState.Pressed), + setOf(InteractiveState.Hovered), + setOf(InteractiveState.Pressed, InteractiveState.Selected), + ) + assertEquals(expected.colors.backgroundBrush.probe(*probes), actual.colors.backgroundBrush.probe(*probes)) + } + + @Test + fun `CheckBox factory matches a style built by hand including custom and combined states`() { + val payload = payload( + "CheckBox", + mapOf( + "shape" to PreviewPropertyValue(token("shape.button")), + "toggleColor" to PreviewPropertyValue( + base = token("color.background"), + states = listOf( + entry(token("color.background.pressed"), "checked"), + entry(token("color.background", alpha = 0.5), "Checked", "pressed"), + entry(token("color.content"), "indeterminate"), + ), + ), + ), + ) + val actual = generated(payload) + + val expected = composeOnce { + CheckBoxStyle.builder() + .shape(shape) + .colorValues { + toggleColor( + SolidColor(background).asStatefulValue( + setOf(CheckBoxStates.Checked) to SolidColor(pressed), + setOf(CheckBoxStates.Checked, InteractiveState.Pressed) to + SolidColor(background.copy(alpha = 0.5f)), + setOf(CheckBoxStates.Indeterminate) to SolidColor(content), + ), + ) + } + .style() + } + + assertEquals(expected.shapes.getDefaultValue(), actual.shapes.getDefaultValue()) + val probes = arrayOf>( + emptySet(), + setOf(CheckBoxStates.Checked), + setOf(CheckBoxStates.Checked, InteractiveState.Pressed), + setOf(CheckBoxStates.Indeterminate), + setOf(CheckBoxStates.Error), + ) + assertEquals(expected.colorValues.toggleBrush.probe(*probes), actual.colorValues.toggleBrush.probe(*probes)) + } + + @Test + fun `Tabs factory matches a style built by hand including nested item and divider styles`() { + fun ref(id: String) = PreviewPropertyValue(PreviewPropertySource.StyleReference(id)) + val payload = payload( + "Tabs", + mapOf("tabItemStyle" to ref("item"), "dividerStyle" to ref("divider")), + mapOf( + "item" to PreviewStyle( + "TabItem", + mapOf( + "shape" to PreviewPropertyValue(token("shape.button")), + "labelStyle" to PreviewPropertyValue(token("typography.button")), + ), + ), + "divider" to PreviewStyle( + "Divider", + mapOf("backgroundColor" to PreviewPropertyValue(token("color.content"))), + ), + ), + ) + val actual = generated(payload) + + val expected = composeOnce { + val item = TabItemStyle.builder().shape(shape).labelStyle(label).style() + val divider = DividerStyle.builder().color { backgroundColor(content) }.style() + TabsStyle.builder().tabItemStyle(item).dividerStyle(divider).style() + } + + assertEquals(expected.tabItemStyle.shape, actual.tabItemStyle.shape) + assertEquals( + expected.tabItemStyle.labelStyles.getDefaultValue(), + actual.tabItemStyle.labelStyles.getDefaultValue(), + ) + assertEquals( + expected.dividerStyle.color.backgroundBrush.getDefaultValue(), + actual.dividerStyle.color.backgroundBrush.getDefaultValue(), + ) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/StyleFactoryCoverageTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/StyleFactoryCoverageTest.kt new file mode 100644 index 0000000000..db7cca6dc0 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/StyleFactoryCoverageTest.kt @@ -0,0 +1,91 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.sandbox.StoryRegistry +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Сверяет три набора: компоненты `uikit-compose-api-meta.json`, сгенерированные фабрики стилей и истории реестра. + * Расхождение, которого нет в [storiesWithoutPreview], роняет тест. + */ +class StyleFactoryCoverageTest { + /** Истории без компонента меты: `ComponentKey.value` на причину. */ + private val storiesWithoutPreview = mapOf( + "Icon" to "стиль NoStyle: у иконки нет настраиваемого стиля", + "Text" to "стиль NoStyle: у текста нет настраиваемого стиля", + "IconTabs" to "компонента IconTabs нет в мете, стиль TabsStyle принадлежит компоненту Tabs", + "Mask" to "компонента Mask нет в мете, стиль TextFieldStyle принадлежит TextField и TextArea", + ) + + private val metaStyles: Map = run { + val text = requireNotNull( + javaClass.classLoader.getResource("sdds/api/uikit-compose-api-meta.json"), + ) { "Нет ресурса api-меты на classpath" }.readText() + Json.parseToJsonElement(text).jsonArray.associate { + val component = it.jsonObject + component.getValue("componentName").jsonPrimitive.content to + component.getValue("styleQualifiedName").jsonPrimitive.content + } + } + + private val factories = GeneratedPreviewStyleFactories.all + + @BeforeTest + fun register() { + StoryRegistry.clear() + registerStories() + } + + @Test + fun `every api meta component has a generated factory and nothing else does`() { + assertEquals(metaStyles.keys, factories.keys) + } + + @Test + fun `factory builds the style class named in the api meta`() { + metaStyles.forEach { (name, style) -> + assertEquals(style, factories.getValue(name).styleQualifiedName, "Стиль фабрики $name") + } + } + + @Test + fun `every api meta component has a story and its style class matches the factory`() { + val entries = StoryRegistry.previewEntries().associateBy { it.componentId } + + assertEquals(metaStyles.keys, entries.keys) + entries.forEach { (id, entry) -> + assertEquals( + factories.getValue(id).styleQualifiedName, + entry.styleQualifiedName, + "Класс стиля истории и фабрики $id", + ) + } + } + + @Test + fun `stories outside the preview registry are exactly the documented exclusions`() { + val previewKeys = StoryRegistry.previewEntries().map { it.story.component }.toSet() + val excluded = StoryRegistry.registeredStories().map { it.component }.filterNot { it in previewKeys } + + assertEquals(storiesWithoutPreview.keys, excluded.map { it.value }.toSet()) + } + + @Test + fun `every nested style type is built by some factory`() { + val builtStyles = factories.values.map { it.styleQualifiedName }.toSet() + + factories.forEach { (name, factory) -> + factory.nestedStyleTypes.forEach { (property, style) -> + assertTrue(style in builtStyles, "$name.$property ожидает $style, которого не строит ни одна фабрика") + } + } + } +} diff --git a/integration-core/preview-compose-plugin/src/manifest/preview-plugin.template.json b/integration-core/preview-compose-plugin/src/manifest/preview-plugin.template.json new file mode 100644 index 0000000000..6218d8adcc --- /dev/null +++ b/integration-core/preview-compose-plugin/src/manifest/preview-plugin.template.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "sdds.compose.preview", + "name": "SDDS Compose Preview", + "platform": "compose", + "protocolVersion": 1, + "entrypoint": "index.html", + "commit": "", + "version": "", + "uikitComposeVersion": "", + "apiMetaSha256": "", + "payloadBridge": { + "direct": "submitPreviewPayload", + "describe": "describePreviewComponent", + "payloadMessageType": "sdds.preview.payload", + "readyMessageType": "sdds.preview.ready", + "resultMessageType": "sdds.preview.result", + "describeMessageType": "sdds.preview.describe", + "descriptionMessageType": "sdds.preview.description" + }, + "components": [] +} diff --git a/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/HttpPreviewAssetResolver.kt b/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/HttpPreviewAssetResolver.kt new file mode 100644 index 0000000000..7b70a9221e --- /dev/null +++ b/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/HttpPreviewAssetResolver.kt @@ -0,0 +1,27 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.compose.PreviewAssetResolver +import com.sdds.preview.contract.PreviewAsset +import kotlinx.coroutines.await +import org.khronos.webgl.Int8Array +import kotlin.js.Promise + +/** Browser resolver, загружающий TTF/OTF assets по URL из payload. */ +public object HttpPreviewAssetResolver : PreviewAssetResolver { + override suspend fun resolve(asset: PreviewAsset): ByteArray { + val bytes: Int8Array = fetchBytes(asset.url).await() + require(bytes.length > 0) { "Ресурс ${asset.id} не содержит данных" } + return ByteArray(bytes.length) { index -> byteAt(bytes, index) } + } +} + +@JsFun( + """(url) => fetch(url).then(response => { + if (!response.ok) throw new Error(`HTTP ${'$'}{response.status} for ${'$'}{url}`); + return response.arrayBuffer(); + }).then(buffer => new Int8Array(buffer))""", +) +private external fun fetchBytes(url: String): Promise + +@JsFun("(array, index) => array[index]") +private external fun byteAt(array: Int8Array, index: Int): Byte diff --git a/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/Main.kt b/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/Main.kt new file mode 100644 index 0000000000..76371f2f19 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/Main.kt @@ -0,0 +1,165 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.window.ComposeViewport +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.compose.uikit.overlay.OverlayHost +import com.sdds.compose.uikit.overlay.OverlayManagerLifecycle +import com.sdds.compose.uikit.overlay.overlayManager +import com.sdds.preview.compose.CachingLoadedFontFamilyFactory +import com.sdds.preview.compose.CachingPreviewAssetResolver +import com.sdds.preview.compose.ComposePreviewRuntime +import com.sdds.preview.compose.PreparedComposePreview +import com.sdds.preview.compose.WasmLoadedFontFamilyFactory +import com.sdds.preview.compose.generated.GeneratedPreviewIcons +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.previewComponentsFrom +import com.sdds.preview.contract.PREVIEW_PROTOCOL_VERSION +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewResult +import com.sdds.sandbox.StoryRegistry +import kotlinx.browser.document +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlin.js.ExperimentalJsExport + +private sealed interface RenderState { + data object Empty : RenderState + data class Ready(val preview: PreparedComposePreview) : RenderState +} + +private val scope = MainScope() +private var renderState: RenderState by mutableStateOf(RenderState.Empty) +private val runtime = ComposePreviewRuntime( + // Редактор присылает одни и те же шрифты при каждом переключении компонента: байты и семейства переиспользуются. + assetResolver = CachingPreviewAssetResolver(HttpPreviewAssetResolver), + fontFamilyFactory = CachingLoadedFontFamilyFactory(WasmLoadedFontFamilyFactory), + icons = GeneratedPreviewIcons.all, + components = run { + registerStories() + previewComponentsFrom(StoryRegistry.previewEntries(), GeneratedPreviewStyleFactories.all) + }, +) +private val coordinator = PreviewRequestCoordinator( + prepare = runtime::prepare, + apply = { preview -> + renderState = RenderState.Ready(preview) + document.documentElement?.setAttribute("data-sdds-preview-request-id", preview.requestId) + }, +) + +private val pendingRenders = PendingRenderResults { result -> + notifyPreviewResult(PreviewContractJson.encodeToString(result)) +} + +/** + * Принимает следующий полный payload. Успех сообщается browser bridge только после отрисовки содержимого, + * ошибки подготовки и применения state сообщаются сразу. + */ +@OptIn(ExperimentalJsExport::class) +@JsExport +public fun submitPreviewPayload(json: String) { + scope.launch { + when (val result = coordinator.submit(json)) { + is PreviewResult.Success -> pendingRenders.await(result) + else -> notifyPreviewResult(PreviewContractJson.encodeToString(result)) + } + } +} + +/** Возвращает сериализованное описание зарегистрированной story. */ +@OptIn(ExperimentalJsExport::class) +@JsExport +public fun describePreviewComponent(componentId: String, requestId: String): String = + PreviewContractJson.encodeToString(runtime.describe(componentId, requestId)) + +/** Browser entrypoint типизированного BasicButton PoC. */ +@OptIn(ExperimentalComposeUiApi::class) +public fun main() { + if (document.getElementById("root") == null) { + document.body?.appendChild(document.createElement("div").apply { id = "root" }) + } + ComposeViewport(viewportContainerId = "root") { PreviewViewport() } + notifyPreviewReady(PREVIEW_PROTOCOL_VERSION) +} + +@Composable +private fun PreviewViewport() { + val state = renderState + val backgroundModifier = when (state) { + RenderState.Empty -> Modifier + is RenderState.Ready -> state.preview.surfaceBackground?.let { Modifier.background(it) } ?: Modifier + } + Box( + modifier = Modifier.fillMaxSize().then(backgroundModifier), + contentAlignment = Alignment.Center, + ) { + when (state) { + RenderState.Empty -> Unit + // Toast и Notification показываются через LocalOverlayManager: как и песочница, плагин предоставляет хост оверлеев. + // Каждый payload получает свой менеджер оверлеев: уведомления прошлого render не должны оставаться на экране. + is RenderState.Ready -> key(state.preview.requestId) { + // Эффект запускается после композиции содержимого; два кадра подтверждают, что layout и draw прошли. + // Исключение в кадре останавливает Compose, и подтверждения не будет. + LaunchedEffect(state.preview.requestId) { + withFrameNanos { } + withFrameNanos { } + pendingRenders.confirm(state.preview.requestId) + } + OverlayHost(overlayManager = overlayManager(OverlayManagerLifecycle.ComposableScoped)) { + // OverlayHost кладёт содержимое в левый верхний угол, поэтому центрируем его внутри хоста. + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CrashCause { state.preview.Content() } + } + } + } + } + } +} + +/** + * Исключение Kotlin/Wasm доходит до браузера как `WebAssembly.Exception` без текста и stack, поэтому причину сбоя layout + * фиксируем здесь: [measure] содержимого выполняется внутри `try`, а текст и stack уходят в `reportCrashCause`. + * Размер и положение содержимого остаются теми же, что без обёртки. Сбои композиции и отрисовки обёртка не видит. + */ +@Suppress("TooGenericExceptionCaught") // Любое исключение фиксируется и бросается дальше без изменений. +@Composable +private fun CrashCause(content: @Composable () -> Unit) { + Layout(content = content) { measurables, constraints -> + val placeables = try { + measurables.map { it.measure(constraints) } + } catch (error: Throwable) { + reportCrashCause(error.toString(), error.stackTraceToString()) + throw error + } + layout( + width = placeables.maxOfOrNull { it.width } ?: constraints.minWidth, + height = placeables.maxOfOrNull { it.height } ?: constraints.minHeight, + ) { + placeables.forEach { it.place(0, 0) } + } + } +} + +@JsFun("(result) => globalThis.__sddsPreviewResult(result)") +private external fun notifyPreviewResult(result: String) + +@JsFun("(protocolVersion) => globalThis.__sddsPreviewReady(protocolVersion)") +private external fun notifyPreviewReady(protocolVersion: Int) + +@JsFun("(message, stack) => { globalThis.__sddsPreviewCrashCause = { message, stack }; }") +private external fun reportCrashCause(message: String, stack: String) diff --git a/integration-core/preview-compose-plugin/src/wasmJsMain/resources/index.html b/integration-core/preview-compose-plugin/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000000..b2269957ce --- /dev/null +++ b/integration-core/preview-compose-plugin/src/wasmJsMain/resources/index.html @@ -0,0 +1,257 @@ + + + + + + SDDS Compose Preview + + + +
+ + + + diff --git a/integration-core/preview-contract/README.md b/integration-core/preview-contract/README.md new file mode 100644 index 0000000000..d50c7654b1 --- /dev/null +++ b/integration-core/preview-contract/README.md @@ -0,0 +1,42 @@ +# Preview Contract + +Kotlin Multiplatform artifact `io.github.salute-developers:preview-contract` +описывает минимальную renderer-ready инструкцию DS Builder Preview Protocol v1. +Модуль не зависит от Compose, Android UI, renderer или `sandbox-*`. + +## Модель + +`PreviewPayload` содержит только данные, необходимые для одного полного render: +platform, effective theme values, effective component configuration, runtime example +props и необязательный surface. Ответ представлен `PreviewResult.Success` или +`PreviewResult.Failure`. + +Assembler обязан до отправки payload разрешить palette references, применить +platform adjustments, вычислить effective properties и раскрыть dependencies/reuse. +Эта история вычисления не является частью wire contract. + +Incremental patches, plugin manifests, asset loading и расширенная диагностика не +входят в v1. Они должны проектироваться отдельными контрактами при появлении +соответствующих runtime consumers. + +## Source of truth + +JSON Schema Draft 2020-12 в `src/commonMain/resources/schemas/v1` является source +of truth. Canonical fixtures публикуются рядом со schemas. Kotlin-модели +`com.sdds.preview.contract` проверяются schema/serialization round-trip tests. + +## Copy of the canonical contract + +`schemas/v1/preview-payload.schema.json` and `fixtures/v1/shared` are byte-identical copies of +`design-system-builder/backend-kt/preview/contract`. `schemas/v1/DIGEST` records their SHA-256: + +- `SchemaContractTest` fails when a file of the copy no longer matches `DIGEST` (a manual edit of the copy); +- the `Preview contract check` workflow compares `DIGEST` with the canonical `CONTRACT.sha256`: a pull request gets a warning, + `release/*`, `hotfix/*`, `main` and the nightly run fail. The reference is set by the repository variable `DSB_CONTRACT_REF` + (default `dev`). + +Update the copy from a design-system-builder checkout: + +```bash +backend-kt/preview/contract/fingerprint.sh --sync-to /integration-core/preview-contract/src/commonMain/resources +``` diff --git a/integration-core/preview-contract/build.gradle.kts b/integration-core/preview-contract/build.gradle.kts new file mode 100644 index 0000000000..7c59bd3ea0 --- /dev/null +++ b/integration-core/preview-contract/build.gradle.kts @@ -0,0 +1,29 @@ +import utils.addDefaultTargets + +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("convention.kmp-lib") + alias(libs.plugins.kotlin.serialization) +} + +group = "integration-core" + +android { + namespace = "com.sdds.preview.contract" +} + +kotlin { + addDefaultTargets() + + sourceSets { + commonMain.dependencies { + api(libs.base.kotlin.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + jvmTest.dependencies { + implementation("com.networknt:json-schema-validator:1.0.87") + } + } +} diff --git a/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewComponentDescription.kt b/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewComponentDescription.kt new file mode 100644 index 0000000000..6dd73e1d31 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewComponentDescription.kt @@ -0,0 +1,118 @@ +package com.sdds.preview.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Runtime-neutral описание зарегистрированной component story. */ +@Serializable +public data class PreviewComponentDescription( + /** Версия Preview Protocol, в которой опубликовано описание. */ + val protocolVersion: Int = PREVIEW_PROTOCOL_VERSION, + /** Идентификатор компонента, принимаемый renderer. */ + val componentId: String, + /** Идентификатор story, используемой renderer. */ + val storyId: String, + /** Поддерживаемые example properties в порядке story producer. */ + val properties: List, +) + +/** Runtime-neutral описание example property и его типизированного default value. */ +@Serializable +public sealed interface PreviewExamplePropertyDescription { + /** Wire key, принимаемый state transformer. */ + public val name: String + + /** Строковое property. */ + @Serializable + @SerialName("string") + public data class StringProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: String, + ) : PreviewExamplePropertyDescription + + /** Логическое property. */ + @Serializable + @SerialName("boolean") + public data class BooleanProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: Boolean, + ) : PreviewExamplePropertyDescription + + /** Целочисленное property. */ + @Serializable + @SerialName("int") + public data class IntProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: Int, + ) : PreviewExamplePropertyDescription + + /** Property с плавающей точкой. */ + @Serializable + @SerialName("float") + public data class FloatProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: Float, + ) : PreviewExamplePropertyDescription + + /** Property с выбором одного значения из фиксированного списка. */ + @Serializable + @SerialName("singleChoice") + public data class SingleChoiceProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: String, + /** Допустимые значения property. */ + val variants: List, + ) : PreviewExamplePropertyDescription +} + +/** Коррелированный результат запроса component description. */ +@Serializable +public sealed interface PreviewComponentDescriptionResult { + /** Идентификатор исходного describe request. */ + public val requestId: String + + /** Description успешно получено. */ + @Serializable + @SerialName("success") + public data class Success( + override val requestId: String, + /** Описание зарегистрированной story. */ + val description: PreviewComponentDescription, + ) : PreviewComponentDescriptionResult + + /** Description не удалось получить. */ + @Serializable + @SerialName("failure") + public data class Failure( + override val requestId: String, + /** Стабильный код ошибки. */ + val code: String, + /** Сообщение для человека. */ + val message: String, + ) : PreviewComponentDescriptionResult +} + +/** Проверяет semantic constraints component description. */ +public fun PreviewComponentDescription.validate(): List { + val errors = mutableListOf() + if (protocolVersion != PREVIEW_PROTOCOL_VERSION) errors += "protocolVersion не поддерживается" + if (componentId.isBlank()) errors += "componentId не должен быть пустым" + if (storyId.isBlank()) errors += "storyId не должен быть пустым" + val duplicateNames = properties.groupingBy { it.name }.eachCount().filterValues { it > 1 }.keys + if (duplicateNames.isNotEmpty()) errors += "Имена properties должны быть уникальны: $duplicateNames" + properties.forEach { property -> + if (property.name.isBlank()) errors += "Имя property не должно быть пустым" + if (property is PreviewExamplePropertyDescription.SingleChoiceProperty) { + if (property.variants.isEmpty()) errors += "${property.name}: variants не должны быть пустыми" + if (property.defaultValue !in property.variants) { + errors += "${property.name}: defaultValue должен входить в variants" + } + } + } + return errors +} diff --git a/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewContract.kt b/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewContract.kt new file mode 100644 index 0000000000..87db925232 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewContract.kt @@ -0,0 +1,308 @@ +package com.sdds.preview.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** Текущая версия Preview Protocol. */ +public const val PREVIEW_PROTOCOL_VERSION: Int = 1 + +/** Платформа preview renderer. */ +@Serializable +public enum class PreviewPlatform { + /** Android View renderer. */ + ANDROID, + + /** Compose renderer. */ + COMPOSE, + + /** Web renderer. */ + WEB, + + /** iOS renderer. */ + IOS, +} + +/** Тип runtime-ресурса preview. */ +@Serializable +public enum class PreviewAssetType { + /** Шрифт TrueType. */ + TTF, + + /** Шрифт OpenType. */ + OTF, + + /** Векторное изображение SVG. */ + SVG, + + /** Растровое изображение PNG. */ + PNG, +} + +/** Описание ресурса, доступного renderer во время выполнения. */ +@Serializable +public data class PreviewAsset( + /** Стабильный идентификатор ресурса. */ val id: String, + /** Тип ресурса. */ val type: PreviewAssetType, + /** URL для загрузки ресурса. */ val url: String, + /** Необязательный digest содержимого для проверки и кеширования. */ val digest: String? = null, +) + +/** Начертание шрифта. */ +@Serializable +public enum class PreviewFontStyle { + /** Обычное начертание. */ + NORMAL, + + /** Курсивное начертание. */ + ITALIC, +} + +/** Начертание font-family, связанное с runtime-ресурсом. */ +@Serializable +public data class PreviewFontFace( + /** Идентификатор font asset. */ val assetId: String, + /** Числовой вес шрифта. */ val weight: Int, + /** Стиль шрифта. */ val style: PreviewFontStyle = PreviewFontStyle.NORMAL, +) + +/** Нормализованное значение токена, готовое к использованию renderer. */ +@Serializable +public sealed interface TokenValue { + /** Цвет. */ + @Serializable + @SerialName("color") + public data class Color(/** Значение цвета. */ val value: String) : TokenValue + + /** Размер в dp. */ + @Serializable + @SerialName("dimension") + public data class Dimension(/** Значение в dp. */ val value: Double) : TokenValue + + /** Форма с радиусами углов в dp. */ + @Serializable + @SerialName("shape") + public data class Shape(/** Радиусы углов. */ val cornerRadii: List) : TokenValue + + /** Тень. */ + @Serializable + @SerialName("shadow") + public data class Shadow(/** Слои тени. */ val layers: List) : TokenValue + + /** Градиент из линейных слоёв. */ + @Serializable + @SerialName("gradient") + public data class Gradient(/** Слои градиента. */ val layers: List) : TokenValue + + /** Семейство runtime-шрифтов. */ + @Serializable + @SerialName("fontFamily") + public data class FontFamily( + /** Доступные начертания семейства. */ val faces: List, + /** Разрешить системный fallback при недоступности font asset. */ val allowFallback: Boolean = false, + ) : TokenValue + + /** Параметры текста. */ + @Serializable + @SerialName("typography") + public data class Typography( + /** Идентификатор font-family token. */ val fontFamilyTokenId: String, + /** Размер шрифта в sp. */ val fontSize: Double, + /** Высота строки в sp. */ val lineHeight: Double, + /** Межбуквенный интервал в sp. */ val letterSpacing: Double, + /** Вес шрифта. */ val weight: Int, + ) : TokenValue +} + +/** Источник effective-значения component property. */ +@Serializable +public sealed interface PreviewPropertySource { + /** Нормализованное literal-значение. */ + @Serializable + @SerialName("literal") + public data class Literal( + /** Значение без platform UI типов. */ val value: JsonElement, + /** Прозрачность от 0 до 1, применяемая к цвету. */ val alpha: Double? = null, + ) : PreviewPropertySource + + /** Ссылка на token из [PreviewPayload.theme]. */ + @Serializable + @SerialName("tokenRef") + public data class TokenReference( + /** Идентификатор token. */ val tokenId: String, + /** Прозрачность от 0 до 1, применяемая к цвету. */ val alpha: Double? = null, + ) : PreviewPropertySource + + /** Ссылка на вложенный стиль из [PreviewPayload.styles]. */ + @Serializable + @SerialName("styleRef") + public data class StyleReference(/** Идентификатор стиля. */ val styleId: String) : PreviewPropertySource + + /** Ссылка на ресурс из [PreviewPayload.assets]. */ + @Serializable + @SerialName("assetRef") + public data class AssetReference(/** Идентификатор ресурса. */ val assetId: String) : PreviewPropertySource +} + +/** Значение property для набора одновременно активных состояний. */ +@Serializable +public data class PreviewStateEntry( + /** Имена состояний; запись действует, когда активны все. */ + @SerialName("when") val states: List, + /** Значение для этого набора состояний. */ val source: PreviewPropertySource, +) + +/** Effective-значение property с необязательными значениями для наборов состояний. */ +@Serializable +public data class PreviewPropertyValue( + /** Базовое значение. */ val base: PreviewPropertySource, + /** Упорядоченные значения для наборов состояний. */ + val states: List = emptyList(), +) + +/** Вложенный стиль компонента, на который ссылается [PreviewPropertySource.StyleReference]. */ +@Serializable +public data class PreviewStyle( + /** Идентификатор компонента, которому принадлежит стиль. */ val component: String, + /** Вычисленные properties стиля. */ val properties: Map = emptyMap(), +) + +/** Точка градиента. */ +@Serializable +public data class GradientStop( + /** Цвет в формате `#RRGGBBAA`. */ val color: String, + /** Позиция от 0 до 1. */ val position: Double, +) + +/** Линейный слой градиента. */ +@Serializable +public data class GradientLayer( + /** Угол в градусах по соглашению CSS: 0° снизу вверх, 90° слева направо. */ val angle: Double, + /** Точки градиента, не менее двух. */ val stops: List, + /** Вид слоя. */ val kind: String = "linear", +) + +/** Один слой тени. */ +@Serializable +public data class ShadowLayer( + /** X-смещение в dp. */ val x: Double, + /** Y-смещение в dp. */ val y: Double, + /** Радиус размытия в dp. */ val blur: Double, + /** Растяжение в dp. */ val spread: Double, + /** Цвет. */ val color: String, +) + +/** Effective-конфигурация компонента, готовая к rendering. */ +@Serializable +public data class PreviewComponent( + /** Идентификатор компонента. */ val id: String, + /** Выбранные styles по variation axes. */ val variations: Map = emptyMap(), + /** Вычисленные component properties. */ val properties: Map = emptyMap(), +) + +/** Runtime-данные демонстрационного экземпляра. */ +@Serializable +public data class PreviewExample( + /** Идентификатор story. */ val id: String, + /** Runtime properties и content. */ val props: Map = emptyMap(), +) + +/** Необязательные параметры области rendering. */ +@Serializable +public data class PreviewSurface( + /** Ширина в pixels. */ val width: Int, + /** Высота в pixels. */ val height: Int, + /** Цвет фона. */ val background: String? = null, +) + +/** Текст по умолчанию: идентификаторы токенов `typography` и `color` темы. */ +@Serializable +public data class PreviewDefaultText( + /** Токен стиля текста. */ val style: String? = null, + /** Токен цвета текста. */ val color: String? = null, +) + +/** Стили по умолчанию: идентификаторы вложенных стилей из `styles` для компонентов, которые истории рисуют без стиля. */ +@Serializable +public data class PreviewDefaultStyles( + /** Стиль кнопок. */ val button: String? = null, + /** Стиль Switch. */ val switch: String? = null, + /** Стиль Spinner. */ val spinner: String? = null, +) + +/** Стили и текст по умолчанию вспомогательных элементов истории; значения берутся из темы дизайн-системы. */ +@Serializable +public data class PreviewDefaults( + /** Текст по умолчанию. */ val text: PreviewDefaultText? = null, + /** Стили по умолчанию. */ val styles: PreviewDefaultStyles? = null, +) + +/** Полная инструкция для одного preview render. */ +@Serializable +public data class PreviewPayload( + /** Версия протокола. */ val protocolVersion: Int = PREVIEW_PROTOCOL_VERSION, + /** Идентификатор render request. */ val requestId: String, + /** Платформа renderer. */ val platform: PreviewPlatform, + /** Runtime-ресурсы, необходимые renderer. */ val assets: List = emptyList(), + /** Вычисленные значения темы по token ID. */ val theme: Map, + /** Effective-конфигурация компонента. */ val component: PreviewComponent, + /** Вложенные стили по идентификаторам. */ val styles: Map = emptyMap(), + /** Runtime-данные примера. */ val example: PreviewExample, + /** Параметры области rendering. */ val surface: PreviewSurface? = null, + /** Стили и текст по умолчанию вспомогательных элементов истории. */ val defaults: PreviewDefaults? = null, +) + +/** Результат обработки preview request. */ +@Serializable +public sealed interface PreviewResult { + /** Идентификатор исходного request. */ + public val requestId: String + + /** Rendering успешно завершён. */ + @Serializable + @SerialName("success") + public data class Success( + override val requestId: String, + /** Предупреждения, не меняющие статус результата. */ val warnings: List = emptyList(), + ) : PreviewResult + + /** Rendering завершён ошибкой. */ + @Serializable + @SerialName("failure") + public data class Failure( + override val requestId: String, + /** Стабильный код ошибки. */ val code: String, + /** Сообщение для человека. */ val message: String, + /** Путь к проблемному полю. */ val path: String? = null, + ) : PreviewResult +} + +/** Предупреждение успешного результата. */ +@Serializable +public data class PreviewWarning( + /** Стабильный код предупреждения. */ val code: String, + /** Сообщение для человека. */ val message: String, + /** Путь к проблемному полю. */ val path: String? = null, +) + +/** JSON-конфигурация Preview Protocol. */ +public val PreviewContractJson: Json = Json { + classDiscriminator = "type" + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true +} + +/** Проверяет поддерживаемую версию payload. */ +public fun PreviewPayload.validateVersion(): PreviewResult.Failure? = + if (protocolVersion == PREVIEW_PROTOCOL_VERSION) { + null + } else { + PreviewResult.Failure( + requestId = requestId, + code = "unsupported_protocol_version", + message = "Preview Protocol $protocolVersion не поддерживается", + path = "$.protocolVersion", + ) + } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-component-description-result-invalid-default.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-component-description-result-invalid-default.json new file mode 100644 index 0000000000..a4f4eb5ced --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-component-description-result-invalid-default.json @@ -0,0 +1,12 @@ +{ + "type": "success", + "requestId": "describe-1", + "description": { + "protocolVersion": 1, + "componentId": "BasicButton", + "storyId": "BasicButton", + "properties": [ + { "type": "boolean", "name": "enabled", "defaultValue": "true" } + ] + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-font-asset.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-font-asset.json new file mode 100644 index 0000000000..3ded111ddc --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-font-asset.json @@ -0,0 +1,9 @@ +{ + "protocolVersion": 1, + "requestId": "request-invalid-font", + "platform": "COMPOSE", + "assets": [{ "id": "font", "type": "WOFF2", "url": "font.woff2" }], + "theme": {}, + "component": { "id": "button" }, + "example": { "id": "default" } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-property.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-property.json new file mode 100644 index 0000000000..3ff664de92 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-property.json @@ -0,0 +1,8 @@ +{ + "protocolVersion": 1, + "requestId": "request-invalid-property", + "platform": "COMPOSE", + "theme": {}, + "component": { "id": "button", "properties": { "color": { "base": { "type": "tokenRef" } } } }, + "example": { "id": "default" } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-missing-component.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-missing-component.json new file mode 100644 index 0000000000..24ebd42d2b --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-missing-component.json @@ -0,0 +1 @@ +{ "protocolVersion": 1, "requestId": "request-1", "platform": "COMPOSE", "theme": {}, "example": { "id": "basic" } } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-result-invalid-type.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-result-invalid-type.json new file mode 100644 index 0000000000..dfb2c6590a --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-result-invalid-type.json @@ -0,0 +1 @@ +{ "type": "rendered", "requestId": "request-1" } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-component-description-result.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-component-description-result.json new file mode 100644 index 0000000000..b290fdf94f --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-component-description-result.json @@ -0,0 +1,21 @@ +{ + "type": "success", + "requestId": "describe-1", + "description": { + "protocolVersion": 1, + "componentId": "BasicButton", + "storyId": "BasicButton", + "properties": [ + { "type": "string", "name": "label", "defaultValue": "label" }, + { "type": "boolean", "name": "enabled", "defaultValue": true }, + { "type": "int", "name": "count", "defaultValue": 1 }, + { "type": "float", "name": "progress", "defaultValue": 0.5 }, + { + "type": "singleChoice", + "name": "icon", + "defaultValue": "Start", + "variants": ["Start", "End", "No"] + } + ] + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-payload.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-payload.json new file mode 100644 index 0000000000..dfa169702c --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-payload.json @@ -0,0 +1,27 @@ +{ + "protocolVersion": 1, + "requestId": "request-1", + "platform": "COMPOSE", + "assets": [ + { "id": "font.inter.regular", "type": "TTF", "url": "https://cdn.example.test/inter-regular.ttf", "digest": "sha256:regular" }, + { "id": "font.inter.italic", "type": "OTF", "url": "https://cdn.example.test/inter-italic.otf" } + ], + "theme": { + "color.primary": { "type": "color", "value": "#006FFFFF" }, + "spacing.m": { "type": "dimension", "value": 16 }, + "shape.round": { "type": "shape", "cornerRadii": [12, 12, 12, 12] }, + "shadow.default": { "type": "shadow", "layers": [{ "x": 0, "y": 2, "blur": 8, "spread": 0, "color": "#33000000" }] }, + "fontFamily.inter": { "type": "fontFamily", "faces": [{ "assetId": "font.inter.regular", "weight": 400 }, { "assetId": "font.inter.italic", "weight": 400, "style": "ITALIC" }] }, + "typography.body": { "type": "typography", "fontFamilyTokenId": "fontFamily.inter", "fontSize": 16, "lineHeight": 20, "letterSpacing": 0.2, "weight": 400 } + }, + "component": { + "id": "button", + "variations": { "view": "primary", "size": "m" }, + "properties": { + "enabled": { "base": { "type": "literal", "value": true } }, + "backgroundColor": { "base": { "type": "tokenRef", "tokenId": "color.primary" }, "states": [{ "when": ["pressed"], "source": { "type": "literal", "value": "#004FBFFF" } }] } + } + }, + "example": { "id": "button-basic", "props": { "text": "Continue" } }, + "surface": { "width": 390, "height": 844, "background": "#FFFFFF" } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result-with-warnings.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result-with-warnings.json new file mode 100644 index 0000000000..52f39e061a --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result-with-warnings.json @@ -0,0 +1 @@ +{ "type": "success", "requestId": "request-1", "warnings": [{ "code": "unknown_state", "message": "Состояние fancy не поддерживается", "path": "$.component.properties.backgroundColor.states[0]" }] } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result.json new file mode 100644 index 0000000000..f0a17f05eb --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result.json @@ -0,0 +1 @@ +{ "type": "success", "requestId": "request-1" } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-on-style-ref.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-on-style-ref.json new file mode 100644 index 0000000000..d44a9ce009 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-on-style-ref.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "styleRef", + "styleId": "x", + "alpha": 0.5 + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-out-of-range.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-out-of-range.json new file mode 100644 index 0000000000..c20bbf5ed0 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-out-of-range.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg", + "alpha": 1.5 + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/duplicate-when.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/duplicate-when.json new file mode 100644 index 0000000000..3249d0d919 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/duplicate-when.json @@ -0,0 +1,45 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [ + { + "when": [ + "pressed", + "pressed" + ], + "source": { + "type": "literal", + "value": 1 + } + } + ] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/empty-when.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/empty-when.json new file mode 100644 index 0000000000..24db7136ad --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/empty-when.json @@ -0,0 +1,42 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [ + { + "when": [], + "source": { + "type": "literal", + "value": 1 + } + } + ] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/gradient-one-stop.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/gradient-one-stop.json new file mode 100644 index 0000000000..663db827a3 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/gradient-one-stop.json @@ -0,0 +1,49 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + }, + "g": { + "type": "gradient", + "layers": [ + { + "kind": "linear", + "angle": 0, + "stops": [ + { + "color": "#FF0000FF", + "position": 0 + } + ] + } + ] + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/shadow-without-spread.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/shadow-without-spread.json new file mode 100644 index 0000000000..fbcd07bbbb --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/shadow-without-spread.json @@ -0,0 +1,45 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + }, + "s": { + "type": "shadow", + "layers": [ + { + "x": 0, + "y": 0, + "blur": 1, + "color": "#00000040" + } + ] + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/states-as-map.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/states-as-map.json new file mode 100644 index 0000000000..77de7b5caf --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/states-as-map.json @@ -0,0 +1,39 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": { + "pressed": { + "type": "literal", + "value": 1 + } + } + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/unknown-asset-type.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/unknown-asset-type.json new file mode 100644 index 0000000000..c1aaed730d --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/unknown-asset-type.json @@ -0,0 +1,40 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [ + { + "id": "a", + "type": "GIF", + "url": "u" + } + ], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/wrong-protocol-version.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/wrong-protocol-version.json new file mode 100644 index 0000000000..3fdf814d56 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/wrong-protocol-version.json @@ -0,0 +1,34 @@ +{ + "protocolVersion": 2, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/alpha-literal.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/alpha-literal.json new file mode 100644 index 0000000000..4729b8ed3a --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/alpha-literal.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "literal", + "value": "#FF0000FF", + "alpha": 0.4 + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/asset-ref-svg.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/asset-ref-svg.json new file mode 100644 index 0000000000..83306c13b8 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/asset-ref-svg.json @@ -0,0 +1,47 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [ + { + "id": "icon.close", + "type": "SVG", + "url": "https://example.test/close.svg", + "digest": "abc" + } + ], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + }, + "icon": { + "base": { + "type": "assetRef", + "assetId": "icon.close" + } + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/gradient-and-shadow-spread.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/gradient-and-shadow-spread.json new file mode 100644 index 0000000000..171dcd25ba --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/gradient-and-shadow-spread.json @@ -0,0 +1,65 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + }, + "gradient.main": { + "type": "gradient", + "layers": [ + { + "kind": "linear", + "angle": 90, + "stops": [ + { + "color": "#FF0000FF", + "position": 0 + }, + { + "color": "#0000FFFF", + "position": 1 + } + ] + } + ] + }, + "shadow.s": { + "type": "shadow", + "layers": [ + { + "x": 0, + "y": 2, + "blur": 4, + "spread": -1, + "color": "#00000040" + } + ] + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/minimal.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/minimal.json new file mode 100644 index 0000000000..164a4b07d6 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/minimal.json @@ -0,0 +1,34 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/state-set.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/state-set.json new file mode 100644 index 0000000000..b52f593248 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/state-set.json @@ -0,0 +1,64 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [ + { + "when": [ + "pressed" + ], + "source": { + "type": "literal", + "value": "#00000080" + } + }, + { + "when": [ + "pressed", + "selected" + ], + "source": { + "type": "tokenRef", + "tokenId": "color.bg" + } + }, + { + "when": [ + "checked" + ], + "source": { + "type": "tokenRef", + "tokenId": "color.bg", + "alpha": 0.5 + } + } + ] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/style-ref.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/style-ref.json new file mode 100644 index 0000000000..732aef01cc --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/style-ref.json @@ -0,0 +1,53 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + }, + "dividerStyle": { + "base": { + "type": "styleRef", + "styleId": "divider.default" + } + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + }, + "styles": { + "divider.default": { + "component": "divider", + "properties": { + "color": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + } + } + } + } + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST new file mode 100644 index 0000000000..b90c22079a --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST @@ -0,0 +1,16 @@ +d21278388436a88c53a9a5a9cb407f1023c21b0ced4b1794251d45f1b7cf788d fixtures/v1/shared/invalid/alpha-on-style-ref.json +5b7b2901fba5696d4d5d40cc74ddfbbd4f4ac8f94825caa70eaefbdcc0da91d7 fixtures/v1/shared/invalid/alpha-out-of-range.json +cc9166ee6f181ccf84d0f5a7ce436286d370e4b22f27b3fd60f19993009fa165 fixtures/v1/shared/invalid/duplicate-when.json +d6ccd194f6ebf281e45323ad7f30d91ac1275e4055bf5845ee0296e181959368 fixtures/v1/shared/invalid/empty-when.json +d5356b033a2a9f25ea365e64ed1689a325372717d026d332bafce81b09de5d28 fixtures/v1/shared/invalid/gradient-one-stop.json +3ea2a049c9af3bf000c56837204a388024ea3beb3385a5800fbb1e1db4a65dc9 fixtures/v1/shared/invalid/shadow-without-spread.json +bcdba1df19fe90d54e5fcbdd0ad0799b02f541ab2426e7e961c0db83a57b7454 fixtures/v1/shared/invalid/states-as-map.json +facc8f3fb1358419fa745de6434e1fc5d918024b7dbe1ab149afea8b01fa5bb0 fixtures/v1/shared/invalid/unknown-asset-type.json +9643732e64208522831a89770e916423e0ded3875fd4f0b2b6414932a485fb7a fixtures/v1/shared/invalid/wrong-protocol-version.json +7920121f085dc2ddc104b6588d5e2256c383bad358798507efaa4da64ef995dc fixtures/v1/shared/valid/alpha-literal.json +19f55c22e19f9de06a17a87432ba766fcdf831bb47cac1a1e5858fb26cf8278d fixtures/v1/shared/valid/asset-ref-svg.json +83c9e23f016eeae3c124af2c4e814eaf02eed6ba56f56f51d71c7bba00558cd2 fixtures/v1/shared/valid/gradient-and-shadow-spread.json +421f953f07649127aa869ed5383163937c9cb78f3b142e4871b9edec0d4a7272 fixtures/v1/shared/valid/minimal.json +264877cdfd91e70b7542ea2a0614ca8c9d0db4e42638eb4a692c3eccfa9ace31 fixtures/v1/shared/valid/state-set.json +45e4ff829d415f0ad2b30de119b7bf7da8d392c23b50914bd6d4a51ca7965014 fixtures/v1/shared/valid/style-ref.json +79da7d4ee214f65f8c7275167ceccf3938940839ec87883e5cd73390198d33db schemas/v1/preview-payload.schema.json diff --git a/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-component-description-result.schema.json b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-component-description-result.schema.json new file mode 100644 index 0000000000..2b7cbbfa38 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-component-description-result.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.sdds.dev/preview/v1/preview-component-description-result.schema.json", + "$defs": { + "property": { + "oneOf": [ + { + "type": "object", + "required": ["type", "name", "defaultValue"], + "properties": { + "type": { "const": "string" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "string" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "defaultValue"], + "properties": { + "type": { "const": "boolean" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "boolean" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "defaultValue"], + "properties": { + "type": { "const": "int" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "integer" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "defaultValue"], + "properties": { + "type": { "const": "float" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "number" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "defaultValue", "variants"], + "properties": { + "type": { "const": "singleChoice" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "string" }, + "variants": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string" }, + "contains": { "type": "string" } + } + }, + "additionalProperties": false + } + ] + }, + "description": { + "type": "object", + "required": ["protocolVersion", "componentId", "storyId", "properties"], + "properties": { + "protocolVersion": { "const": 1 }, + "componentId": { "type": "string", "minLength": 1 }, + "storyId": { "type": "string", "minLength": 1 }, + "properties": { + "type": "array", + "items": { "$ref": "#/$defs/property" } + } + }, + "additionalProperties": false + } + }, + "oneOf": [ + { + "type": "object", + "required": ["type", "requestId", "description"], + "properties": { + "type": { "const": "success" }, + "requestId": { "type": "string", "minLength": 1 }, + "description": { "$ref": "#/$defs/description" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "requestId", "code", "message"], + "properties": { + "type": { "const": "failure" }, + "requestId": { "type": "string", "minLength": 1 }, + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + ] +} diff --git a/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-payload.schema.json b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-payload.schema.json new file mode 100644 index 0000000000..1c65e01a17 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-payload.schema.json @@ -0,0 +1,559 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.sdds.dev/preview/v1/preview-payload.schema.json", + "type": "object", + "required": [ + "protocolVersion", + "requestId", + "platform", + "theme", + "component", + "example" + ], + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1 + }, + "platform": { + "enum": [ + "ANDROID", + "COMPOSE", + "WEB", + "IOS" + ] + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/$defs/asset" + } + }, + "theme": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/tokenValue" + } + }, + "component": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "variations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/propertyValue" + } + } + }, + "additionalProperties": false + }, + "example": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "props": { + "type": "object" + } + }, + "additionalProperties": false + }, + "surface": { + "type": "object", + "required": [ + "width", + "height" + ], + "properties": { + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "background": { + "type": "string" + } + }, + "additionalProperties": false + }, + "styles": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/style" + } + }, + "defaults": { + "type": "object", + "description": "Стили и текст по умолчанию для вспомогательных элементов истории компонента (кнопки, Switch, Spinner, текст); значения берутся из темы дизайн-системы.", + "properties": { + "text": { + "type": "object", + "properties": { + "style": { + "type": "string", + "description": "Идентификатор токена typography." + }, + "color": { + "type": "string", + "description": "Идентификатор токена color." + } + }, + "additionalProperties": false + }, + "styles": { + "type": "object", + "description": "Идентификаторы стилей из `styles` для компонентов, которые истории рисуют без собственного стиля.", + "properties": { + "button": { + "type": "string" + }, + "switch": { + "type": "string" + }, + "spinner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": true, + "$defs": { + "asset": { + "type": "object", + "required": [ + "id", + "type", + "url" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "enum": [ + "TTF", + "OTF", + "SVG", + "PNG" + ] + }, + "url": { + "type": "string", + "minLength": 1 + }, + "digest": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "propertySource": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "literal" + }, + "value": {}, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "tokenId" + ], + "properties": { + "type": { + "const": "tokenRef" + }, + "tokenId": { + "type": "string", + "minLength": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "styleId" + ], + "properties": { + "type": { + "const": "styleRef" + }, + "styleId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "assetId" + ], + "properties": { + "type": { + "const": "assetRef" + }, + "assetId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + ] + }, + "propertyValue": { + "type": "object", + "required": [ + "base" + ], + "properties": { + "base": { + "$ref": "#/$defs/propertySource" + }, + "states": { + "type": "array", + "items": { + "$ref": "#/$defs/stateEntry" + } + } + }, + "additionalProperties": false + }, + "tokenValue": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "color" + }, + "value": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{8}$" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "dimension" + }, + "value": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "cornerRadii" + ], + "properties": { + "type": { + "const": "shape" + }, + "cornerRadii": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "layers" + ], + "properties": { + "type": { + "const": "shadow" + }, + "layers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "x", + "y", + "blur", + "spread", + "color" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "blur": { + "type": "number" + }, + "color": { + "type": "string" + }, + "spread": { + "type": "number" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "faces" + ], + "properties": { + "type": { + "const": "fontFamily" + }, + "faces": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "assetId", + "weight" + ], + "properties": { + "assetId": { + "type": "string", + "minLength": 1 + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "style": { + "enum": [ + "NORMAL", + "ITALIC" + ] + } + }, + "additionalProperties": false + } + }, + "allowFallback": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "fontFamilyTokenId", + "fontSize", + "lineHeight", + "letterSpacing", + "weight" + ], + "properties": { + "type": { + "const": "typography" + }, + "fontFamilyTokenId": { + "type": "string", + "minLength": 1 + }, + "fontSize": { + "type": "number" + }, + "lineHeight": { + "type": "number" + }, + "letterSpacing": { + "type": "number" + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "layers" + ], + "properties": { + "type": { + "const": "gradient" + }, + "layers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "kind", + "angle", + "stops" + ], + "properties": { + "kind": { + "const": "linear" + }, + "angle": { + "type": "number" + }, + "stops": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": [ + "color", + "position" + ], + "properties": { + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{8}$" + }, + "position": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + ] + }, + "stateEntry": { + "type": "object", + "required": [ + "when", + "source" + ], + "properties": { + "when": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "source": { + "$ref": "#/$defs/propertySource" + } + }, + "additionalProperties": false + }, + "style": { + "type": "object", + "required": [ + "component", + "properties" + ], + "properties": { + "component": { + "type": "string", + "minLength": 1 + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/propertyValue" + } + } + }, + "additionalProperties": false + } + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-result.schema.json b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-result.schema.json new file mode 100644 index 0000000000..f78ffcdfc0 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-result.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.sdds.dev/preview/v1/preview-result.schema.json", + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "requestId" + ], + "properties": { + "type": { + "const": "success" + }, + "requestId": { + "type": "string", + "minLength": 1 + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "requestId", + "code", + "message" + ], + "properties": { + "type": { + "const": "failure" + }, + "requestId": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + } + ] +} diff --git a/integration-core/preview-contract/src/commonTest/kotlin/com/sdds/preview/contract/PreviewContractTest.kt b/integration-core/preview-contract/src/commonTest/kotlin/com/sdds/preview/contract/PreviewContractTest.kt new file mode 100644 index 0000000000..9543df7d36 --- /dev/null +++ b/integration-core/preview-contract/src/commonTest/kotlin/com/sdds/preview/contract/PreviewContractTest.kt @@ -0,0 +1,47 @@ +package com.sdds.preview.contract + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PreviewContractTest { + @Test + fun `current protocol version is accepted`() { + assertNull(payload(protocolVersion = PREVIEW_PROTOCOL_VERSION).validateVersion()) + } + + @Test + fun `unknown protocol version is rejected`() { + val failure = payload(protocolVersion = 2).validateVersion() + assertEquals("unsupported_protocol_version", failure?.code) + assertEquals("$.protocolVersion", failure?.path) + } + + @Test + fun `description semantic validation rejects duplicate names and invalid choices`() { + val description = PreviewComponentDescription( + componentId = "button", + storyId = "basic", + properties = listOf( + PreviewExamplePropertyDescription.StringProperty("label", "Label"), + PreviewExamplePropertyDescription.StringProperty("label", "Other"), + PreviewExamplePropertyDescription.SingleChoiceProperty("icon", "Missing", listOf("Start")), + ), + ) + + val errors = description.validate() + + assertTrue(errors.any { "уникальны" in it }) + assertTrue(errors.any { "defaultValue" in it }) + } + + private fun payload(protocolVersion: Int) = PreviewPayload( + protocolVersion = protocolVersion, + requestId = "request-1", + platform = PreviewPlatform.COMPOSE, + theme = emptyMap(), + component = PreviewComponent("button"), + example = PreviewExample("basic"), + ) +} diff --git a/integration-core/preview-contract/src/jvmTest/kotlin/com/sdds/preview/contract/SchemaContractTest.kt b/integration-core/preview-contract/src/jvmTest/kotlin/com/sdds/preview/contract/SchemaContractTest.kt new file mode 100644 index 0000000000..6860799b83 --- /dev/null +++ b/integration-core/preview-contract/src/jvmTest/kotlin/com/sdds/preview/contract/SchemaContractTest.kt @@ -0,0 +1,135 @@ +package com.sdds.preview.contract + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.networknt.schema.JsonSchemaFactory +import com.networknt.schema.SpecVersion +import kotlinx.serialization.KSerializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.serializer +import java.io.File +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SchemaContractTest { + private val mapper = ObjectMapper() + private val factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + + @Test + fun `canonical fixtures conform to schema and survive Kotlin round trip`() { + roundTrip("preview-payload") + roundTrip("preview-result") + roundTrip("preview-result", "preview-result-with-warnings") + roundTrip("preview-component-description-result") + } + + @Test + fun `negative fixtures report expected paths`() { + assertInvalid("preview-payload", "preview-payload-missing-component", "$.component") + assertInvalid("preview-result", "preview-result-invalid-type", "$.type") + assertInvalid("preview-payload", "preview-payload-invalid-font-asset", "$.assets[0].type") + assertInvalid("preview-payload", "preview-payload-invalid-property", "$.component.properties.color.base") + assertInvalid( + "preview-component-description-result", + "preview-component-description-result-invalid-default", + "$.description.properties[0].defaultValue", + ) + } + + @Test + fun `shared valid fixtures pass the schema and survive Kotlin round trip`() { + val names = sharedFixtures("valid") + assertTrue(names.isNotEmpty()) + names.forEach { name -> + val fixture = resource("fixtures/v1/shared/valid/$name") + assertValid("preview-payload", mapper.readTree(fixture)) + val model = PreviewContractJson.decodeFromString(fixture) + assertValid("preview-payload", mapper.readTree(PreviewContractJson.encodeToString(model))) + } + } + + @Test + fun `shared invalid fixtures are rejected by the schema`() { + val names = sharedFixtures("invalid") + assertTrue(names.isNotEmpty()) + names.forEach { name -> + val messages = schema("preview-payload").validate( + mapper.readTree(resource("fixtures/v1/shared/invalid/$name")), + ) + assertTrue(messages.isNotEmpty(), "Fixture $name must be rejected") + } + } + + @Test + fun `canonical schema and shared fixtures match the recorded digest`() { + val recorded = resource("schemas/v1/DIGEST").lines().filter { it.isNotBlank() } + .associate { line -> line.substringAfter(" ") to line.substringBefore(" ") } + val expectedFiles = listOf("schemas/v1/preview-payload.schema.json") + + sharedFixtures("valid").map { "fixtures/v1/shared/valid/$it" } + + sharedFixtures("invalid").map { "fixtures/v1/shared/invalid/$it" } + assertEquals(expectedFiles.sorted(), recorded.keys.sorted(), "DIGEST lists a different set of files") + expectedFiles.forEach { path -> + val actual = MessageDigest.getInstance("SHA-256").digest(resource(path).toByteArray()) + .joinToString("") { "%02x".format(it) } + assertEquals( + recorded.getValue(path), + actual, + "$path differs from the recorded digest; run " + + "design-system-builder/backend-kt/preview/contract/fingerprint.sh --sync-to ", + ) + } + } + + @Test + fun `schemas and fixtures are packaged as resources`() { + listOf("preview-payload", "preview-result", "preview-component-description-result").forEach { + assertTrue(resource("schemas/v1/$it.schema.json").isNotBlank()) + assertTrue(resource("fixtures/v1/positive/$it.json").isNotBlank()) + } + } + + @Test + fun `public runtime remains platform neutral`() { + val runtimeNames = System.getProperty("java.class.path") + assertTrue("androidx.compose" !in runtimeNames) + assertTrue("com.android" !in runtimeNames) + assertTrue("sandbox" !in runtimeNames) + } + + private fun sharedFixtures(kind: String): List { + val directory = File(requireNotNull(javaClass.classLoader.getResource("fixtures/v1/shared/$kind")).toURI()) + return directory.list().orEmpty().filter { it.endsWith(".json") }.sorted() + } + + private inline fun roundTrip(name: String, fixtureName: String = name) { + val fixture = resource("fixtures/v1/positive/$fixtureName.json") + assertValid(name, mapper.readTree(fixture)) + val serializer: KSerializer = serializer() + val model = PreviewContractJson.decodeFromString(serializer, fixture) + val encoded = PreviewContractJson.encodeToString(serializer, model) + assertValid(name, mapper.readTree(encoded)) + } + + private fun assertInvalid(schemaName: String, fixtureName: String, expectedPath: String) { + val messages = schema(schemaName).validate( + mapper.readTree(resource("fixtures/v1/negative/$fixtureName.json")), + ) + assertTrue(messages.isNotEmpty()) + assertTrue(messages.any { it.path == expectedPath || it.message.contains(expectedPath) }) + } + + private fun assertValid(schemaName: String, node: JsonNode) { + assertEquals(emptySet(), schema(schemaName).validate(node)) + } + + private fun schema(name: String) = factory.getSchema( + mapper.readTree(resource("schemas/v1/$name.schema.json")), + ) + + private fun resource(path: String): String = requireNotNull(javaClass.classLoader.getResource(path)) { + "Resource $path is not packaged" + }.readText() +} diff --git a/integration-core/preview-sdk-compose/README.md b/integration-core/preview-sdk-compose/README.md new file mode 100644 index 0000000000..5f74c91439 --- /dev/null +++ b/integration-core/preview-sdk-compose/README.md @@ -0,0 +1,8 @@ +# Compose Preview SDK + +Экспериментальный KMP SDK преобразует renderer-ready `PreviewPayload` в Compose-значения. +MVP поддерживает theme tokens, runtime TTF/OTF fonts и общие contracts style factories. +UIKit-specific BasicButton assembly находится в `integration-core:preview-compose-plugin`. + +В модуль намеренно не входят универсальная генерация style factories, Gradle tooling plugin, +manifest/distribution и browser host bridge. diff --git a/integration-core/preview-sdk-compose/build.gradle.kts b/integration-core/preview-sdk-compose/build.gradle.kts new file mode 100644 index 0000000000..686cb834ac --- /dev/null +++ b/integration-core/preview-sdk-compose/build.gradle.kts @@ -0,0 +1,26 @@ +import utils.addDefaultTargets + +plugins { + id("convention.cmp-lib") +} + +group = "integration-core" + +android { namespace = "com.sdds.preview.compose" } + +kotlin { + addDefaultTargets() + sourceSets { + commonMain.dependencies { + api(project(":preview-contract")) + api(project(":sandbox-core")) + implementation(libs.sdds.uikit.compose) + implementation(compose.foundation) + implementation(compose.ui) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + } + } +} diff --git a/integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ComposePreviewRuntime.kt b/integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ComposePreviewRuntime.kt new file mode 100644 index 0000000000..2acab6288d --- /dev/null +++ b/integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ComposePreviewRuntime.kt @@ -0,0 +1,231 @@ +package com.sdds.preview.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.sdds.compose.sandbox.ComposeBaseStory +import com.sdds.compose.uikit.style.Style +import com.sdds.preview.contract.PreviewComponentDescription +import com.sdds.preview.contract.PreviewComponentDescriptionResult +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewExamplePropertyDescription +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewWarning +import com.sdds.preview.contract.validate +import com.sdds.preview.contract.validateVersion +import com.sdds.sandbox.Property +import com.sdds.sandbox.StoryEntry +import com.sdds.sandbox.UiState +import kotlinx.serialization.decodeFromString + +/** Подготовленный preview, скрывающий конкретные типы story state и style. */ +public interface PreparedComposePreview { + /** Идентификатор исходного render request. */ + public val requestId: String + + /** Предупреждения, которые нужно вернуть вместе с успешным результатом. */ + public val warnings: List + get() = emptyList() + + /** Фон viewport из `PreviewPayload.surface`, если он задан корректным цветом. */ + public val surfaceBackground: Color? + get() = null + + /** Отображает подготовленные style и state через существующую story. */ + @Composable + public fun Content() +} + +/** Type-erased binding существующей story и соответствующей style factory. */ +public interface ComposePreviewComponent { + /** Идентификатор компонента из Preview Protocol. */ + public val componentId: String + + /** Описание example properties зарегистрированной story. */ + public val description: PreviewComponentDescription + + /** Проверяет payload, специфичный для компонента (например, типы вложенных стилей), до подготовки темы. */ + public fun validate(payload: PreviewPayload) {} + + /** Подготавливает component-specific state после подготовки общей темы. */ + public fun prepare(payload: PreviewPayload, theme: PreparedComposeTheme): PreparedComposePreview +} + +/** + * Создаёт preview binding без отдельного renderer-класса для компонента. + * Типы state и style остаются связанными внутри SDK generic implementation. + */ +public fun previewComponent( + story: ComposeBaseStory, + styleFactory: ComposePreviewStyleFactory, +): ComposePreviewComponent = TypedComposePreviewComponent(story, styleFactory) + +/** + * Создаёт preview binding из type-erased истории и фабрики (записи реестра). Совместимость классов стиля + * истории и фабрики + * должна быть проверена вызывающим (реестр знает `styleQualifiedName`). + */ +@Suppress("UNCHECKED_CAST") +public fun previewComponentOf( + componentId: String, + story: ComposeBaseStory<*, *>, + styleFactory: ComposePreviewStyleFactory<*>, +): ComposePreviewComponent = TypedComposePreviewComponent( + story as ComposeBaseStory, + styleFactory as ComposePreviewStyleFactory