diff --git a/binding/IncludeNativeAssets.HarfBuzzSharp.targets b/binding/IncludeNativeAssets.HarfBuzzSharp.targets index 754e70a9559..7b3eb95157a 100644 --- a/binding/IncludeNativeAssets.HarfBuzzSharp.targets +++ b/binding/IncludeNativeAssets.HarfBuzzSharp.targets @@ -6,7 +6,18 @@ $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant) - + + + + + + + diff --git a/binding/IncludeNativeAssets.SkiaSharp.targets b/binding/IncludeNativeAssets.SkiaSharp.targets index a4f98db59c0..7f60ed4fce3 100644 --- a/binding/IncludeNativeAssets.SkiaSharp.targets +++ b/binding/IncludeNativeAssets.SkiaSharp.targets @@ -6,7 +6,18 @@ $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant) - + + + + + + + diff --git a/build.cake b/build.cake index a62d3f2d369..483dd572449 100644 --- a/build.cake +++ b/build.cake @@ -103,6 +103,10 @@ Task ("tests-netcore") .IsDependentOn ("externals") .Does (() => RunCake ("./scripts/infra/tests/tests-netcore.cake", "Default")); +Task ("tests-container") + .Description ("Run the console test suite against prebuilt natives (used by the containerized test legs).") + .Does (() => RunCake ("./scripts/infra/tests/tests-container.cake", "Default")); + Task ("tests-android") .Description ("Run all Android tests.") .IsDependentOn ("externals") diff --git a/documentation/dev/README.md b/documentation/dev/README.md index 501f747e61a..46cbbca9234 100644 --- a/documentation/dev/README.md +++ b/documentation/dev/README.md @@ -54,6 +54,11 @@ C# Wrapper (binding/SkiaSharp/) → P/Invoke → C API (externals/skia/src/c | [building.md](building.md) | Build on Windows & macOS | | [building-linux.md](building-linux.md) | Build native libraries for Linux | +### Testing +| Document | Description | +|----------|-------------| +| [containerized-testing.md](containerized-testing.md) | Run the console test suite inside a Docker container (Linux glibc/Alpine + Nano Server) via the bootstrapper `docker:` feature + the `tests-container` cake target | + ### Releasing | Document | Description | |----------|-------------| diff --git a/documentation/dev/containerized-testing.md b/documentation/dev/containerized-testing.md new file mode 100644 index 00000000000..36c86ad80fd --- /dev/null +++ b/documentation/dev/containerized-testing.md @@ -0,0 +1,140 @@ +# Containerized testing (Docker) + +The console test suite (`SkiaSharp.Tests.Console`) can be run **inside a Docker container** against a +chosen native build. This covers container runtimes that the agent-based test legs don't — **Linux +(Azure Linux, glibc)**, **Linux (Alpine, musl)**, their **No Dependencies** variants, and **Windows +Nano Server**. + +Pieces: + +- **Cake target** [`tests-container`](../../scripts/infra/tests/tests-container.cake) — runs the + console suite against a prebuilt native library. Builds no externals. +- **Env images** [`scripts/infra/tests/docker/`](../../scripts/infra/tests/docker) — + `azurelinux/`, `azurelinux-nodeps/`, `alpine/`, `alpine-nodeps/`, `nanoserver/`. +- **CI legs** `tests_container_linux`, `tests_container_linux_nodeps`, `tests_container_alpine_linux`, + `tests_container_alpine_nodeps`, `tests_container_nanoserver_windows` in + [`scripts/azure-templates-stages-test.yml`](../../scripts/azure-templates-stages-test.yml). + +The desktop `Linux` leg already runs on an Ubuntu agent, so the glibc container leg uses **Azure +Linux** instead — a different, minimal glibc distro rather than a second Ubuntu run. + +## How a leg runs + +Each leg uses the bootstrapper `docker:` feature (`azure-templates-jobs-bootstrapper.yml`), which: + +1. builds the **env image** from `/Dockerfile` with the `Docker@2` task, then +2. runs `dotnet cake --target=tests-container` **inside** that image against the mounted repo: + `docker run --volume : skiasharp … dotnet cake --target=tests-container …`. + +The feature runs on both Linux and Windows agents: Linux mounts `/work` and runs via `/bin/sh` (POSIX, +so the bare No Dependencies images need no shell package); Windows mounts `C:\work` and runs via `cmd`. + +The **env images** provide only what each scenario needs. The two fontconfig images add `fontconfig` ++ fonts (see [Fonts](#fonts)); the **No Dependencies** and **Nano Server** images install **nothing** +beyond the base .NET SDK — that is the point of those legs. There is no `COPY` in these Dockerfiles; +the repo is mounted at run time. + +## What `tests-container` does + +1. builds `tests/SkiaSharp.Tests.Console` with `-p:TargetFrameworks=net10.0`, collapsing the + multi-targeted binding projects to a single TFM so no Android/iOS workloads are needed; +2. runs the suite via `dotnet test`, writing `output/logs/testlogs/**/TestResults.trx`. + +It builds no externals — the prebuilt `libSkiaSharp` comes from `output/native///` +in the mounted repo (in CI, the merged `native` artifact; locally, `externals-download`). + +## Selecting the native build + +`--nativePlatform=` sets the `SkiaSharpNativePlatform` / `HarfBuzzSharpNativePlatform` +MSBuild properties. In `binding/IncludeNativeAssets.SkiaSharp.targets` (and the HarfBuzz twin), those +properties make the desktop `Content` include copy the native library from +`output/native///` only. The `` is derived from the host (`OSArchitecture`). + +This is how a container runs against a platform-specific build the OS-derived defaults don't map — +`nanoserver` (`output/native/nanoserver/x64/libSkiaSharp.dll`) on Windows, and `alpine` +(`output/native/alpine/x64/libSkiaSharp.so`) on musl. + +## Running it locally + +Build the env image, then run `tests-container` inside it against the mounted repo: + +```bash +# Linux Azure Linux (glibc). --nativePlatform picks the build; is auto from the host. +dotnet cake --target=externals-download +docker build -t skiasharp-tests-env scripts/infra/tests/docker/azurelinux +docker run --rm --volume "$(pwd):/work" -w /work skiasharp-tests-env \ + /bin/sh -c "dotnet tool restore && dotnet cake --target=tests-container --nativePlatform=linux" +``` + +```bash +# Linux Alpine (musl). +docker build -t skiasharp-tests-env-alpine scripts/infra/tests/docker/alpine +docker run --rm --volume "$(pwd):/work" -w /work skiasharp-tests-env-alpine \ + /bin/sh -c "dotnet tool restore && dotnet cake --target=tests-container --nativePlatform=alpine" +``` + +```bash +# No Dependencies (bare image, no fontconfig). Swap in alpine-nodeps / --nativePlatform=alpinenodeps +# for the musl variant. +docker build -t skiasharp-tests-env-nodeps scripts/infra/tests/docker/azurelinux-nodeps +docker run --rm --volume "$(pwd):/work" -w /work skiasharp-tests-env-nodeps \ + /bin/sh -c "dotnet tool restore && dotnet cake --target=tests-container --nativePlatform=linuxnodeps" +``` + +```powershell +# Nano Server (Windows container host only). +dotnet cake --target=externals-nanoserver +docker build -t skiasharp-tests-env scripts/infra/tests/docker/nanoserver +docker run --rm --volume "${pwd}:C:\work" -w C:\work skiasharp-tests-env ` + cmd /c "dotnet tool restore && dotnet cake --target=tests-container --nativePlatform=nanoserver" +``` + +## CI wiring + +Each leg is a bootstrapper job in the `tests` stage that: + +- declares `requiredArtifacts: - name: native`, so `output/native/…` is the merged native artifact; +- sets `docker:` to the env image and `target: tests-container` with + `additionalArgs: --nativePlatform=<…>`; +- publishes the TRX via `PublishTestResults@2` (Azure DevOps Tests tab) and the + `testlogs_container_` artifact. + +The legs are gating: a genuine test failure fails the build. Environment differences that are not +SkiaSharp regressions — most notably the fontless `nodeps` and Nano Server builds — are handled by +the runtime self-skip helpers (see [Fonts](#fonts) below), so those tests skip rather than fail. + +The Nano Server leg requires a Windows agent with container support (matching the `ltsc2022` image +base) and pulls the Nano Server .NET SDK image. + +## Fonts + +Whether the suite can resolve **system fonts** depends on how the native library was compiled, not +just on the image: + +- **fontconfig builds** (`linux`, `alpine`) enumerate whatever fonts are installed in the image. + Base .NET SDK images ship no fonts, so the two fontconfig env images install them: `fontconfig` + + DejaVu on both, plus an emoji font for the Unicode tests. Alpine installs `font-noto-emoji` + (packaged); Azure Linux has no emoji font in its repos, so its Dockerfile downloads **Noto Emoji** + (SIL OFL) pinned by URL commit and verified by SHA-256. These provide the families the test config + expects (`DefaultFontFamily`, `UnicodeFontFamilies`). +- **non-fontconfig / bare** — the **No Dependencies** variants (`linuxnodeps`, `alpinenodeps`, built + with `skia_use_fontconfig=false`) run in bare images with nothing installed, and **Nano Server** — + all enumerate **no** system fonts regardless of what the image contains. Their font manager + (`SkFontMgr_New_Custom_Empty`, a FreeType scanner) can only use fonts loaded explicitly + (`SKTypeface.FromFile` / `FromStream` / `FromData`). APIs that resolve a system family or the + default typeface (`SKTypeface.FromFamilyName`, `SKFontManager.Default`, a default `SKFont`) have + nothing to bind to. + +The Linux test config chooses `UnicodeFontFamilies` per libc — `Symbola` (Ubuntu desktop) or +`Noto Emoji` (Azure Linux container) on glibc, and `Noto Color Emoji` on musl — keyed off +`PlatformConfiguration.IsGlibc`. + +Tests that need system fonts detect the environment at runtime and self-skip, so the same suite +runs unmodified everywhere: + +- `SkipWhenNoSystemFontManager()` — for tests that enumerate or match families + (`SKFontManager` / `SKFontStyleSet`, or `MatchCharacter`). Skips where there is no usable font + manager: WASM, the NoDependencies builds, and Nano Server. +- `SkipWhenNoDefaultFont()` — for tests that measure or draw with the **default** typeface. Skips + where the default typeface is empty: the NoDependencies builds and Nano Server. WASM keeps a + single embedded default font, so these still run there. diff --git a/scripts/azure-templates-jobs-bootstrapper.yml b/scripts/azure-templates-jobs-bootstrapper.yml index de022a2ac26..49e3c2d05eb 100644 --- a/scripts/azure-templates-jobs-bootstrapper.yml +++ b/scripts/azure-templates-jobs-bootstrapper.yml @@ -377,7 +377,7 @@ jobs: dockerfile: ${{ parameters.docker }}/Dockerfile context: ${{ parameters.docker }} image: skiasharp:skiasharp - buildArguments: --platform linux/amd64 --tag skiasharp ${{ parameters.dockerArgs }} + buildArguments: --tag skiasharp ${{ parameters.dockerArgs }} enableNetwork: true - ${{ if ne(parameters.use1ESPipelineTemplates, 'true') }}: - task: Docker@2 @@ -387,19 +387,34 @@ jobs: command: build buildContext: ${{ parameters.docker }} dockerfile: ${{ parameters.docker }}/Dockerfile - arguments: --platform linux/amd64 --tag skiasharp ${{ parameters.dockerArgs }} - - bash: | - echo dotnet tool restore > cmd.sh - echo dotnet cake --target=${{ parameters.target }} --verbosity=${{ parameters.verbosity }} --configuration=${{ coalesce(parameters.configuration, 'Release') }} ${{ parameters.additionalArgs }} >> cmd.sh - sed -i 's/--gnArgs=\" \"//' cmd.sh - cat cmd.sh - displayName: Generate the script for the Docker image - condition: and(succeeded(), ne(variables['CACHE_SKIP'], 'true')) - - bash: | - docker run --rm --name skiasharp --volume $(pwd):/work skiasharp /bin/bash /work/cmd.sh - displayName: Run the bootstrapper for ${{ parameters.target }} using the Docker image - retryCountOnTaskFailure: ${{ parameters.retryCount }} - condition: and(succeeded(), ne(variables['CACHE_SKIP'], 'true')) + arguments: --tag skiasharp ${{ parameters.dockerArgs }} + # Run the target inside the image against the mounted repo. Linux and Windows containers + # differ in shell and mount path, so the run step is branched by agent OS. + - ${{ if ne(parameters.buildAgent.pool.os, 'windows') }}: + - bash: | + echo dotnet tool restore > cmd.sh + echo dotnet cake --target=${{ parameters.target }} --verbosity=${{ parameters.verbosity }} --configuration=${{ coalesce(parameters.configuration, 'Release') }} ${{ parameters.additionalArgs }} >> cmd.sh + sed -i 's/--gnArgs=\" \"//' cmd.sh + cat cmd.sh + displayName: Generate the script for the Docker image + condition: and(succeeded(), ne(variables['CACHE_SKIP'], 'true')) + - bash: | + docker run --rm --name skiasharp --volume $(pwd):/work skiasharp /bin/sh /work/cmd.sh + displayName: Run the bootstrapper for ${{ parameters.target }} using the Docker image + retryCountOnTaskFailure: ${{ parameters.retryCount }} + condition: and(succeeded(), ne(variables['CACHE_SKIP'], 'true')) + - ${{ if eq(parameters.buildAgent.pool.os, 'windows') }}: + - pwsh: | + 'dotnet tool restore' | Set-Content cmd.cmd -Encoding ascii + 'dotnet cake --target=${{ parameters.target }} --verbosity=${{ parameters.verbosity }} --configuration=${{ coalesce(parameters.configuration, 'Release') }} ${{ parameters.additionalArgs }}' | Add-Content cmd.cmd -Encoding ascii + Get-Content cmd.cmd + displayName: Generate the script for the Docker image + condition: and(succeeded(), ne(variables['CACHE_SKIP'], 'true')) + - pwsh: | + docker run --rm --name skiasharp --volume "$(Build.SourcesDirectory):C:\work" -w C:\work skiasharp cmd /c C:\work\cmd.cmd + displayName: Run the bootstrapper for ${{ parameters.target }} using the Docker image + retryCountOnTaskFailure: ${{ parameters.retryCount }} + condition: and(succeeded(), ne(variables['CACHE_SKIP'], 'true')) - pwsh: .\scripts\infra\native\shared\get-free-space.ps1 displayName: Get the volume information after the build diff --git a/scripts/azure-templates-stages-test.yml b/scripts/azure-templates-stages-test.yml index c47897e0547..4e83db88d97 100644 --- a/scripts/azure-templates-stages-test.yml +++ b/scripts/azure-templates-stages-test.yml @@ -84,7 +84,7 @@ stages: ${{ else }}: dependsOn: native jobs: - - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|netfx (Windows) + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Windows (.NET Framework) parameters: name: tests_netfx_windows displayName: Windows (.NET Framework) @@ -110,7 +110,7 @@ stages: - name: testlogs_netfx_windows always: true path: 'output/logs/testlogs' - - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|netcore (Windows) + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Windows (.NET Core) parameters: name: tests_netcore_windows displayName: Windows (.NET Core) @@ -138,10 +138,10 @@ stages: path: 'output/logs/testlogs' - name: coverage_netcore_windows path: 'output/coverage' - - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|netcore (macOS) + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|macOS parameters: name: tests_netcore_macos - displayName: macOS (.NET Core) + displayName: macOS buildAgent: ${{ parameters.buildAgentMac }} target: tests-netcore additionalArgs: --skipExternals="all" --coverage=$(ENABLE_CODE_COVERAGE) @@ -166,10 +166,10 @@ stages: path: 'output/logs/testlogs' - name: coverage_netcore_macos path: 'output/coverage' - - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|netcore (Linux) + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Linux parameters: name: tests_netcore_linux - displayName: Linux (.NET Core) + displayName: Linux buildAgent: ${{ parameters.buildAgentLinux }} # Software GL (Mesa llvmpipe) + software Vulkan (Mesa lavapipe) + a # virtual X server give the headless agent deterministic GPU output so @@ -217,10 +217,10 @@ stages: path: 'output/logs/testlogs' - name: coverage_netcore_linux path: 'output/coverage' - - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|android (Linux) + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Android parameters: name: tests_android_linux - displayName: Android (Linux) + displayName: Android buildAgent: ${{ parameters.buildAgentAndroidTests }} target: tests-android additionalArgs: --device=android-emulator-64 --deviceVersion=$(ANDROID_TEST_DEVICE_VERSION) --skipExternals="all" --coverage=$(ENABLE_CODE_COVERAGE) @@ -259,10 +259,10 @@ stages: - name: testlogs_android_$(ANDROID_TEST_DEVICE_VERSION) always: true path: 'output/logs/testlogs' - - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|ios (macOS) + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|iOS parameters: name: tests_ios_macos - displayName: iOS (macOS) + displayName: iOS buildAgent: ${{ parameters.buildAgentMac }} target: tests-ios additionalArgs: --device=ios-simulator-64 --deviceVersion=$(IOS_TEST_DEVICE_VERSION) --skipExternals="all" --coverage=$(ENABLE_CODE_COVERAGE) @@ -284,10 +284,10 @@ stages: - name: testlogs_ios_$(IOS_TEST_DEVICE_VERSION) always: true path: 'output/logs/testlogs' - - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|maccatalyst (macOS) + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Mac Catalyst parameters: name: tests_maccatalyst_macos - displayName: Mac Catalyst (macOS) + displayName: Mac Catalyst continueOnError: true buildAgent: ${{ parameters.buildAgentMac }} target: tests-maccatalyst @@ -311,10 +311,10 @@ stages: - name: testlogs_maccatalyst always: true path: 'output/logs/testlogs' - - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests [WASM] (Linux) + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|WASM parameters: name: tests_wasm_linux - displayName: WASM (Linux) + displayName: WASM buildAgent: ${{ parameters.buildAgentLinux }} packages: $(MANAGED_LINUX_PACKAGES) ninja-build target: tests-wasm @@ -347,7 +347,7 @@ stages: # mismatch in the native lib, fail CI. installPreviewSdk provisions the preview SDK + # wasm-tools workload (which bundles the matching emscripten) and pins global.json to it. name: tests_wasm_preview_linux - displayName: WASM Preview (Linux) + displayName: WASM (.NET Preview) buildAgent: ${{ parameters.buildAgentLinux }} packages: $(MANAGED_LINUX_PACKAGES) ninja-build target: tests-wasm @@ -370,9 +370,134 @@ stages: - name: testlogs_wasm_preview always: true path: 'output/logs/testlogs' - # TODO: add tests for linux alpine - # TODO: add tests for linux no dependencies - # TODO: add tests for windows nano server + + # Containerized test legs — see documentation/dev/containerized-testing.md + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Linux (Azure Linux) + parameters: + name: tests_container_linux + displayName: Linux (Azure Linux) + buildAgent: ${{ parameters.buildAgentLinux }} + docker: scripts/infra/tests/docker/azurelinux + target: tests-container + additionalArgs: --nativePlatform=linux + installAndroidSdk: false + installXcode: false + shouldPublish: false + requiredArtifacts: + - name: native + postBuildSteps: + - task: PublishTestResults@2 + displayName: Publish the Azure Linux container test results + condition: always() + inputs: + testResultsFormat: VSTest + testResultsFiles: 'output/logs/testlogs/**/*.trx' + testRunTitle: 'Linux (Azure Linux) Tests' + publishArtifacts: + - name: testlogs_container_linux + always: true + path: 'output/logs/testlogs' + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Linux (Azure Linux, No Dependencies) + parameters: + name: tests_container_linux_nodeps + displayName: Linux (Azure Linux, No Dependencies) + buildAgent: ${{ parameters.buildAgentLinux }} + docker: scripts/infra/tests/docker/azurelinux-nodeps + target: tests-container + additionalArgs: --nativePlatform=linuxnodeps + installAndroidSdk: false + installXcode: false + shouldPublish: false + requiredArtifacts: + - name: native + postBuildSteps: + - task: PublishTestResults@2 + displayName: Publish the Azure Linux NoDeps container test results + condition: always() + inputs: + testResultsFormat: VSTest + testResultsFiles: 'output/logs/testlogs/**/*.trx' + testRunTitle: 'Linux (Azure Linux, No Dependencies) Tests' + publishArtifacts: + - name: testlogs_container_linux_nodeps + always: true + path: 'output/logs/testlogs' + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Linux (Alpine) + parameters: + name: tests_container_alpine_linux + displayName: Linux (Alpine) + buildAgent: ${{ parameters.buildAgentLinux }} + docker: scripts/infra/tests/docker/alpine + target: tests-container + additionalArgs: --nativePlatform=alpine + installAndroidSdk: false + installXcode: false + shouldPublish: false + requiredArtifacts: + - name: native + postBuildSteps: + - task: PublishTestResults@2 + displayName: Publish the Alpine container test results + condition: always() + inputs: + testResultsFormat: VSTest + testResultsFiles: 'output/logs/testlogs/**/*.trx' + testRunTitle: 'Linux (Alpine) Tests' + publishArtifacts: + - name: testlogs_container_alpine + always: true + path: 'output/logs/testlogs' + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Linux (Alpine, No Dependencies) + parameters: + name: tests_container_alpine_nodeps + displayName: Linux (Alpine, No Dependencies) + buildAgent: ${{ parameters.buildAgentLinux }} + docker: scripts/infra/tests/docker/alpine-nodeps + target: tests-container + additionalArgs: --nativePlatform=alpinenodeps + installAndroidSdk: false + installXcode: false + shouldPublish: false + requiredArtifacts: + - name: native + postBuildSteps: + - task: PublishTestResults@2 + displayName: Publish the Alpine NoDeps container test results + condition: always() + inputs: + testResultsFormat: VSTest + testResultsFiles: 'output/logs/testlogs/**/*.trx' + testRunTitle: 'Linux (Alpine, No Dependencies) Tests' + publishArtifacts: + - name: testlogs_container_alpine_nodeps + always: true + path: 'output/logs/testlogs' + - template: /scripts/azure-templates-jobs-bootstrapper.yml@self # Tests|Windows (Nano Server) + parameters: + name: tests_container_nanoserver_windows + displayName: Windows (Nano Server) + buildAgent: ${{ parameters.buildAgentWindows }} + docker: scripts/infra/tests/docker/nanoserver + target: tests-container + additionalArgs: --nativePlatform=nanoserver + installAndroidSdk: false + installXcode: false + shouldPublish: false + requiredArtifacts: + - name: native + postBuildSteps: + - task: PublishTestResults@2 + displayName: Publish the Nano Server container test results + condition: always() + inputs: + testResultsFormat: VSTest + testResultsFiles: 'output/logs/testlogs/**/*.trx' + testRunTitle: 'Nano Server Container Tests' + publishArtifacts: + - name: testlogs_container_nanoserver + always: true + path: 'output/logs/testlogs' + - job: coverage_reports # Coverage Reports displayName: Coverage Reports pool: ${{ parameters.buildAgentHost.pool }} diff --git a/scripts/infra/tests/docker/alpine-nodeps/Dockerfile b/scripts/infra/tests/docker/alpine-nodeps/Dockerfile new file mode 100644 index 00000000000..2a55b098a77 --- /dev/null +++ b/scripts/infra/tests/docker/alpine-nodeps/Dockerfile @@ -0,0 +1,20 @@ +# Containerized test environment — Linux Alpine, No Dependencies (musl). +# +# This image proves the NoDependencies musl native build (skia_use_fontconfig=false) runs on a bare +# Alpine image with NOTHING installed beyond the base .NET SDK — no fontconfig, no fonts, no extra +# runtime libraries. That is the whole point of the leg: it fails if libSkiaSharp ever grows an +# undeclared dependency on an optional system package. The font-dependent tests self-skip at +# runtime (there is no system font manager), so the suite still passes. +# +# Consumed by the bootstrapper's `docker:` feature via: +# +# docker run --volume :/work skiasharp /bin/sh /work/cmd.sh +# +# The repository (including output/native/alpinenodeps//libSkiaSharp.so from the `native` +# artifact) is mounted at /work at run time; tests-container selects it via --nativePlatform=alpinenodeps. +# The base image already provides everything the native library links against (libstdc++, libgcc, +# musl libc, ICU), so there is deliberately nothing to install here. + +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine + +WORKDIR /work diff --git a/scripts/infra/tests/docker/alpine/Dockerfile b/scripts/infra/tests/docker/alpine/Dockerfile new file mode 100644 index 00000000000..0059db36142 --- /dev/null +++ b/scripts/infra/tests/docker/alpine/Dockerfile @@ -0,0 +1,28 @@ +# Containerized test environment — Linux Alpine (musl). +# +# This image is the ENVIRONMENT the SkiaSharp console test suite runs inside. It is consumed by the +# bootstrapper's `docker:` feature (scripts/azure-templates-jobs-bootstrapper.yml), which builds it +# with the approved container-image task and then runs the tests inside it via: +# +# docker run --volume :/work skiasharp /bin/sh /work/cmd.sh +# # where cmd.sh = "dotnet tool restore" + "dotnet cake --target=tests-container ..." +# +# So this Dockerfile only provisions fonts; the repository (including output/native/alpine// +# libSkiaSharp.so — the musl build — from the `native` artifact) is mounted at /work at run time. +# There is intentionally no COPY here. The build context is this directory, not the repo. The +# tests-container cake target selects the musl build via --nativePlatform=alpine. + +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine + +# Fonts for Skia's fontconfig-backed default font manager (the base .NET SDK image already provides +# the C++/ICU runtime the native library links against, so only fonts are added): +# - fontconfig: font discovery used by Skia at runtime +# - font-dejavu: "DejaVu Sans" (DefaultFontFamily on Linux tests) +# - font-noto-emoji: "Noto Color Emoji" (UnicodeFontFamilies on musl — covers emoji like U+1F680) +RUN apk add --no-cache \ + fontconfig \ + font-dejavu \ + font-noto-emoji \ + && fc-cache -f + +WORKDIR /work diff --git a/scripts/infra/tests/docker/azurelinux-nodeps/Dockerfile b/scripts/infra/tests/docker/azurelinux-nodeps/Dockerfile new file mode 100644 index 00000000000..40434209d43 --- /dev/null +++ b/scripts/infra/tests/docker/azurelinux-nodeps/Dockerfile @@ -0,0 +1,20 @@ +# Containerized test environment — Linux Azure Linux, No Dependencies (glibc). +# +# This image proves the NoDependencies native build (skia_use_fontconfig=false) runs on a bare +# glibc image with NOTHING installed beyond the base .NET SDK — no fontconfig, no fonts, no extra +# runtime libraries. That is the whole point of the leg: it fails if libSkiaSharp ever grows an +# undeclared dependency on an optional system package. The font-dependent tests self-skip at +# runtime (there is no system font manager), so the suite still passes. +# +# Consumed by the bootstrapper's `docker:` feature via: +# +# docker run --volume :/work skiasharp /bin/sh /work/cmd.sh +# +# The repository (including output/native/linuxnodeps//libSkiaSharp.so from the `native` +# artifact) is mounted at /work at run time; tests-container selects it via --nativePlatform=linuxnodeps. +# The base image already provides everything the native library links against (libstdc++, libgcc, +# libc, ICU), so there is deliberately nothing to install here. + +FROM mcr.microsoft.com/dotnet/sdk:10.0-azurelinux3.0 + +WORKDIR /work diff --git a/scripts/infra/tests/docker/azurelinux/Dockerfile b/scripts/infra/tests/docker/azurelinux/Dockerfile new file mode 100644 index 00000000000..74ce9269371 --- /dev/null +++ b/scripts/infra/tests/docker/azurelinux/Dockerfile @@ -0,0 +1,36 @@ +# Containerized test environment — Linux Azure Linux (glibc). +# +# This image is the ENVIRONMENT the SkiaSharp console test suite runs inside. It is consumed by the +# bootstrapper's `docker:` feature (scripts/azure-templates-jobs-bootstrapper.yml), which builds it +# with the approved container-image task and then runs the tests inside it via: +# +# docker run --volume :/work skiasharp /bin/sh /work/cmd.sh +# # where cmd.sh = "dotnet tool restore" + "dotnet cake --target=tests-container ..." +# +# So this Dockerfile only provisions fonts; the repository (including output/native/linux// +# libSkiaSharp.so from the `native` artifact) is mounted at /work at run time. There is intentionally +# no COPY here. The build context is this directory, not the repo. The tests-container cake target +# selects the glibc build via --nativePlatform=linux. +# +# Azure Linux is glibc, so it shares the desktop Linux leg's fontconfig behavior and font config +# (DefaultFontFamily "DejaVu Sans"). It exercises a non-Ubuntu glibc distro (the desktop leg already +# covers Ubuntu). + +FROM mcr.microsoft.com/dotnet/sdk:10.0-azurelinux3.0 + +# Fonts for Skia's fontconfig-backed default font manager: +# - fontconfig: font discovery used by Skia at runtime +# - dejavu-sans-fonts: "DejaVu Sans" (DefaultFontFamily on Linux tests) +# Azure Linux has no emoji/symbol font in any repo, so download Noto Emoji (SIL OFL, monochrome) +# for the unicode tests (covers e.g. U+1F680) and verify it by SHA-256. Pinned to a fixed +# googlefonts/noto-emoji commit so the file is reproducible. +ARG NOTO_EMOJI_URL=https://raw.githubusercontent.com/googlefonts/noto-emoji/9a5261d871451f9b5183c93483cbd68ed916b1e9/fonts/NotoEmoji-Regular.ttf +ARG NOTO_EMOJI_SHA256=415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5 +RUN tdnf install -y fontconfig dejavu-sans-fonts \ + && tdnf clean all \ + && mkdir -p /usr/share/fonts/noto \ + && curl -fSL "$NOTO_EMOJI_URL" -o /usr/share/fonts/noto/NotoEmoji-Regular.ttf \ + && echo "$NOTO_EMOJI_SHA256 /usr/share/fonts/noto/NotoEmoji-Regular.ttf" | sha256sum -c - \ + && fc-cache -f + +WORKDIR /work diff --git a/scripts/infra/tests/docker/nanoserver/Dockerfile b/scripts/infra/tests/docker/nanoserver/Dockerfile new file mode 100644 index 00000000000..f419bae53ee --- /dev/null +++ b/scripts/infra/tests/docker/nanoserver/Dockerfile @@ -0,0 +1,26 @@ +# escape=` +# Containerized test environment — Windows Nano Server. +# +# This image is the ENVIRONMENT the SkiaSharp console test suite runs inside. It is consumed by the +# bootstrapper's `docker:` feature (scripts/azure-templates-jobs-bootstrapper.yml), which builds it +# with the approved container-image task and then runs the tests inside it via: +# +# docker run --volume :C:\work -w C:\work skiasharp cmd /c C:\work\cmd.cmd +# # where cmd.cmd = "dotnet tool restore" + "dotnet cake --target=tests-container ..." +# +# So this Dockerfile only provisions the toolchain; the repository (including +# output/native/nanoserver/x64/libSkiaSharp.dll from the `native` artifact) is mounted at C:\work at +# run time — there is intentionally no COPY here. The build context is this directory, not the repo. +# +# It uses the Nano Server .NET *SDK* image so the suite can be built AND run in one place. Because +# it is Nano Server, it has ZERO system fonts — which is exactly the environment under test. The +# tests-container cake target overlays output/native/nanoserver/x64/libSkiaSharp.dll (via +# --nativePlatform=nanoserver), so the suite runs against the nanoserver native build, not the +# regular windows one. +# +# REQUIRES a Windows container host (Windows agent with container support). It cannot be built or +# run on Linux/macOS Docker. + +FROM mcr.microsoft.com/dotnet/sdk:10.0-nanoserver-ltsc2022 + +WORKDIR C:\work diff --git a/scripts/infra/tests/tests-container.cake b/scripts/infra/tests/tests-container.cake new file mode 100644 index 00000000000..d9010d0e16c --- /dev/null +++ b/scripts/infra/tests/tests-container.cake @@ -0,0 +1,71 @@ +DirectoryPath ROOT_PATH = MakeAbsolute(Directory("../../..")); + +#load "../shared/shared.cake" +#load "../shared/msbuild.cake" +#load "test-shared.cake" + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// CONTAINER TESTS — run ONLY the console test suite (SkiaSharp.Tests.Console) +// +// This target is invoked INSIDE a container by the bootstrapper's `docker:` feature +// (docker run ... dotnet cake --target=tests-container). It does NOT build any native +// externals — the prebuilt libSkiaSharp is provided in the mounted repo's output/native (populated +// in CI from the `native` artifact; locally by externals-download). +// +// It runs a single assembly (the cross-platform console suite) rather than the full tests-netcore +// set, so the container image stays minimal (no GTK4 / Vulkan / Direct3D dependencies). +// +// --nativePlatform selects which output/native// build to run against. It is passed +// through to the native-asset targets as the SkiaSharp/HarfBuzzSharp NativePlatform MSBuild property, +// so the build copies that platform's library. This is how the Windows container runs against the +// nanoserver build (output/native/nanoserver/x64/libSkiaSharp.dll) and Alpine against the musl build, +// which the OS-derived defaults don't map. +// +// dotnet cake --target=tests-container --nativePlatform=linux +// dotnet cake --target=tests-container --nativePlatform=alpine +// dotnet cake --target=tests-container --nativePlatform=nanoserver +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// Which output/native// build to run against. The is derived from the host +// (OSArchitecture) by the native-asset targets, so only the platform is specified here. +var NATIVE_PLATFORM = Argument("nativePlatform", ""); + +Task ("Default") + .Description ("Run the .NET console test suite (for containerized runs).") + .Does (() => +{ + CleanDirectories ($"{PACKAGE_CACHE_PATH}/skiasharp*"); + CleanDirectories ($"{PACKAGE_CACHE_PATH}/harfbuzzsharp*"); + + var tfm = "net10.0"; + var testAssembly = "SkiaSharp.Tests.Console"; + var csproj = $"{ROOT_PATH}/tests/{testAssembly}/{testAssembly}.csproj"; + + var props = new Dictionary { + // TargetFrameworks (plural) collapses the multi-targeted binding projects to net10.0 only, + // so the Android/iOS/etc. workloads are never restored/built — keeping the image lean. + { "TargetFramework", tfm }, + { "TargetFrameworks", tfm }, + // The Windows-only sn.exe re-sign step isn't present in the Nano Server SDK image (and isn't + // needed to run tests — PublicSign already gives the correct identity for InternalsVisibleTo). + { "DisableStrongNameSigning", "true" }, + // mdoc.exe doesn't run on Nano Server (missing Windows DLL deps) and doc generation is + // irrelevant to running the test suite, so skip it in every container build. + { "SkipMDocGenerateDocs", "true" }, + }; + + // Select the native build: the native-asset targets copy this platform's library. + if (!string.IsNullOrEmpty(NATIVE_PLATFORM)) { + props["SkiaSharpNativePlatform"] = NATIVE_PLATFORM; + props["HarfBuzzSharpNativePlatform"] = NATIVE_PLATFORM; + } + + if (!SKIP_BUILD) { + RunDotNetBuild (csproj, properties: props); + } + + var results = $"{ROOT_PATH}/output/logs/testlogs/{testAssembly}/{DATE_TIME_STR}/{tfm}"; + RunDotNetTest (csproj, results, properties: props); +}); + +RunTarget(TARGET); diff --git a/source/SkiaSharp.Build.targets b/source/SkiaSharp.Build.targets index 4bd5357a81f..4c890d4906d 100644 --- a/source/SkiaSharp.Build.targets +++ b/source/SkiaSharp.Build.targets @@ -151,6 +151,10 @@ internal partial class VersionConstants { _SignAssembly Sign the assembly using sn. + + Set DisableStrongNameSigning=true to skip this sn.exe re-sign (and its verify) where sn.exe is + unavailable, e.g. the Nano Server SDK container. PublicSign still applies, so the assembly keeps + its public-key identity for InternalsVisibleTo. =================================================================================================================== --> @@ -163,7 +167,7 @@ internal partial class VersionConstants { + Condition=" $(IsWindows) and '$(SignAssembly)' == 'true' and '$(TargetPath)' != '' and '$(BuildingInsideVisualStudio)' != 'true' and '$(DisableStrongNameSigning)' != 'true' "> @@ -182,7 +186,7 @@ internal partial class VersionConstants { + Condition=" $(IsWindows) and '$(SignAssembly)' == 'true' and '$(Configuration)' == 'Release' and '$(TargetPath)' != '' and '$(BuildingInsideVisualStudio)' != 'true' and '$(DisableStrongNameSigning)' != 'true' "> diff --git a/tests/Content/Goldens/raster.nanoserver/Text.png b/tests/Content/Goldens/raster.nanoserver/Text.png new file mode 100644 index 00000000000..f5e53aaad35 Binary files /dev/null and b/tests/Content/Goldens/raster.nanoserver/Text.png differ diff --git a/tests/SkiaSharp.Tests.Console/DefaultTestConfig.Console.cs b/tests/SkiaSharp.Tests.Console/DefaultTestConfig.Console.cs index 1734e3ceeac..a0fa62d85eb 100644 --- a/tests/SkiaSharp.Tests.Console/DefaultTestConfig.Console.cs +++ b/tests/SkiaSharp.Tests.Console/DefaultTestConfig.Console.cs @@ -33,7 +33,12 @@ public DefaultTestConfig() // set the test fields DefaultFontFamily = IsLinux ? "DejaVu Sans" : "Arial"; UnicodeFontFamilies = - IsLinux ? new[] { "Symbola" } : + IsLinux ? (SkiaSharp.Internals.PlatformConfiguration.IsGlibc + // glibc images resolve emoji via "Symbola" (ttf-ancient-fonts, on the desktop + // Ubuntu agent) or "Noto Emoji" (downloaded into the Azure Linux container image); + // Alpine/musl images install font-noto-emoji, matching "Noto Color Emoji". + ? new[] { "Symbola", "Noto Emoji" } + : new[] { "Noto Color Emoji" }) : IsMac ? new[] { "Apple Color Emoji" } : new[] { "Segoe UI Emoji", "Segoe UI Symbol" }; } diff --git a/tests/SkiaSharp.Tests.Console/SkiaSharp/SKBitmapThreadingTest.cs b/tests/SkiaSharp.Tests.Console/SkiaSharp/SKBitmapThreadingTest.cs index a97e2384ab8..df393a13c60 100644 --- a/tests/SkiaSharp.Tests.Console/SkiaSharp/SKBitmapThreadingTest.cs +++ b/tests/SkiaSharp.Tests.Console/SkiaSharp/SKBitmapThreadingTest.cs @@ -19,9 +19,13 @@ public static void ImageScalingMultipleThreadsTest(int numThreads, int numIterat { // The (100, 1000) variant creates 100K undisposed native allocations to stress // GC finalizer throughput. On x86 (2GB address space), the GC can't keep up and - // Skia's native allocator fails. See #3608. - if (IntPtr.Size == 4 && numThreads >= 100 && numIterationsPerThread >= 1000) - Assert.Skip("Stress test skipped on x86 due to address space limit."); + // Skia's native allocator fails. See #3608. On musl (Alpine) the same allocation + // pressure across 100 threads stalls the allocator/finalizer interaction and the + // run hangs indefinitely (deterministic on x64 musl; glibc keeps up and passes). + // Skip the heavy variant on both; the (10,10) and (10,100) variants still run and + // exercise concurrent bitmap scaling on every platform. + if (numThreads >= 100 && numIterationsPerThread >= 1000 && (IntPtr.Size == 4 || IsMusl)) + Assert.Skip("Heavy threading stress variant is skipped on x86 (address space limit) and musl (allocator/finalizer stalls)."); var referenceFile = Path.Combine(PathToImages, "baboon.jpg"); diff --git a/tests/Tests/BaseTest.cs b/tests/Tests/BaseTest.cs index c38de2bd83c..7ba74f22f71 100644 --- a/tests/Tests/BaseTest.cs +++ b/tests/Tests/BaseTest.cs @@ -9,6 +9,13 @@ public abstract class BaseTest protected static bool IsMac = TestConfig.Current.IsMac; protected static bool IsUnix = TestConfig.Current.IsUnix; protected static bool IsWindows = TestConfig.Current.IsWindows; + protected static bool IsNanoServer = TestConfig.Current.IsNanoServer; + protected static bool IsMusl = TestConfig.Current.IsMusl; + + // XPS requires the Windows XPS Object Model / DirectWrite, present on desktop + // and Server Windows but not on Nano Server or non-Windows platforms (where + // CreateXps returns null). + protected static bool SupportsXps = IsWindows && !IsNanoServer; protected static string[] UnicodeFontFamilies => TestConfig.Current.UnicodeFontFamilies; protected static string DefaultFontFamily => TestConfig.Current.DefaultFontFamily; @@ -94,5 +101,31 @@ protected static void SkipOnPlatform(bool condition, string reason) { Assert.SkipWhen(condition, reason); } + + // True when the platform has no system font manager that can enumerate or match fonts. + // WASM has no font manager; the NoDependencies Linux builds (skia_use_fontconfig=false) and + // Windows Nano Server have an "empty" font manager that reports one empty family and matches + // nothing. Probing a basic Latin character ('a') detects all three (guard IsBrowser first so + // we never call into a non-existent manager on WASM). + protected static bool HasNoSystemFontManager => + IsBrowser || SkiaSharp.SKFontManager.Default.MatchCharacter('a') is null; + + // True when there is no usable default typeface. The NoDependencies and Nano Server builds + // default to an empty typeface (no glyphs). WASM keeps a single embedded default font, so it + // is NOT considered fontless here. + protected static bool HasNoDefaultFont => + SkiaSharp.SKTypeface.Default.IsEmpty; + + // Skip tests that need the system font manager to enumerate or match fonts. + protected static void SkipWhenNoSystemFontManager(string reason = "This platform has no system font manager to enumerate or match fonts.") + { + Assert.SkipWhen(HasNoSystemFontManager, reason); + } + + // Skip tests that need a usable default typeface (system fonts installed and enumerable). + protected static void SkipWhenNoDefaultFont(string reason = "This platform has no usable default typeface (no system fonts).") + { + Assert.SkipWhen(HasNoDefaultFont, reason); + } } } diff --git a/tests/Tests/SkiaSharp/SKDocumentTest.cs b/tests/Tests/SkiaSharp/SKDocumentTest.cs index 76901d8bf34..9d2e56d7717 100644 --- a/tests/Tests/SkiaSharp/SKDocumentTest.cs +++ b/tests/Tests/SkiaSharp/SKDocumentTest.cs @@ -44,19 +44,18 @@ public void PdfFileWithNonASCIIPathIsClosed() [Fact] public void XpsFileIsClosed() { + Assert.SkipWhen(!SupportsXps, "XPS is only supported on desktop/server Windows"); + var path = Path.Combine(PathToImages, Guid.NewGuid().ToString("D") + ".xps"); using (new SKAutoCoInitialize()) using (var doc = SKDocument.CreateXps(path)) { - if (IsWindows) - { - Assert.NotNull(doc); - Assert.NotNull(doc.BeginPage(100, 100)); + Assert.NotNull(doc); + Assert.NotNull(doc.BeginPage(100, 100)); - doc.EndPage(); - doc.Close(); - } + doc.EndPage(); + doc.Close(); } File.Delete(path); @@ -134,44 +133,29 @@ public void ManagedStreamDisposeOrder() [Fact] public void CanCreateXps() { - // XPS is only supported on Windows + Assert.SkipWhen(!SupportsXps, "XPS is only supported on desktop/server Windows"); using (var stream = new MemoryStream()) { using (new SKAutoCoInitialize()) using (var doc = SKDocument.CreateXps(stream)) { - if (IsWindows) - { - Assert.NotNull(doc); - Assert.NotNull(doc.BeginPage(100, 100)); + Assert.NotNull(doc); + Assert.NotNull(doc.BeginPage(100, 100)); - doc.EndPage(); - doc.Close(); - } - else - { - Assert.Null(doc); - } + doc.EndPage(); + doc.Close(); } - if (IsWindows) - { - Assert.True(stream.Length > 0); - Assert.True(stream.Position > 0); - } - else - { - Assert.True(stream.Length == 0); - Assert.True(stream.Position == 0); - } + Assert.True(stream.Length > 0); + Assert.True(stream.Position > 0); } } [Fact] public void CanCreateXpsWithOptions() { - // XPS is only supported on Windows + Assert.SkipWhen(!SupportsXps, "XPS is only supported on desktop/server Windows"); var options = new SKDocumentXpsOptions { Dpi = 150, AllowNoPngs = true }; @@ -180,30 +164,35 @@ public void CanCreateXpsWithOptions() using (new SKAutoCoInitialize()) using (var doc = SKDocument.CreateXps(stream, options)) { - if (IsWindows) - { - Assert.NotNull(doc); - Assert.NotNull(doc.BeginPage(100, 100)); + Assert.NotNull(doc); + Assert.NotNull(doc.BeginPage(100, 100)); - doc.EndPage(); - doc.Close(); - } - else - { - Assert.Null(doc); - } + doc.EndPage(); + doc.Close(); } - if (IsWindows) - { - Assert.True(stream.Length > 0); - Assert.True(stream.Position > 0); - } - else + Assert.True(stream.Length > 0); + Assert.True(stream.Position > 0); + } + } + + [Fact] + public void CreateXpsReturnsNullWhereUnsupported() + { + Assert.SkipWhen(SupportsXps, "XPS is supported on this platform"); + + var options = new SKDocumentXpsOptions { Dpi = 150, AllowNoPngs = true }; + + using (var stream = new MemoryStream()) + { + using (new SKAutoCoInitialize()) { - Assert.True(stream.Length == 0); - Assert.True(stream.Position == 0); + Assert.Null(SKDocument.CreateXps(stream)); + Assert.Null(SKDocument.CreateXps(stream, options)); } + + Assert.True(stream.Length == 0); + Assert.True(stream.Position == 0); } } diff --git a/tests/Tests/SkiaSharp/SKFontManagerTest.cs b/tests/Tests/SkiaSharp/SKFontManagerTest.cs index b9f10228781..7aa8463e533 100644 --- a/tests/Tests/SkiaSharp/SKFontManagerTest.cs +++ b/tests/Tests/SkiaSharp/SKFontManagerTest.cs @@ -10,7 +10,7 @@ public class SKFontManagerTest : SKTest [Fact] public void TestFontManagerMatchCharacter() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; var emoji = "🚀"; @@ -51,7 +51,7 @@ public void TestFamilyCount() [Fact] public void TestGetFontStyles() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; @@ -64,7 +64,7 @@ public void TestGetFontStyles() [Fact] public void TestMatchFamilyStyle() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; @@ -243,7 +243,7 @@ public unsafe void FromFamilyReturnsSameObject() [Fact] public unsafe void FromFamilyDisposeDoesNotDispose() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; @@ -261,7 +261,7 @@ public unsafe void FromFamilyDisposeDoesNotDispose() [Fact] public unsafe void TypefaceAndFontManagerReturnsSameObject() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; @@ -279,6 +279,7 @@ public unsafe void TypefaceAndFontManagerReturnsSameObject() public unsafe void GCStillCollectsTypeface() { SkipOnNonWindows("Test uses Windows-specific font path"); + SkipWhenNoSystemFontManager("Test resolves the 'Times New Roman' system font"); var handle = DoWork(); diff --git a/tests/Tests/SkiaSharp/SKFontStyleSetTest.cs b/tests/Tests/SkiaSharp/SKFontStyleSetTest.cs index 4c9e9a29ee8..926ab54d3da 100644 --- a/tests/Tests/SkiaSharp/SKFontStyleSetTest.cs +++ b/tests/Tests/SkiaSharp/SKFontStyleSetTest.cs @@ -27,7 +27,7 @@ public void TestFindsNothing() [Fact] public void TestSetHasAtLeastOne() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; @@ -53,7 +53,7 @@ public void TestCanGetStyles() [Fact] public void TestCanCreateBoldFromIndex() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; var set = fonts.GetFontStyles(DefaultFontFamily); @@ -82,7 +82,7 @@ public void TestCanCreateBoldFromIndex() [Fact] public void TestCanCreateBold() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; var set = fonts.GetFontStyles(DefaultFontFamily); @@ -123,7 +123,7 @@ public void CreateTypefaceReturnsSameTypeface() [Fact] public unsafe void CreateTypefaceDisposeDoesNotDispose() { - SkipOnPlatform(IsBrowser, "WASM has no system font manager"); + SkipWhenNoSystemFontManager(); var fonts = SKFontManager.Default; var set = fonts.GetFontStyles(DefaultFontFamily); diff --git a/tests/Tests/SkiaSharp/SKFontTest.cs b/tests/Tests/SkiaSharp/SKFontTest.cs index fa42b14679a..9d957bf65fb 100644 --- a/tests/Tests/SkiaSharp/SKFontTest.cs +++ b/tests/Tests/SkiaSharp/SKFontTest.cs @@ -90,7 +90,7 @@ public void PlainGlyphsReturnsTheCorrectNumberOfCharacters() [Fact] public void UnicodeGlyphsReturnsTheCorrectNumberOfCharacters() { - SkipOnPlatform(IsBrowser, "WASM has no system fonts with emoji support"); + SkipWhenNoSystemFontManager(); const string text = "🚀"; var emojiChar = StringUtilities.GetUnicodeCharacterCode(text, SKTextEncoding.Utf32); @@ -109,6 +109,7 @@ public void UnicodeGlyphsReturnsTheCorrectNumberOfCharacters() [Fact] public void ContainsTextIsCorrect() { + SkipWhenNoDefaultFont(); const string text = "A"; var font = new SKFont(); @@ -121,7 +122,7 @@ public void ContainsTextIsCorrect() [Fact] public void ContainsUnicodeTextIsCorrect() { - SkipOnPlatform(IsBrowser, "WASM has no system fonts with emoji support"); + SkipWhenNoSystemFontManager(); const string text = "🚀"; var emojiChar = StringUtilities.GetUnicodeCharacterCode(text, SKTextEncoding.Utf32); @@ -155,6 +156,7 @@ public void CanMeasureBadUnicodeText() [Fact] public void MeasureTextMeasuresTheText() { + SkipWhenNoDefaultFont(); var font = new SKFont(SKTypeface.Default); var width = font.MeasureText("Hello World!"); @@ -165,6 +167,7 @@ public void MeasureTextMeasuresTheText() [Fact] public void MeasureTextMeasuresTheTextForBytes() { + SkipWhenNoDefaultFont(); var font = new SKFont(SKTypeface.Default); var text8 = StringUtilities.GetEncodedText("Hello World!", SKTextEncoding.Utf8); @@ -184,6 +187,7 @@ public void MeasureTextMeasuresTheTextForBytes() [Fact] public void MeasureTextReturnsTheBounds() { + SkipWhenNoDefaultFont(); var font = new SKFont(SKTypeface.Default); var width = font.MeasureText("Hello World!", out var bounds); @@ -195,6 +199,7 @@ public void MeasureTextReturnsTheBounds() [Fact] public void MeasureTextReturnsTheBoundsForBytes() { + SkipWhenNoDefaultFont(); var font = new SKFont(SKTypeface.Default); var text8 = StringUtilities.GetEncodedText("Hello World!", SKTextEncoding.Utf8); @@ -277,6 +282,7 @@ public void GetGlyphWidthsReturnsTheCorrectAmount() [Fact] public void GetGlyphWidthsAreCorrect() { + SkipWhenNoDefaultFont(); SkipOnPlatform(IsBrowser, "WASM has no system fonts for glyph width measurement"); var font = new SKFont(SKTypeface.Default); @@ -303,6 +309,7 @@ public void GetGlyphWidthsAreCorrect() [Fact] public unsafe void TextInterceptsAreFoundCorrectly() { + SkipWhenNoDefaultFont(); var text = "|"; var font = new SKFont(SKTypeface.Default); @@ -335,6 +342,7 @@ public void GetTextPathSucceedsForEmptyString() [Fact] public void GetTextPathWithPositionsProducesNonEmptyPath() { + SkipWhenNoDefaultFont(); var font = new SKFont(); var text = "AV"; var glyphCount = font.CountGlyphs(text); @@ -354,6 +362,7 @@ public void GetTextPathWithPositionsProducesNonEmptyPath() [Fact] public void GetTextPathWithPositionsMatchesExpectedBounds() { + SkipWhenNoDefaultFont(); var font = new SKFont(); var text = "A"; @@ -384,6 +393,7 @@ public void GetTextPathWithPositionsMatchesExpectedBounds() [InlineData(SKTextEncoding.GlyphId, "a", 2)] public void BreakTextReturnsTheCorrectNumberOfBytes(SKTextEncoding encoding, string text, int expectedRead) { + SkipWhenNoDefaultFont(); var font = new SKFont(SKTypeface.Default); // get bytes @@ -442,6 +452,7 @@ public void BreakTextReturnsTheCorrectNumberOfCharacters() [InlineData(1 << 17)] public void BreakTextWidthIsEqualToMeasureTextWidth(int textSize) { + SkipWhenNoDefaultFont(); var font = new SKFont(SKTypeface.Default); if (textSize >= 0) @@ -465,6 +476,7 @@ public void BreakTextWidthIsEqualToMeasureTextWidth(int textSize) [InlineData(1 << 17)] public void BreakTextHandlesLongText(int textSize) { + SkipWhenNoDefaultFont(); var font = new SKFont(SKTypeface.Default); if (textSize >= 0) @@ -562,6 +574,7 @@ public void CanSetTypefacesWithoutCrashing(string fontfile) [Fact] public void DefaultFontTypefaceIsDefault() { + SkipWhenNoDefaultFont(); using var font = new SKFont(); Assert.NotNull(font.Typeface); Assert.False(font.Typeface.IsEmpty); @@ -598,6 +611,7 @@ public void FontTypefaceSetNullDoesNotCrashOnMeasure() [Fact] public void FontWithDefaultCanMeasure() { + SkipWhenNoDefaultFont(); using var font = new SKFont(SKTypeface.Default); var width = font.MeasureText("Hello World!"); Assert.True(width > 0); diff --git a/tests/Tests/SkiaSharp/SKPaintTest.cs b/tests/Tests/SkiaSharp/SKPaintTest.cs index df40125aaae..0d7605bc3ae 100644 --- a/tests/Tests/SkiaSharp/SKPaintTest.cs +++ b/tests/Tests/SkiaSharp/SKPaintTest.cs @@ -78,6 +78,7 @@ public void GetFillPathIsWorkingWithLine() [Fact] public void NonAntiAliasedTextOnScaledCanvasIsCorrect() { + SkipWhenNoDefaultFont(); SkipOnPlatform(IsAndroid, "TODO: figure out why the font has changed"); SkipOnPlatform(IsBrowser, "WASM text rendering produces slightly different pixel values"); diff --git a/tests/Tests/SkiaSharp/SKTypefaceTest.cs b/tests/Tests/SkiaSharp/SKTypefaceTest.cs index fc902523ac0..71bae11374d 100644 --- a/tests/Tests/SkiaSharp/SKTypefaceTest.cs +++ b/tests/Tests/SkiaSharp/SKTypefaceTest.cs @@ -240,7 +240,7 @@ public void PlainGlyphsReturnsTheCorrectNumberOfCharacters() [Obsolete ("Tests obsolete SKTypeface.CountGlyphs/GetGlyphs — see SKFontTest for non-obsolete equivalents")] public void UnicodeGlyphsReturnsTheCorrectNumberOfCharacters() { - SkipOnPlatform(IsBrowser, "WASM has no system fonts with emoji support"); + SkipWhenNoSystemFontManager(); const string text = "🚀"; var emojiChar = StringUtilities.GetUnicodeCharacterCode(text, SKTextEncoding.Utf32); @@ -260,6 +260,7 @@ public void UnicodeGlyphsReturnsTheCorrectNumberOfCharacters() [Obsolete ("Tests obsolete SKTypeface.ContainsGlyphs — see SKFontTest for non-obsolete equivalents")] public void ContainsGlyphsWithByteSpanDoesNotStackOverflow () { + SkipWhenNoDefaultFont(); using var typeface = SKTypeface.Default; var text = System.Text.Encoding.UTF8.GetBytes ("Hello"); ReadOnlySpan span = text; @@ -556,6 +557,7 @@ public unsafe void FromFamilyDisposeDoesNotDispose() public unsafe void GCStillCollectsTypeface() { SkipOnNonWindows("Test uses Windows-specific font path"); + SkipWhenNoSystemFontManager("Test resolves the 'Times New Roman' system font"); var handle = DoWork(); @@ -600,6 +602,7 @@ public void DefaultHasValidNativeHandle() [Fact] public void DefaultFamilyNameIsNotNullOrEmpty() { + SkipWhenNoDefaultFont(); Assert.NotNull(SKTypeface.Default.FamilyName); Assert.NotEmpty(SKTypeface.Default.FamilyName); } @@ -607,6 +610,7 @@ public void DefaultFamilyNameIsNotNullOrEmpty() [Fact] public void DefaultIsNotEmpty() { + SkipWhenNoDefaultFont(); Assert.False(SKTypeface.Default.IsEmpty); Assert.True(SKTypeface.Default.GlyphCount > 0); } diff --git a/tests/Tests/SkiaSharp/Visual/GoldenStore.cs b/tests/Tests/SkiaSharp/Visual/GoldenStore.cs index a3eebbaab32..3e71ab61417 100644 --- a/tests/Tests/SkiaSharp/Visual/GoldenStore.cs +++ b/tests/Tests/SkiaSharp/Visual/GoldenStore.cs @@ -62,21 +62,22 @@ public ResolvedGolden(byte[] pixels, string location) /// /// The default golden key for a cell, relative to the Goldens root: - /// {renderer}.{platform}/{scene}.png. This is the path the - /// captured-image marker carries and the harvest script writes to by - /// default; a promoted, platform-portable golden lives at the shared - /// {renderer}/{scene}.png key instead. + /// {renderer}.{platform}/{scene}.png using the most-specific platform + /// tag. This is the path the captured-image marker carries and the harvest + /// script writes to by default; a promoted, platform-portable golden lives at + /// the shared {renderer}/{scene}.png key instead. /// public static string Key(string rendererName, string sceneName) => - $"{rendererName}.{VisualPlatform.Tag}/{sceneName}.png"; + $"{rendererName}.{VisualPlatform.Tags[0]}/{sceneName}.png"; /// /// Golden keys for a cell in lookup order (most specific first): - /// the per-platform override, then the platform-portable renderer golden. + /// each per-platform tag, then the platform-portable renderer golden. /// public static IEnumerable Candidates(string rendererName, string sceneName) { - yield return $"{rendererName}.{VisualPlatform.Tag}/{sceneName}.png"; + foreach (var tag in VisualPlatform.Tags) + yield return $"{rendererName}.{tag}/{sceneName}.png"; yield return $"{rendererName}/{sceneName}.png"; } diff --git a/tests/Tests/SkiaSharp/Visual/Tests/VisualMatrixTestsBase.cs b/tests/Tests/SkiaSharp/Visual/Tests/VisualMatrixTestsBase.cs index 1454d105548..528c8d34323 100644 --- a/tests/Tests/SkiaSharp/Visual/Tests/VisualMatrixTestsBase.cs +++ b/tests/Tests/SkiaSharp/Visual/Tests/VisualMatrixTestsBase.cs @@ -158,7 +158,7 @@ private void EmitVisualImage(string rendererName, string sceneName, string kind, { var normalized = RendererPixels.NormalizedInfo(info); using var data = image.Encode(SKEncodedImageFormat.Png, 100); - var key = $"{rendererName}.{VisualPlatform.Tag}/{sceneName}.{kind}.png"; + var key = $"{rendererName}.{VisualPlatform.Tags[0]}/{sceneName}.{kind}.png"; WriteOutput( $"{VisualImageMarker} path={key} " + $"size={normalized.Width}x{normalized.Height} base64={Convert.ToBase64String(data.ToArray())}"); @@ -216,7 +216,7 @@ private void FailUnseeded(string rendererName, string sceneName, SKImageInfo inf // marker above), so seed it by harvesting the TRX and committing the // result, after which the cell compares strictly and goes green. Assert.Fail( - $"No golden recorded yet for '{rendererName}/{sceneName}' on '{VisualPlatform.Tag}' " + + $"No golden recorded yet for '{rendererName}/{sceneName}' on '{string.Join("' / '", VisualPlatform.Tags)}' " + $"(looked for {looked}). " + "The rendered PNG is in the test results as a ##SKIA-GOLDEN-IMAGE## marker; " + "seed it with scripts/infra/tests/extract-visual-goldens.py and commit. " + diff --git a/tests/Tests/SkiaSharp/Visual/VisualPlatform.cs b/tests/Tests/SkiaSharp/Visual/VisualPlatform.cs index 4da9edb7686..02197954706 100644 --- a/tests/Tests/SkiaSharp/Visual/VisualPlatform.cs +++ b/tests/Tests/SkiaSharp/Visual/VisualPlatform.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; namespace SkiaSharp.Tests.Visual { @@ -10,7 +12,25 @@ namespace SkiaSharp.Tests.Visual /// internal static class VisualPlatform { - public static string Tag { get; } = DetermineTag(); + /// + /// Golden directory tags for the current host, most specific first. Usually a + /// single entry, but Windows Nano Server rasterizes text with FreeType instead + /// of DirectWrite, so it looks up its own nanoserver golden first and + /// falls back to the shared windows golden for cells that render + /// identically (shapes, gradients). + /// + public static IReadOnlyList Tags { get; } = DetermineTags().ToList(); + + private static IEnumerable DetermineTags() + { + // Nano Server IS Windows but rasterizes text with FreeType, not DirectWrite, + // so it looks up its own golden first and then falls back to the shared + // "windows" golden (DetermineTag returns "windows" on Nano). + if (TestConfig.Current.IsNanoServer) + yield return "nanoserver"; + + yield return DetermineTag(); + } private static string DetermineTag() { diff --git a/tests/Tests/TestConfig.cs b/tests/Tests/TestConfig.cs index b19b83e2fe1..6b0f3c72a9f 100644 --- a/tests/Tests/TestConfig.cs +++ b/tests/Tests/TestConfig.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.CompilerServices; using SkiaSharp.Internals; namespace SkiaSharp.Tests @@ -20,6 +21,40 @@ public static TestConfig Current public bool IsMac => PlatformConfiguration.IsMac; public bool IsUnix => PlatformConfiguration.IsUnix; public bool IsWindows => PlatformConfiguration.IsWindows; + public bool IsGlibc => PlatformConfiguration.IsGlibc; + public bool IsMusl => PlatformConfiguration.IsLinux && !PlatformConfiguration.IsGlibc; + public bool IsNanoServer => _isNanoServer.Value; + + private static readonly Lazy _isNanoServer = new(DetectNanoServer); + + // Windows Nano Server identifies itself in the registry as InstallationType + // "Nano Server" (full Windows reports "Client"/"Server", Server Core reports + // "Server Core"). Guard on Windows first, and keep the registry read in a + // separate non-inlined method so the Microsoft.Win32 types are only resolved + // (JIT-compiled) on Windows and never on the mobile/WASM test hosts. + private static bool DetectNanoServer() => + PlatformConfiguration.IsWindows && ReadIsNanoServer(); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static bool ReadIsNanoServer() + { + try + { + using var key = Microsoft.Win32.Registry.LocalMachine + .OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + return string.Equals( + key?.GetValue("InstallationType") as string, + "Nano Server", + StringComparison.OrdinalIgnoreCase); + } + catch + { + // A constrained host can deny the registry read (SecurityException, + // UnauthorizedAccessException, etc.). Treat any failure as "not Nano Server" + // so a capability probe never fails the whole test run during config evaluation. + return false; + } + } public string[] UnicodeFontFamilies { get; protected set; } public string DefaultFontFamily { get; protected set; }