diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7222eef129a..0e4d70cf3f1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,10 +23,8 @@ /tests/Aspire.Hosting.Maui.Tests @jfversluis /tests/Aspire.Hosting.Testing.Tests @sebastienros /tests/Aspire.Hosting.Tests @mitchdenny -/tests/Aspire.Templates.Tests @radical @eerhardt /tests/Shared @radical @eerhardt /tests/helix @radical @eerhardt -/tests/templates.proj @radical @eerhardt # playground apps /playground/deployers @mitchdenny diff --git a/.github/actions/enumerate-tests/action.yml b/.github/actions/enumerate-tests/action.yml index ed12d1c444e..22ca6cefcc0 100644 --- a/.github/actions/enumerate-tests/action.yml +++ b/.github/actions/enumerate-tests/action.yml @@ -5,7 +5,7 @@ inputs: required: false type: string default: '' - description: 'Additional MSBuild arguments passed to the test matrix generation step (e.g., /p:IncludeTemplateTests=true /p:OnlyDeploymentTests=true)' + description: 'Additional MSBuild arguments passed to the test matrix generation step (e.g., /p:IncludeCliE2ETests=true /p:OnlyDeploymentTests=true)' # Output format: JSON with structure {"include": [{...}, ...]} # Each entry contains: @@ -19,7 +19,6 @@ inputs: # - testSessionTimeout: Timeout for the test session (e.g., '20m') # - testHangTimeout: Timeout for hung tests (e.g., '10m') # - requiresNugets: Boolean indicating if NuGet packages are needed -# - requiresTestSdk: Boolean indicating if test SDK is needed # - extraTestArgs: Additional test arguments (e.g., '--filter-trait "Partition=P1"') # - collection: (collection type only) Collection/partition name # - classname: (class type only) Fully qualified test class name diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 92e53231665..4051ead265b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -216,13 +216,6 @@ jobs: Write-Host "NuGet package source '$source' does not exist — skipping copy" } - - name: Install sdk for nuget based testing - if: ${{ fromJson(inputs.properties).requiresTestSdk == true }} - run: > - ${{ env.DOTNET_SCRIPT }} build ${{ github.workspace }}/tests/workloads.proj - /p:SkipPackageCheckForTemplatesTesting=true - /bl:${{ github.workspace }}/artifacts/log/Debug/InstallSdkForTesting.binlog - - name: Install Azure Functions Core Tools if: runner.os == 'Linux' && (inputs.testShortName == 'Playground' || inputs.testShortName == 'Azure') run: | @@ -616,7 +609,6 @@ jobs: path: | **/*.binlog testresults/** - artifacts/bin/Aspire.Templates.Tests/Debug/net8.0/logs/** artifacts/log/test-logs/** - name: Copy CLI E2E recordings for upload diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4061445999a..c898e4bf755 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,7 @@ jobs: - uses: ./.github/actions/enumerate-tests id: generate_tests_matrix with: - buildArgs: '/p:IncludeTemplateTests=true /p:IncludeCliE2ETests=${{ github.event_name == ''pull_request'' }}' + buildArgs: '/p:IncludeCliE2ETests=true' - name: Split matrix by dependency type id: split_matrix @@ -387,14 +387,10 @@ jobs: # 'skipped' can be when a transitive dependency fails and the dependent job gets 'skipped'. # For example, one of setup_* jobs failing and the dependent test jobs getting 'skipped'. # Some jobs are optional and can have an empty matrix. In those cases we allow a 'skipped' - # result. Specifically: - # - tests_no_nugets_overflow: this overflow bucket is expected to be empty unless the - # primary no-nugets matrix exceeds the overflow threshold. - # - tests_requires_nugets_macos: some runs intentionally produce no macOS requires-nugets - # tests, so an empty matrix and 'skipped' result are expected. - # - cli_starter_validation_windows: this job only runs for pull requests and is expected to - # be skipped for other workflow events. - # All other jobs in this gate are required, and a 'skipped' result is treated as a failure. + # result. Any matrix-backed test job is only required when setup_for_tests produced at + # least one entry for that bucket. The CLI starter validation job only runs for pull + # requests and is expected to be skipped for other workflow events. All other jobs in + # this gate are required, and a 'skipped' result is treated as a failure. if: >- ${{ always() && (contains(needs.*.result, 'failure') || @@ -403,21 +399,34 @@ jobs: (needs.extension_tests_win.result == 'skipped' || needs.cli_starter_validation_windows.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || - needs.tests_requires_nugets_windows.result == 'skipped' || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped') || - needs.tests_requires_cli_archive.result == 'skipped' || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_no_nugets).include[0] != null && + needs.tests_no_nugets.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_no_nugets_overflow).include[0] != null && + needs.tests_no_nugets_overflow.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_linux).include[0] != null && + needs.tests_requires_nugets_linux.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_windows).include[0] != null && + needs.tests_requires_nugets_windows.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null && + needs.tests_requires_cli_archive.result == 'skipped') || needs.polyglot_validation.result == 'skipped')) || (github.event_name != 'pull_request' && (needs.extension_tests_win.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || - needs.tests_requires_nugets_windows.result == 'skipped' || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_no_nugets).include[0] != null && + needs.tests_no_nugets.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_no_nugets_overflow).include[0] != null && + needs.tests_no_nugets_overflow.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_linux).include[0] != null && + needs.tests_requires_nugets_linux.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_windows).include[0] != null && + needs.tests_requires_nugets_windows.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null && + needs.tests_requires_cli_archive.result == 'skipped') || needs.polyglot_validation.result == 'skipped'))) }} run: | echo "One or more dependent jobs failed." diff --git a/Aspire.slnx b/Aspire.slnx index 18fa49a110f..b5237b84342 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -416,7 +416,6 @@ - diff --git a/docs/ci/TestingOnCI.md b/docs/ci/TestingOnCI.md index 36f8ecf8757..13668d27f3a 100644 --- a/docs/ci/TestingOnCI.md +++ b/docs/ci/TestingOnCI.md @@ -58,7 +58,7 @@ This invokes `eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder. - Writes a `.tests-metadata.json` file to `artifacts/helix/` containing: - `projectName`, `shortName`, `testProjectPath` - `supportedOSes` array (e.g., `["windows", "linux", "macos"]`) - - `properties` object with boolean flags (defined in `eng/testing/CITestsProperties.props`): `requiresNugets`, `requiresTestSdk`, `requiresCliArchive`, `requiresGitHubToken`, `enablePlaywrightInstall` + - `properties` object with boolean flags (defined in `eng/testing/CITestsProperties.props`): `requiresNugets`, `requiresCliArchive`, `requiresGitHubToken`, `enablePlaywrightInstall` - `testSessionTimeout`, `testHangTimeout` values - `uncollectedTestsSessionTimeout`, `uncollectedTestsHangTimeout` values - `splitTests` flag @@ -93,19 +93,18 @@ After all projects build, `eng/AfterSolutionBuild.targets` runs `eng/scripts/bui { "tests": [ { - "name": "Templates-StarterTests", - "shortname": "Templates-StarterTests", - "testProjectPath": "tests/Aspire.Templates.Tests/...", - "supportedOSes": ["windows", "linux", "macos"], + "name": "Cli E2E / SmokeTests", + "shortname": "SmokeTests", + "testProjectPath": "tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj", + "supportedOSes": ["linux"], "properties": { "requiresNugets": true, - "requiresTestSdk": true, - "requiresCliArchive": false, + "requiresCliArchive": true, "enablePlaywrightInstall": false }, - "testSessionTimeout": "20m", - "testHangTimeout": "10m", - "extraTestArgs": "--filter-class \"...\"" + "testSessionTimeout": "30m", + "testHangTimeout": "15m", + "extraTestArgs": "--filter-class \"Aspire.Cli.EndToEnd.Tests.SmokeTests\"" }, { "name": "Hosting-Docker", @@ -114,7 +113,6 @@ After all projects build, `eng/AfterSolutionBuild.targets` runs `eng/scripts/bui "supportedOSes": ["linux"], "properties": { "requiresNugets": false, - "requiresTestSdk": false, "requiresCliArchive": false, "enablePlaywrightInstall": false }, @@ -253,8 +251,6 @@ For tests that need the built Aspire packages (e.g., template tests, end-to-end ```xml true - - true ``` @@ -299,14 +295,13 @@ This flag is tracked in the test metadata and controls whether Playwright browse ## CI Test Property Registry -All boolean test properties (such as `RequiresNugets`, `RequiresTestSdk`, `RequiresCliArchive`, `EnablePlaywrightInstall`) are defined in a single source of truth: +All boolean test properties (such as `RequiresNugets`, `RequiresCliArchive`, `EnablePlaywrightInstall`) are defined in a single source of truth: **`eng/testing/CITestsProperties.props`** ```xml - @@ -395,7 +390,7 @@ To run enumeration locally and inspect the generated matrix: ./build.sh -test \ /p:TestRunnerName=TestEnumerationRunsheetBuilder \ /p:TestMatrixOutputPath=artifacts/canonical-test-matrix.json \ - /p:IncludeTemplateTests=true \ + /p:IncludeCliE2ETests=true \ /p:GenerateCIPartitions=true ``` diff --git a/docs/ci/azdo-public-pipeline.md b/docs/ci/azdo-public-pipeline.md index ce172f77361..293c7d8839e 100644 --- a/docs/ci/azdo-public-pipeline.md +++ b/docs/ci/azdo-public-pipeline.md @@ -69,24 +69,22 @@ Which tests run here is controlled by the `RunOnAzdoCI` property (see [Test Rout #### Helix Tests (`runHelixTests: true`) -1. Installs SDKs for testing (`tests/workloads.proj`) -2. Sends test work items to Helix via `send-to-helix.yml` → `send-to-helix-ci.proj` -3. Downloads `.trx` result files from Helix after completion +1. Sends test work items to Helix via `send-to-helix.yml` → `send-to-helix-ci.proj` +2. Downloads `.trx` result files from Helix after completion ## Helix Test Infrastructure ### How Tests Are Sent to Helix -The entry point is `tests/helix/send-to-helix-ci.proj`, which defines **four test categories**: +The entry point is `tests/helix/send-to-helix-ci.proj`, which defines **three test categories**: | Category | Targets file | Runs on Windows | Runs on Linux | Description | |---------------------|-------------------------------------------|-----------------|---------------|-------------------------------------------------------------------| | `basictests` | `send-to-helix-basictests.targets` | ✅ | ✅ | Standard unit/integration tests | | `endtoendtests` | `send-to-helix-endtoendtests.targets` | ❌ | ✅ | End-to-end scenario tests (needs Docker) | -| `templatestests` | `send-to-helix-templatestests.targets` | ✅ | ✅ | Template creation/run tests | | `buildonhelixtests` | `send-to-helix-buildonhelixtests.targets` | ❌ | ✅ | Tests that `dotnet build` + `dotnet test` on Helix (needs Docker) | -The `send-to-helix-ci.proj` first runs `PrepareDependencies` sequentially, then dispatches all categories in parallel via MSBuild. +The `send-to-helix-ci.proj` first runs `PrepareDependencies` sequentially, then dispatches all categories in parallel via MSBuild. The removed template coverage is replaced by a directly-run AzDO subset of `Aspire.Cli.EndToEnd.Tests`, not by a Helix category. Each category is handled by `send-to-helix-inner.proj` (the Helix SDK project), which imports the category-specific `.targets` file. @@ -109,14 +107,6 @@ Each test category has its own strategy for splitting tests into Helix work item - Each work item filters tests by `--filter-trait "scenario="` - Tests run only on Linux (Docker required) -#### `templatestests` - -- **One work item per test class** — test class names are extracted at build time -- The `ExtractTestClassNames` target runs the test assembly with `--list-tests` to discover classes -- Class names are written to `.tests.list` -- Each class becomes a separate Helix work item with `--filter-class ` -- Correlation payloads include multiple SDK versions (`dotnet-8`, `dotnet-9`, `dotnet-10`) - #### `buildonhelixtests` - **One work item per test project zip** — similar to `basictests` @@ -135,7 +125,7 @@ Each test category has its own strategy for splitting tests into Helix work item Each work item follows this lifecycle: -1. **Pre-commands**: Clean up stale processes (dotnet-tests, dcp.exe), start Docker cleanup, set environment variables (DCP paths, SDK paths, dev certs, Docker BuildKit) +1. **Pre-commands**: Clean up stale `dcp.exe` processes, start Docker cleanup, set environment variables (DCP paths, dev certs, Docker BuildKit) 2. **Command**: Run the test executable with MTP arguments, blame/crash dump collection, quarantine exclusion 3. **Post-commands**: List Docker state, rename `.trx` files for collection @@ -146,7 +136,6 @@ These are shared across all work items in a Helix job: - **DCP binary** — the orchestrator binary, set via `DcpPublisher__CliPath` - **Dev cert scripts** — for HTTPS dev certificate setup on Linux - **Docker CLI** — specific version installed on the agent -- **SDKs for testing** — `dotnet-tests` directory with a configured .NET SDK - **Built NuGet packages** — `artifacts/packages/Shipping/` for template tests - **Playwright browser dependencies** — for UI tests - **Azure Functions CLI** — for Functions integration tests @@ -187,7 +176,6 @@ This is also the easiest way to inspect locally what payload Helix agents will r 2. **Archive directories** (defined in `tests/Directory.Build.props`): - `artifacts/helix/tests/` — basic tests - `artifacts/helix/e2e-tests/` — end-to-end tests - - `artifacts/helix/templates-tests/` — template tests - `artifacts/helix/build-on-helix-tests/` — build-on-helix tests - `artifacts/helix/cli-e2e-tests/` — CLI E2E tests - `artifacts/helix/deployment-e2e-tests/` — deployment E2E tests @@ -198,11 +186,6 @@ This is also the easiest way to inspect locally what payload Helix agents will r - `nuget.config` — configured to resolve built packages from artifacts - Shared test utilities -4. **Test class extraction** (for `templatestests`): - - The `ExtractTestClassNames` target runs the test executable with `--list-tests` - - Extracts unique class names matching a prefix regex - - Writes them to `.tests.list` alongside the zip - ## Helix xUnit Configuration When `PrepareForHelix=true`, a special `xunit.runner.json` is used (`tests/helix/xunit.runner.json`): @@ -275,7 +258,6 @@ Since AzDO tests don't run on PRs, changes can silently break the pipeline. The **Real incidents**: - `49b1fd3b`: Template tests ran `dotnet test --list-tests` which invoked the system dotnet (6.0) instead of the repo's dotnet (8.0+) → "You must install or update .NET" error. A prior PR removed `DOTNET_ROOT` environment variable override for GitHub Actions, breaking AzDO. -- `258d2e95`: `dotnet-tests` SDK directory wasn't properly prepared for template and helix test runs **What to watch for**: Changes to `DOTNET_ROOT`, `PATH`, or SDK version settings in `BuildAndTest.yml`, `tests/Directory.Build.targets`, or helix targets. If a change works by relying on the system dotnet or GitHub Actions' pre-installed SDK, it will likely break AzDO/Helix. @@ -362,7 +344,6 @@ When reviewing PRs, flag these for manual AzDO validation (`/azp run aspire-test - [ ] Tests using `AddProject()` in projects that run on Helix - [ ] Changes to `AspireProjectOrPackageReference` items - [ ] New or modified Verify snapshot tests -- [ ] Changes to `tests/workloads.proj` or SDK setup ## How to Manually Trigger AzDO Tests @@ -387,7 +368,6 @@ This comment in a PR will trigger the `aspire-tests` pipeline, which runs both p | `tests/helix/send-to-helix-inner.proj` | Helix SDK project (work item builder) | | `tests/helix/send-to-helix-basictests.targets` | Basic test work items | | `tests/helix/send-to-helix-endtoendtests.targets` | E2E test work items (by scenario) | -| `tests/helix/send-to-helix-templatestests.targets` | Template test work items (by class) | | `tests/helix/send-to-helix-buildonhelixtests.targets` | Build-on-Helix test work items | | `eng/Testing.props` | Default test runner properties | | `eng/Testing.targets` | Test skip/run logic per runner context | diff --git a/dogfood.sh b/dogfood.sh index 79a1a325038..7d05991bed4 100755 --- a/dogfood.sh +++ b/dogfood.sh @@ -16,9 +16,10 @@ else fi REPO_ROOT=$(cd "${scriptroot}";pwd) -SDK_PATH=$REPO_ROOT/artifacts/bin/dotnet-tests +SDK_PATH=$REPO_ROOT/.dotnet if [ ! -x "$SDK_PATH/dotnet" ]; then echo "Error: Could not find dotnet at $SDK_PATH/dotnet" + echo "Run ./restore.sh first to install the repo-local SDK." return fi diff --git a/eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets b/eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets index 187ae06229f..8eff319d550 100644 --- a/eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets +++ b/eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets @@ -27,7 +27,6 @@ <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and $(_NormalizedProjectDirectory.Contains('tests/testproject'))">true <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and $(_NormalizedProjectDirectory.Contains('tests/TestingAppHost1'))">true - <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and '$(IncludeTemplateTests)' != 'true' and '$(MSBuildProjectName)' == 'Aspire.Templates.Tests'">true <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and '$(IncludeCliE2ETests)' != 'true' and '$(MSBuildProjectName)' == 'Aspire.Cli.EndToEnd.Tests'">true diff --git a/eng/Versions.props b/eng/Versions.props index 99087265486..1c521532234 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -8,13 +8,11 @@ preview.1 net8.0 $(DefaultTargetFramework);net9.0;net10.0 - + 8.0.21 9.0.10 - - 8.0.415 - - 9.0.306 + + 8.0.415 3.2.2 1.21.0 3.1.5 diff --git a/eng/pipelines/azure-pipelines-unofficial.yml b/eng/pipelines/azure-pipelines-unofficial.yml index dd92bf3940e..7662f19fd97 100644 --- a/eng/pipelines/azure-pipelines-unofficial.yml +++ b/eng/pipelines/azure-pipelines-unofficial.yml @@ -236,6 +236,9 @@ extends: repoLogPath: $(Build.Arcade.LogsPath) repoTestResultsPath: $(Build.Arcade.TestResultsPath) isWindows: true + targetRids: + - win-x64 + - win-arm64 - pwsh: | $ErrorActionPreference = 'Stop' $shippingDir = "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/Shipping" diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml index 848f70589dc..6833429ce53 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -327,6 +327,9 @@ extends: publishVSCodeExtension: ${{ parameters.publishVSCodeExtension }} vscePublishToken: $(VscePublishToken) vscePublishPreRelease: ${{ parameters.vscePublishPreRelease }} + targetRids: + - win-x64 + - win-arm64 # Extract the Aspire version from a generated nupkg filename so downstream # stages can use it. Aspire.Hosting.AppHost has no RID in its filename and diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index 11cbde39bbf..8051882b9c3 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -34,6 +34,9 @@ parameters: - name: vscePublishPreRelease type: boolean default: false + - name: targetRids + type: object + default: [] steps: # Internal pipeline: Build with pack+sign @@ -55,6 +58,27 @@ steps: /p:BuildExtension=true displayName: 🟣Build + # Verify the signed win-x64 CLI archive works (version check + starter/AppHost template smokes) + - pwsh: | + $ErrorActionPreference = 'Stop' + $archiveDir = "${{ parameters.repoArtifactsPath }}/signed-archives/${{ parameters.buildConfig }}" + $archive = Get-ChildItem -Path $archiveDir -Filter "aspire-cli-win-x64-*.zip" -Recurse | Select-Object -First 1 + if (-not $archive) { + Write-Error "No win-x64 CLI archive found under '$archiveDir'" + exit 1 + } + Write-Host "Found archive: $($archive.FullName)" + & "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.ps1" -ArchivePath $archive.FullName + if ($LASTEXITCODE -ne 0) { + Write-Host "##[error]CLI archive verification failed" + exit 1 + } + displayName: 🟣Verify CLI archive and template smokes (win-x64) + condition: succeeded() + env: + ASPIRE_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + # Log MicroBuild environment for debugging # MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml # which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps. @@ -192,29 +216,61 @@ steps: PathtoPublish: '${{ parameters.repoArtifactsPath }}/packages/Release/vscode' ArtifactName: aspire-vscode-extension - - script: ${{ parameters.dotnetScript }} - build - tests/workloads.proj - /p:SkipPackageCheckForTemplatesTesting=true - displayName: 🟣Prepare sdks for templates testing + # Run a small, intentionally marked CLI E2E template-coverage subset directly on AzDO. + - ${{ if ne(parameters.isWindows, 'true') }}: + - script: | + set -euo pipefail - - script: ${{ parameters.buildScript }} - -build - -restore - -test - -configuration ${{ parameters.buildConfig }} - /bl:${{ parameters.repoLogPath }}/BuildTemplatesTests.binlog - $(_OfficialBuildIdArgs) - $(_InternalBuildArgs) - /p:SkipTests=false - -projects $(Build.SourcesDirectory)\tests\Aspire.Templates.Tests\Aspire.Templates.Tests.csproj - env: - RunOnlyBasicBuildTemplateTests: true - # test root path for template test projects - DEV_TEMP: $(Build.SourcesDirectory)\.. - DOTNET_ROOT: $(Build.SourcesDirectory)\.dotnet - TEST_LOG_PATH: $(Build.SourcesDirectory)\artifacts\log\$(_BuildConfig)\Aspire.Templates.Tests - displayName: 🟣Run Template tests + archive_dir="$(Build.StagingDirectory)/cli-e2e-archives" + mkdir -p "$archive_dir" + + cp "${{ parameters.repoArtifactsPath }}/signed-archives/${{ parameters.buildConfig }}/aspire-cli-linux-x64-"*.tar.gz "$archive_dir/" + cp "${{ parameters.repoArtifactsPath }}/packages/${{ parameters.buildConfig }}/Shipping/"*.nupkg "$archive_dir/" + + ASPIRE_E2E_CLI_ARCHIVE_DIR="$archive_dir" \ + ${{ parameters.dotnetScript }} test \ + --project "$(Build.SourcesDirectory)/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj" \ + --no-launch-profile \ + -- \ + --filter-trait "azdo-template-coverage=true" \ + --filter-not-trait "quarantined=true" \ + --filter-not-trait "outerloop=true" + env: + DOCKER_BUILDKIT: 1 + DOTNET_ROOT: $(Build.SourcesDirectory)/.dotnet + displayName: 🟣Run CLI E2E template coverage subset + + # Publish Windows CLI archives as separate artifacts so the Prepare Installers + # stage can download them by name (matching the native_archives_ pattern + # used by build_sign_native for macOS/Linux). + # win-arm64 archives are staged for downstream consumers; runtime verification is + # intentionally skipped because these win-x64 build agents cannot execute them. + - ${{ if eq(parameters.isWindows, 'true') }}: + - ${{ each targetRid in parameters.targetRids }}: + - pwsh: | + $ErrorActionPreference = 'Stop' + $sourceDir = '${{ parameters.repoArtifactsPath }}/signed-archives/${{ parameters.buildConfig }}' + $pattern = 'aspire-cli-${{ targetRid }}-*' + $stagingDir = '$(Build.StagingDirectory)/native_archives_${{ replace(targetRid, '-', '_') }}' + + New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + + $files = Get-ChildItem -Path $sourceDir -Recurse -Filter $pattern + if ($files.Count -eq 0) { + Write-Error "No CLI archives matching '$pattern' found under '$sourceDir'" + exit 1 + } + + foreach ($f in $files) { + Copy-Item $f.FullName $stagingDir + Write-Host "Staged: $($f.Name)" + } + displayName: 🟣Stage CLI archives (${{ targetRid }}) + - task: 1ES.PublishBuildArtifacts@1 + displayName: 🟣Publish native_archives_${{ replace(targetRid, '-', '_') }} + inputs: + PathtoPublish: $(Build.StagingDirectory)/native_archives_${{ replace(targetRid, '-', '_') }} + ArtifactName: native_archives_${{ replace(targetRid, '-', '_') }} # Public pipeline - helix tests - ${{ if eq(parameters.runAsPublic, 'true') }}: @@ -279,13 +335,6 @@ steps: # Helix tests are run only on the public pipeline - ${{ if and(eq(parameters.runAsPublic, 'true'), eq(parameters.runHelixTests, 'true')) }}: - - script: ${{ parameters.buildScript }} - /p:Configuration=${{ parameters.buildConfig }} - $(_OfficialBuildIdArgs) - /bl:${{ parameters.repoLogPath }}/InstallSdksForTesting.binlog - -projects $(Build.SourcesDirectory)/tests/workloads.proj - displayName: Install sdk for testing - # Helix captures code coverage information and, once tests are complete, the code coverage information is # downloaded to /artifacts/helixresults folder. - template: /eng/pipelines/templates/send-to-helix.yml diff --git a/eng/scripts/verify-cli-archive.ps1 b/eng/scripts/verify-cli-archive.ps1 index 98faaa73c18..6ddb7a83d69 100644 --- a/eng/scripts/verify-cli-archive.ps1 +++ b/eng/scripts/verify-cli-archive.ps1 @@ -7,8 +7,13 @@ 1. Cleans ~/.aspire to ensure no stale state 2. Extracts the CLI archive to a temp location 3. Runs 'aspire --version' to validate the binary executes - 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation - 5. Cleans up temp directories + 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + starter project creation + 5. Builds the generated starter AppHost project + 6. Enables hidden templates using a temp local config file and runs 'aspire new aspire-apphost' to validate empty AppHost creation + 7. Builds the generated empty AppHost project + 8. Builds representative template project-file scenarios that replaced Aspire.Templates.Tests coverage + 9. Builds the single-file AppHost template + 10. Cleans up temp directories .PARAMETER ArchivePath Path to the CLI archive (.zip or .tar.gz) @@ -55,9 +60,126 @@ function Set-ExecutablePermission([string]$Path) { } } +function Save-XmlDocument([xml]$Document, [string]$Path) { + $settings = [System.Xml.XmlWriterSettings]::new() + $settings.Indent = $true + $settings.OmitXmlDeclaration = $true + + $writer = [System.Xml.XmlWriter]::Create($Path, $settings) + try { + $Document.Save($writer) + } + finally { + $writer.Dispose() + } +} + +function Get-AppHostSdkVersion([string]$ProjectPath) { + [xml]$document = Get-Content -Raw $ProjectPath + $sdkValue = $document.Project.Sdk + if (-not $sdkValue) { + throw "Sdk attribute not found in '$ProjectPath'." + } + + $prefix = "Aspire.AppHost.Sdk/" + if (-not $sdkValue.StartsWith($prefix, [System.StringComparison]::Ordinal)) { + throw "Unexpected SDK value '$sdkValue' in '$ProjectPath'." + } + + return $sdkValue.Substring($prefix.Length) +} + +function Add-PackageReference([string]$ProjectPath, [string]$PackageName, [string]$Version = $null) { + [xml]$document = Get-Content -Raw $ProjectPath + $project = $document.Project + + $itemGroup = $document.CreateElement("ItemGroup") + $packageReference = $document.CreateElement("PackageReference") + $packageReference.SetAttribute("Include", $PackageName) + if ($Version) { + $packageReference.SetAttribute("Version", $Version) + } + + $itemGroup.AppendChild($packageReference) | Out-Null + $project.AppendChild($itemGroup) | Out-Null + Save-XmlDocument $document $ProjectPath +} + +function Rewrite-AsExplicitSdkReference([string]$ProjectPath, [bool]$IncludeAspireHostingAppHostPackageReference) { + [xml]$document = Get-Content -Raw $ProjectPath + $project = $document.Project + $version = Get-AppHostSdkVersion $ProjectPath + + $project.SetAttribute("Sdk", "Microsoft.NET.Sdk") + + $sdkElement = $document.CreateElement("Sdk") + $sdkElement.SetAttribute("Name", "Aspire.AppHost.Sdk") + $sdkElement.SetAttribute("Version", $version) + + if ($project.FirstChild) { + $project.InsertBefore($sdkElement, $project.FirstChild) | Out-Null + } + else { + $project.AppendChild($sdkElement) | Out-Null + } + + Save-XmlDocument $document $ProjectPath + + if ($IncludeAspireHostingAppHostPackageReference) { + Add-PackageReference $ProjectPath "Aspire.Hosting.AppHost" $version + } +} + +function Disable-PackageSourceMapping([string]$NuGetConfigPath) { + if (-not (Test-Path $NuGetConfigPath)) { + return + } + + [xml]$document = Get-Content -Raw $NuGetConfigPath + $node = $document.SelectSingleNode("/*[local-name()='configuration']/*[local-name()='packageSourceMapping']") + if ($node) { + $node.ParentNode.RemoveChild($node) | Out-Null + Save-XmlDocument $document $NuGetConfigPath + } +} + +function Add-CentralPackageManagementForRedis([string]$ProjectPath, [string]$DirectoryPackagesPropsPath) { + $version = Get-AppHostSdkVersion $ProjectPath + Add-PackageReference $ProjectPath "Aspire.Hosting.Redis" + + Set-Content -Path $DirectoryPackagesPropsPath -Value @" + + + true + NU1507;`$(NoWarn) + + + + + +"@ +} + $userHome = Get-UserHome $verifyTmpDir = $null $aspireBackup = $null +$dotnetCmd = $null + +function Get-DotNetCommand { + $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path + $repoDotNet = if ($IsWindows) { + Join-Path $repoRoot "dotnet.cmd" + } + else { + Join-Path $repoRoot "dotnet.sh" + } + + if (Test-Path $repoDotNet) { + return $repoDotNet + } + + return (Get-Command dotnet -ErrorAction Stop).Source +} function Invoke-Cleanup { if ($verifyTmpDir -and (Test-Path $verifyTmpDir)) { @@ -89,12 +211,14 @@ try { $env:DOTNET_CLI_TELEMETRY_OPTOUT = "true" $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "true" $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" + $dotnetCmd = Get-DotNetCommand Write-Host "" Write-Host "==========================================" Write-Host " Aspire CLI Archive Verification" Write-Host "==========================================" Write-Host " Archive: $ArchivePath" + Write-Host " dotnet: $dotnetCmd" Write-Host "==========================================" Write-Host "" @@ -163,7 +287,7 @@ try { Write-Host " Version: $versionOutput" Write-Ok "'aspire --version' succeeded" - # Step 4: Create a new project with aspire new + # Step 4: Create a new starter project with aspire new # This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) $projectDir = Join-Path $verifyTmpDir "VerifyApp" New-Item -ItemType Directory -Path $projectDir -Force | Out-Null @@ -184,6 +308,201 @@ try { } Write-Ok "'aspire new' created project successfully" + # Step 5: Build the generated starter AppHost project + $starterAppHostProject = Join-Path $appHostDir "VerifyApp.AppHost.csproj" + if (-not (Test-Path $starterAppHostProject)) { + Write-Err "Expected AppHost project '$starterAppHostProject' not found after 'aspire new aspire-starter'" + exit 1 + } + + Write-Step "Running 'dotnet build $starterAppHostProject'..." + & $dotnetCmd build $starterAppHostProject 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'dotnet build' failed for starter AppHost with exit code $LASTEXITCODE" + exit 1 + } + Write-Ok "Starter AppHost build succeeded" + + # Step 6: Enable hidden templates in a temp local config file so global developer config is untouched + $hiddenTemplateConfigDir = Join-Path $verifyTmpDir "hidden-template-config" + New-Item -ItemType Directory -Path $hiddenTemplateConfigDir -Force | Out-Null + + Write-Step "Enabling hidden Aspire templates using temp local config..." + Push-Location $hiddenTemplateConfigDir + try { + & $aspireBin config set features:showAllTemplates true --non-interactive 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire config set features:showAllTemplates' failed with exit code $LASTEXITCODE" + exit 1 + } + } + finally { + Pop-Location + } + Write-Ok "Hidden Aspire templates enabled in temp local config" + + # Step 7: Create an empty .NET AppHost project + $appHostTemplateDir = Join-Path $verifyTmpDir "VerifyAppHost" + New-Item -ItemType Directory -Path $appHostTemplateDir -Force | Out-Null + + Push-Location $hiddenTemplateConfigDir + try { + Write-Step "Running 'aspire new aspire-apphost --name VerifyAppHost --output $appHostTemplateDir --non-interactive --nologo'..." + & $aspireBin new aspire-apphost --name VerifyAppHost --output $appHostTemplateDir --non-interactive --nologo 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire new aspire-apphost' failed with exit code $LASTEXITCODE" + exit 1 + } + } + finally { + Pop-Location + } + + $emptyAppHostProject = Join-Path $appHostTemplateDir "VerifyAppHost.csproj" + if (-not (Test-Path $emptyAppHostProject)) { + Write-Err "Expected AppHost project '$emptyAppHostProject' not found after 'aspire new aspire-apphost'" + Get-ChildItem $appHostTemplateDir | Format-Table + exit 1 + } + Write-Ok "'aspire new aspire-apphost' created project successfully" + + # Step 8: Build the generated empty AppHost project + Write-Step "Running 'dotnet build $emptyAppHostProject'..." + & $dotnetCmd build $emptyAppHostProject 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'dotnet build' failed for empty AppHost with exit code $LASTEXITCODE" + exit 1 + } + Write-Ok "Empty AppHost build succeeded" + + # Step 9: Verify explicit SDK-reference AppHost project behavior on Windows + $explicitSdkDir = Join-Path $verifyTmpDir "VerifyExplicitSdkApp" + New-Item -ItemType Directory -Path $explicitSdkDir -Force | Out-Null + + Push-Location $hiddenTemplateConfigDir + try { + Write-Step "Running 'aspire new aspire --name VerifyExplicitSdkApp --output $explicitSdkDir --non-interactive --nologo'..." + & $aspireBin new aspire --name VerifyExplicitSdkApp --output $explicitSdkDir --non-interactive --nologo 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire new aspire' failed for explicit SDK scenario with exit code $LASTEXITCODE" + exit 1 + } + } + finally { + Pop-Location + } + + $explicitSdkAppHostProject = Join-Path $explicitSdkDir "VerifyExplicitSdkApp.AppHost/VerifyExplicitSdkApp.AppHost.csproj" + if (-not (Test-Path $explicitSdkAppHostProject)) { + Write-Err "Expected AppHost project '$explicitSdkAppHostProject' not found for explicit SDK scenario" + exit 1 + } + + Rewrite-AsExplicitSdkReference $explicitSdkAppHostProject $true + Write-Step "Running 'dotnet build $explicitSdkAppHostProject'..." + & $dotnetCmd build $explicitSdkAppHostProject 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'dotnet build' failed for explicit SDK AppHost with exit code $LASTEXITCODE" + exit 1 + } + Write-Ok "Explicit SDK AppHost build succeeded" + + # Step 10: Verify central package management AppHost project behavior on Windows + $cpmDir = Join-Path $verifyTmpDir "VerifyCpmApp" + New-Item -ItemType Directory -Path $cpmDir -Force | Out-Null + + Push-Location $hiddenTemplateConfigDir + try { + Write-Step "Running 'aspire new aspire --name VerifyCpmApp --output $cpmDir --non-interactive --nologo'..." + & $aspireBin new aspire --name VerifyCpmApp --output $cpmDir --non-interactive --nologo 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire new aspire' failed for CPM scenario with exit code $LASTEXITCODE" + exit 1 + } + } + finally { + Pop-Location + } + + $cpmAppHostProject = Join-Path $cpmDir "VerifyCpmApp.AppHost/VerifyCpmApp.AppHost.csproj" + if (-not (Test-Path $cpmAppHostProject)) { + Write-Err "Expected AppHost project '$cpmAppHostProject' not found for CPM scenario" + exit 1 + } + + Add-CentralPackageManagementForRedis $cpmAppHostProject (Join-Path $cpmDir "Directory.Packages.props") + Write-Step "Running 'dotnet build $cpmAppHostProject'..." + & $dotnetCmd build $cpmAppHostProject 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'dotnet build' failed for CPM AppHost with exit code $LASTEXITCODE" + exit 1 + } + Write-Ok "Central package management AppHost build succeeded" + + # Step 11: Verify AppHost package version override behavior on Windows + $versionedAppHostDir = Join-Path $verifyTmpDir "VerifyVersionedAppHost" + New-Item -ItemType Directory -Path $versionedAppHostDir -Force | Out-Null + + Push-Location $hiddenTemplateConfigDir + try { + Write-Step "Running 'aspire new aspire-apphost --name VerifyVersionedAppHost --output $versionedAppHostDir --non-interactive --nologo'..." + & $aspireBin new aspire-apphost --name VerifyVersionedAppHost --output $versionedAppHostDir --non-interactive --nologo 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire new aspire-apphost' failed for version override scenario with exit code $LASTEXITCODE" + exit 1 + } + } + finally { + Pop-Location + } + + $versionedAppHostProject = Join-Path $versionedAppHostDir "VerifyVersionedAppHost.csproj" + if (-not (Test-Path $versionedAppHostProject)) { + Write-Err "Expected AppHost project '$versionedAppHostProject' not found for version override scenario" + exit 1 + } + + Disable-PackageSourceMapping (Join-Path $versionedAppHostDir "nuget.config") + Add-PackageReference $versionedAppHostProject "Aspire.Hosting.AppHost" "8.1.0" + Write-Step "Running 'dotnet build $versionedAppHostProject'..." + & $dotnetCmd build $versionedAppHostProject 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'dotnet build' failed for version override AppHost with exit code $LASTEXITCODE" + exit 1 + } + Write-Ok "Version override AppHost build succeeded" + + # Step 12: Verify single-file AppHost template behavior on Windows + $singleFileAppHostDir = Join-Path $verifyTmpDir "VerifySingleFileAppHost" + New-Item -ItemType Directory -Path $singleFileAppHostDir -Force | Out-Null + + Write-Step "Running 'dotnet new aspire-apphost-singlefile --name VerifySingleFileAppHost --output $singleFileAppHostDir'..." + & $dotnetCmd new aspire-apphost-singlefile --name VerifySingleFileAppHost --output $singleFileAppHostDir 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'dotnet new aspire-apphost-singlefile' failed with exit code $LASTEXITCODE" + exit 1 + } + + $singleFileAppHostPath = Join-Path $singleFileAppHostDir "apphost.cs" + if (-not (Test-Path $singleFileAppHostPath)) { + Write-Err "Expected single-file AppHost '$singleFileAppHostPath' not found" + exit 1 + } + + Push-Location $singleFileAppHostDir + try { + Write-Step "Running 'dotnet build apphost.cs'..." + & $dotnetCmd build apphost.cs 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'dotnet build' failed for single-file AppHost with exit code $LASTEXITCODE" + exit 1 + } + } + finally { + Pop-Location + } + Write-Ok "Single-file AppHost build succeeded" + Write-Host "" Write-Host "==========================================" Write-Host " All verification checks passed!" -ForegroundColor Green diff --git a/eng/testing/CITestsProperties.props b/eng/testing/CITestsProperties.props index b64698c5158..7bb53a8dcfa 100644 --- a/eng/testing/CITestsProperties.props +++ b/eng/testing/CITestsProperties.props @@ -15,7 +15,6 @@ --> - diff --git a/playground/AspireWithMaui/AspireWithMaui.slnx b/playground/AspireWithMaui/AspireWithMaui.slnx index 8452badc2f2..e6da2e4e0b4 100644 --- a/playground/AspireWithMaui/AspireWithMaui.slnx +++ b/playground/AspireWithMaui/AspireWithMaui.slnx @@ -363,7 +363,6 @@ - diff --git a/src/Aspire.ProjectTemplates/README.md b/src/Aspire.ProjectTemplates/README.md index c8e1b381470..e32ebbb7133 100644 --- a/src/Aspire.ProjectTemplates/README.md +++ b/src/Aspire.ProjectTemplates/README.md @@ -24,4 +24,4 @@ dotnet pack ./src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj ### Updating tests -Template tests can be run using the standard test commands. You can follow the directions in *[repo_root]/tests/Aspire.Templates.Tests/README.md* to run them locally if desired, or simply send a PR and observe the test output there. +Template end-to-end coverage lives in *[repo_root]/tests/Aspire.Cli.EndToEnd.Tests/*. For local runs, follow the directions in *[repo_root]/tests/Aspire.Cli.EndToEnd.Tests/README.md*. diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj index 0edc29f9342..58e851de8aa 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj +++ b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj @@ -8,8 +8,6 @@ true true Exe - true - true true true @@ -42,8 +40,9 @@ $(TestArchiveTestsDirForCliEndToEndTests) - 30m - 15m + 45m + 30m + $(_MtpDiagnosticFlags) --hangdump-timeout $(TestHangTimeout) --timeout $(TestSessionTimeout) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs deleted file mode 100644 index 5a72dd22291..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Tests.Utils; -using Hex1b.Automation; -using Xunit; - -namespace Aspire.Cli.EndToEnd.Tests; - -/// -/// End-to-end tests for running .NET csproj AppHost projects using the Aspire bundle. -/// Validates that the bundle correctly provides DCP and Dashboard paths to the hosting -/// infrastructure when running SDK-based app hosts (not just polyglot/guest app hosts). -/// -public sealed class BundleSmokeTests(ITestOutputHelper output) -{ - [CaptureWorkspaceOnFailure] - [Fact] - public async Task CreateAndRunAspireStarterProjectWithBundle() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var strategy = CliInstallStrategy.Detect(output.WriteLine); - - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - - await auto.AspireNewAsync("BundleStarterApp", counter); - - await auto.AspireStartAsync(counter); - await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CSharpInitTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CSharpInitTests.cs index 702ef4c1fe2..d64ea83356b 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CSharpInitTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CSharpInitTests.cs @@ -26,7 +26,7 @@ public sealed class CSharpInitTests(ITestOutputHelper output) public async Task InteractiveCSharpInitCreatesExpectedFiles() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var strategy = CliInstallStrategy.Detect(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs index fa617337cb9..5a2c867a482 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs @@ -70,7 +70,7 @@ public async Task InvalidAppHostPathWithComments_IsHealedOnRun() await auto.WaitForSuccessPromptAsync(counter); await auto.TypeAsync("aspire run"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("Press CTRL+C to stop the AppHost and exit.", timeout: TimeSpan.FromMinutes(3)); + await auto.WaitUntilTextAsync("Press CTRL+C to stop the", timeout: TimeSpan.FromMinutes(3)); await auto.Ctrl().KeyAsync(Hex1bKey.C); await auto.WaitForSuccessPromptAsync(counter); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs index e4eaf3e0a2f..2d45dbbce8c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Hex1b.Automation; using Xunit; @@ -43,10 +42,7 @@ public async Task DescribeCommandShowsRunningResources() await auto.WaitForSuccessPromptAsync(counter); // Start the AppHost in the background using aspire start - await auto.TypeAsync("aspire start"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireStartAsync(counter); // Wait a bit for resources to stabilize await auto.TypeAsync("sleep 5"); @@ -73,10 +69,7 @@ public async Task DescribeCommandShowsRunningResources() await auto.WaitForSuccessPromptAsync(counter); // Stop the AppHost using aspire stop - await auto.TypeAsync("aspire stop"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(StopCommandStrings.AppHostStoppedSuccessfully, timeout: TimeSpan.FromMinutes(1)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireStopAsync(counter); // Exit the shell await auto.TypeAsync("exit"); @@ -147,10 +140,7 @@ public async Task DescribeCommandResolvesReplicaNames() output.WriteLine("Modified AppHost.cs to add .WithReplicas(2) to apiservice and clear fixed endpoint target ports"); // Start the AppHost in the background using aspire start - await auto.TypeAsync("aspire start"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireStartAsync(counter); // Wait for resources to stabilize await auto.TypeAsync("sleep 10"); @@ -198,10 +188,7 @@ public async Task DescribeCommandResolvesReplicaNames() await auto.WaitForSuccessPromptAsync(counter); // Stop the AppHost using aspire stop - await auto.TypeAsync("aspire stop"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(StopCommandStrings.AppHostStoppedSuccessfully, timeout: TimeSpan.FromMinutes(1)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireStopAsync(counter); // Exit the shell await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DotNetTemplateBehaviorTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DotNetTemplateBehaviorTests.cs new file mode 100644 index 00000000000..55e977f28a4 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/DotNetTemplateBehaviorTests.cs @@ -0,0 +1,646 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +public sealed class DotNetTemplateLocalhostTldTests(ITestOutputHelper output) +{ + public static TheoryData LocalhostTldHostnameTestData() + { + return new() + { + { "aspire", "my.namespace.app", "my-namespace-app", true }, + { "aspire", ".StartWithDot", "startwithdot", true }, + { "aspire", "EndWithDot.", "endwithdot", true }, + { "aspire", "My..Test__Project", "my-test-project", true }, + { "aspire", "Project123.Test456", "project123-test456", true }, + { "aspire-apphost", "my.service.name", "my-service-name", true }, + { "aspire-apphost-singlefile", "-my.service..name-", "my-service-name", true }, + { "aspire-starter", "Test_App.1", "test-app-1", false }, + { "aspire-ts-cs-starter", "My-App.Test", "my-app-test", false } + }; + } + + [Theory] + [MemberData(nameof(LocalhostTldHostnameTestData))] + [CaptureWorkspaceOnFailure] + public async Task LocalhostTldGeneratesDnsCompliantHostnames(string templateName, string projectName, string expectedHostname, bool useBootstrapInstall) + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + string projectRoot; + if (useBootstrapInstall) + { + await DotNetTemplateBehaviorTestHelpers.CreateTemplateBootstrapAsync(auto, counter); + projectRoot = await DotNetTemplateBehaviorTestHelpers.CreateDotNetTemplateInBootstrapAsync(auto, counter, workspace, templateName, projectName, ["--localhost-tld"]); + } + else + { + projectRoot = await DotNetTemplateBehaviorTestHelpers.CreateCliTemplateAsync(auto, counter, workspace, templateName, projectName, useLocalhostTld: true); + } + + DotNetTemplateBehaviorTestHelpers.AssertDevLocalhostHostname(projectRoot, templateName, projectName, expectedHostname); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} + +public sealed class DotNetSingleFileAppHostTemplateTests(ITestOutputHelper output) +{ + [Fact] + [Trait("azdo-template-coverage", "true")] + [CaptureWorkspaceOnFailure] + public async Task SingleFileAppHostTemplateBuildsAndStarts() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await DotNetTemplateBehaviorTestHelpers.CreateTemplateBootstrapAsync(auto, counter); + await DotNetTemplateBehaviorTestHelpers.CreateDotNetTemplateInBootstrapAsync(auto, counter, workspace, "aspire-apphost-singlefile", "SingleFileAppHost", []); + + await auto.TypeAsync("cd SingleFileAppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("dotnet build apphost.cs"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4)); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} + +public sealed class DotNetTemplateTransportTests(ITestOutputHelper output) +{ + [Fact] + [CaptureWorkspaceOnFailure] + public async Task NoHttpsTemplateRequiresAllowUnsecuredTransport() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await DotNetTemplateBehaviorTestHelpers.CreateTemplateBootstrapAsync(auto, counter); + var projectRoot = await DotNetTemplateBehaviorTestHelpers.CreateDotNetTemplateInBootstrapAsync(auto, counter, workspace, "aspire", "NoHttpsApp", ["--no-https"]); + var appHostDirectory = Path.Combine(projectRoot, "NoHttpsApp.AppHost"); + + await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(appHostDirectory, workspace)}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire start"); + await auto.EnterAsync(); + await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromMinutes(1)); + + var detachLogPath = await auto.CaptureLatestAspireLogAsync( + "~/.aspire/logs/cli_*_detach-child_*.log", + workspace, + counter, + "_aspire-detach.log"); + Assert.Contains("must be an https address unless the 'ASPIRE_ALLOW_UNSECURED_TRANSPORT' environment variable is set to true", File.ReadAllText(detachLogPath), StringComparison.Ordinal); + + await auto.ClearScreenAsync(counter); + + await auto.TypeAsync("export ASPIRE_ALLOW_UNSECURED_TRANSPORT=true"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} + +public sealed class DotNetTemplateTargetFrameworkTests(ITestOutputHelper output) +{ + [Fact] + [CaptureWorkspaceOnFailure] + public async Task DotNetTemplateCreatesSupportedTargetFrameworksAndOlderSdkRejectsNewerTarget() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await DotNetTemplateBehaviorTestHelpers.CreateTemplateBootstrapAsync(auto, counter); + + var projectRoots = new Dictionary(StringComparer.Ordinal) + { + ["net8.0"] = await DotNetTemplateBehaviorTestHelpers.CreateDotNetTemplateInBootstrapAsync(auto, counter, workspace, "aspire", "EmptyNet8", ["-f", "net8.0"]), + ["net10.0"] = await DotNetTemplateBehaviorTestHelpers.CreateDotNetTemplateInBootstrapAsync(auto, counter, workspace, "aspire", "EmptyNet10", ["-f", "net10.0"]) + }; + + foreach (var (targetFramework, projectRoot) in projectRoots) + { + DotNetTemplateBehaviorTestHelpers.AssertGeneratedProjectsTargetFramework(projectRoot, targetFramework); + + await auto.CaptureCommandOutputAsync( + $"dotnet build {AspireCliShellCommandHelpers.QuoteBashArg(CliE2ETestHelpers.ToContainerPath(projectRoot, workspace))}", + workspace, + counter, + $"dotnet-build-{targetFramework}.log", + TimeSpan.FromMinutes(4)); + } + + await auto.CaptureCommandOutputAsync( + "if [ ! -x /tmp/dotnet8/dotnet ]; then " + + "curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh && " + + "bash /tmp/dotnet-install.sh --channel 8.0 --install-dir /tmp/dotnet8 --no-path; " + + "fi", + workspace, + counter, + "dotnet8-install.log", + TimeSpan.FromMinutes(5)); + + var olderSdkBuildLogPath = await auto.CaptureFailingCommandOutputAsync( + $"DOTNET_MULTILEVEL_LOOKUP=0 /tmp/dotnet8/dotnet build {AspireCliShellCommandHelpers.QuoteBashArg(CliE2ETestHelpers.ToContainerPath(projectRoots["net10.0"], workspace))}", + workspace, + counter, + "dotnet8-net10-build.log", + TimeSpan.FromMinutes(2)); + + Assert.Contains("The current .NET SDK does not support targeting .NET 10.0", File.ReadAllText(olderSdkBuildLogPath), StringComparison.Ordinal); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} + +public sealed class DotNetTemplateProjectFileBehaviorTests(ITestOutputHelper output) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + [Trait("azdo-template-coverage", "true")] + [CaptureWorkspaceOnFailure] + public async Task DotNetTemplateWithExplicitSdkReferenceBuildsAndStarts(bool includeAspireHostingAppHostPackageReference) + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.EnableShowAllTemplatesAsync(counter); + + await auto.AspireNewSubcommandAsync("aspire", "ExplicitSdkApp", counter); + + var appHostProjectPath = Path.Combine(workspace.WorkspaceRoot.FullName, "ExplicitSdkApp", "ExplicitSdkApp.AppHost", "ExplicitSdkApp.AppHost.csproj"); + DotNetTemplateBehaviorTestHelpers.RewriteAsExplicitSdkReference(appHostProjectPath, includeAspireHostingAppHostPackageReference); + + var appHostDirectory = Path.GetDirectoryName(appHostProjectPath)!; + await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(appHostDirectory, workspace)}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("dotnet build"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [Fact] + [Trait("azdo-template-coverage", "true")] + [CaptureWorkspaceOnFailure] + public Task DotNetAppHostTemplateBuildsWithAppHostPackageVersionOverride_8_1_0() + => DotNetAppHostTemplateBuildsWithAppHostPackageVersionOverride("8.1.0"); + + [Theory] + [InlineData("9.*-*")] + [InlineData("[9.0.0]")] + [CaptureWorkspaceOnFailure] + public Task DotNetAppHostTemplateBuildsWithAppHostPackageVersionOverride(string version) + => DotNetAppHostTemplateBuildsWithAppHostPackageVersionOverrideCore(version); + + private async Task DotNetAppHostTemplateBuildsWithAppHostPackageVersionOverrideCore(string version) + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.EnableShowAllTemplatesAsync(counter); + + await auto.AspireNewSubcommandAsync("aspire-apphost", "VersionedAppHost", counter); + + var appHostRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "VersionedAppHost"); + var appHostProjectPath = Path.Combine(appHostRoot, "VersionedAppHost.csproj"); + var nugetConfigPath = Path.Combine(appHostRoot, "nuget.config"); + if (File.Exists(nugetConfigPath)) + { + DotNetTemplateBehaviorTestHelpers.DisableAspirePackageSourceMapping(nugetConfigPath); + } + + DotNetTemplateBehaviorTestHelpers.AddAspireHostingAppHostPackageReference(appHostProjectPath, version); + + await auto.TypeAsync("cd VersionedAppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("dotnet build"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4)); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [Fact] + [Trait("azdo-template-coverage", "true")] + [CaptureWorkspaceOnFailure] + public async Task DotNetTemplateWithCentralPackageManagementBuildsAndStarts() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.EnableShowAllTemplatesAsync(counter); + + await auto.AspireNewSubcommandAsync("aspire", "CpmApp", counter); + + var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "CpmApp"); + var appHostProjectPath = Path.Combine(projectRoot, "CpmApp.AppHost", "CpmApp.AppHost.csproj"); + DotNetTemplateBehaviorTestHelpers.AddCentralPackageManagementForRedis(appHostProjectPath, Path.Combine(projectRoot, "Directory.Packages.props")); + + await auto.TypeAsync("cd CpmApp/CpmApp.AppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("dotnet build"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} + +internal static partial class DotNetTemplateBehaviorTestHelpers +{ + internal static async Task CreateTemplateBootstrapAsync(Hex1bTerminalAutomator auto, SequenceCounter counter) + { + await auto.AspireNewSubcommandAsync("aspire-starter", "TemplateBootstrap", counter, "--use-redis-cache", "false", "--test-framework", "None"); + await auto.ClearScreenAsync(counter); + await auto.TypeAsync("cd TemplateBootstrap"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } + + internal static async Task CreateDotNetTemplateInBootstrapAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter, + TemporaryWorkspace workspace, + string templateName, + string projectName, + IReadOnlyList extraArgs) + { + var commandParts = new List + { + "dotnet", + "new", + templateName, + "-n", + Quote(projectName), + "-o", + Quote($"./{projectName}") + }; + + commandParts.AddRange(extraArgs); + + await auto.TypeAsync(string.Join(" ", commandParts)); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + var markerFileName = templateName switch + { + "aspire" or "aspire-starter" or "aspire-ts-cs-starter" => "*.sln", + "aspire-apphost-singlefile" => "apphost.run.json", + _ => null + }; + + return markerFileName is null + ? Path.Combine(GetTemplateBootstrapRoot(workspace), projectName) + : ResolveGeneratedTemplateDirectory(workspace, projectName, markerFileName); + } + + internal static string ResolveGeneratedTemplateDirectory(TemporaryWorkspace workspace, string projectName, string markerFileName) + { + var bootstrapRoot = GetTemplateBootstrapRoot(workspace); + var expectedProjectRoot = Path.Combine(bootstrapRoot, projectName); + if (Directory.Exists(expectedProjectRoot)) + { + return expectedProjectRoot; + } + + var candidates = Directory.EnumerateFiles(bootstrapRoot, markerFileName, SearchOption.AllDirectories) + .Select(path => Path.GetDirectoryName(path)!) + .Where(directory => + { + var relativePath = Path.GetRelativePath(bootstrapRoot, directory); + + return relativePath != "." && + !relativePath.StartsWith("TemplateBootstrap", StringComparison.Ordinal); + }) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return Assert.Single(candidates); + } + + private static string GetTemplateBootstrapRoot(TemporaryWorkspace workspace) + => Path.Combine(workspace.WorkspaceRoot.FullName, "TemplateBootstrap"); + + internal static async Task CreateCliTemplateAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter, + TemporaryWorkspace workspace, + string templateName, + string projectName, + bool useLocalhostTld) + { + var extraArgs = templateName switch + { + "aspire-starter" => useLocalhostTld + ? new[] { "--localhost-tld", "--use-redis-cache", "false", "--test-framework", "None" } + : new[] { "--use-redis-cache", "false", "--test-framework", "None" }, + "aspire-ts-cs-starter" => useLocalhostTld + ? new[] { "--localhost-tld", "--use-redis-cache", "false" } + : new[] { "--use-redis-cache", "false" }, + _ => useLocalhostTld ? ["--localhost-tld"] : [] + }; + + await auto.AspireNewSubcommandAsync(templateName, projectName, counter, extraArgs); + return Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + } + + internal static void AssertDevLocalhostHostname(string projectRoot, string templateName, string projectName, string expectedHostname) + { + var settingsPath = templateName switch + { + "aspire" or "aspire-starter" or "aspire-ts-cs-starter" => Path.Combine(projectRoot, $"{projectName}.AppHost", "Properties", "launchSettings.json"), + "aspire-apphost" => Path.Combine(projectRoot, "Properties", "launchSettings.json"), + "aspire-apphost-singlefile" => Path.Combine(projectRoot, "apphost.run.json"), + _ => throw new ArgumentOutOfRangeException(nameof(templateName), templateName, "Unknown template name.") + }; + + if (!File.Exists(settingsPath)) + { + settingsPath = FindGeneratedAppHostSettingsPath(projectRoot, templateName); + } + + Assert.True(File.Exists(settingsPath), $"Expected launch settings at {settingsPath}."); + + using var document = JsonDocument.Parse(File.ReadAllText(settingsPath)); + var profiles = document.RootElement.GetProperty("profiles"); + + var foundDevLocalhost = false; + foreach (var profile in profiles.EnumerateObject()) + { + if (!profile.Value.TryGetProperty("applicationUrl", out var applicationUrl)) + { + continue; + } + + var urls = applicationUrl.GetString(); + if (string.IsNullOrEmpty(urls) || !urls.Contains(".dev.localhost:", StringComparison.Ordinal)) + { + continue; + } + + foundDevLocalhost = true; + Assert.Contains($"{expectedHostname}.dev.localhost:", urls, StringComparison.Ordinal); + + foreach (Match match in DevLocalhostHostnameRegex().Matches(urls)) + { + var hostname = match.Groups[1].Value; + Assert.DoesNotContain("_", hostname, StringComparison.Ordinal); + Assert.DoesNotContain(".", hostname, StringComparison.Ordinal); + Assert.False(hostname.StartsWith("-", StringComparison.Ordinal), $"Hostname '{hostname}' should not start with hyphen."); + Assert.False(hostname.EndsWith("-", StringComparison.Ordinal), $"Hostname '{hostname}' should not end with hyphen."); + } + } + + Assert.True(foundDevLocalhost, $"Expected a .dev.localhost URL in {settingsPath}."); + } + + internal static void AssertGeneratedProjectsTargetFramework(string projectRoot, string expectedTargetFramework) + { + Assert.True(Directory.Exists(projectRoot), $"Expected project root at {projectRoot}."); + + var projectFiles = Directory.EnumerateFiles(projectRoot, "*.csproj", SearchOption.AllDirectories) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + + Assert.NotEmpty(projectFiles); + + foreach (var projectFile in projectFiles) + { + var document = XDocument.Load(projectFile); + var targetFrameworks = document.Descendants() + .Where(static element => element.Name.LocalName is "TargetFramework" or "TargetFrameworks") + .SelectMany(static element => element.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .ToArray(); + + Assert.Contains(expectedTargetFramework, targetFrameworks); + } + } + + private static string FindGeneratedAppHostSettingsPath(string projectRoot, string templateName) + { + Assert.True(Directory.Exists(projectRoot), $"Expected project root at {projectRoot}."); + + if (templateName is "aspire-apphost-singlefile") + { + return Assert.Single(Directory.EnumerateFiles(projectRoot, "apphost.run.json", SearchOption.AllDirectories)); + } + + var candidates = Directory.EnumerateFiles(projectRoot, "launchSettings.json", SearchOption.AllDirectories) + .Where(path => + { + var propertiesDirectory = Directory.GetParent(path); + var appHostDirectory = propertiesDirectory?.Parent; + + return string.Equals(propertiesDirectory?.Name, "Properties", StringComparison.Ordinal) && + appHostDirectory?.Name.EndsWith(".AppHost", StringComparison.Ordinal) is true; + }) + .ToArray(); + + return Assert.Single(candidates); + } + + internal static void RewriteAsExplicitSdkReference(string appHostProjectPath, bool includeAspireHostingAppHostPackageReference) + { + var document = XDocument.Load(appHostProjectPath); + var project = document.Root ?? throw new InvalidOperationException($"Project root not found in {appHostProjectPath}."); + var sdkAttribute = project.Attribute("Sdk") ?? throw new InvalidOperationException($"Sdk attribute not found in {appHostProjectPath}."); + var version = ExtractSdkVersion(sdkAttribute.Value); + + project.SetAttributeValue("Sdk", "Microsoft.NET.Sdk"); + project.Elements("Sdk").Remove(); + project.AddFirst(new XElement("Sdk", + new XAttribute("Name", "Aspire.AppHost.Sdk"), + new XAttribute("Version", version))); + + if (includeAspireHostingAppHostPackageReference) + { + project.Add(new XElement("ItemGroup", + new XElement("PackageReference", + new XAttribute("Include", "Aspire.Hosting.AppHost"), + new XAttribute("Version", version)))); + } + + document.Save(appHostProjectPath); + } + + internal static void AddAspireHostingAppHostPackageReference(string appHostProjectPath, string version) + { + var document = XDocument.Load(appHostProjectPath); + var project = document.Root ?? throw new InvalidOperationException($"Project root not found in {appHostProjectPath}."); + project.Add(new XElement("ItemGroup", + new XElement("PackageReference", + new XAttribute("Include", "Aspire.Hosting.AppHost"), + new XAttribute("Version", version)))); + document.Save(appHostProjectPath); + } + + internal static void DisableAspirePackageSourceMapping(string nugetConfigPath) + { + var document = XDocument.Load(nugetConfigPath); + document.Root?.Element("packageSourceMapping")?.Remove(); + document.Save(nugetConfigPath); + } + + internal static void AddCentralPackageManagementForRedis(string appHostProjectPath, string directoryPackagesPropsPath) + { + var document = XDocument.Load(appHostProjectPath); + var project = document.Root ?? throw new InvalidOperationException($"Project root not found in {appHostProjectPath}."); + var sdkAttribute = project.Attribute("Sdk") ?? throw new InvalidOperationException($"Sdk attribute not found in {appHostProjectPath}."); + var version = ExtractSdkVersion(sdkAttribute.Value); + + project.Add(new XElement("ItemGroup", + new XElement("PackageReference", + new XAttribute("Include", "Aspire.Hosting.Redis")))); + document.Save(appHostProjectPath); + + File.WriteAllText(directoryPackagesPropsPath, $$""" + + + true + NU1507;$(NoWarn) + + + + + + """); + } + + private static string ExtractSdkVersion(string sdkValue) + { + const string prefix = "Aspire.AppHost.Sdk/"; + return sdkValue.StartsWith(prefix, StringComparison.Ordinal) + ? sdkValue[prefix.Length..] + : throw new InvalidOperationException($"Unexpected SDK value '{sdkValue}'."); + } + + internal static string Quote(string value) => $"\"{value}\""; + + [GeneratedRegex(@"://([^:]+)\.dev\.localhost:")] + private static partial Regex DevLocalhostHostnameRegex(); +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs deleted file mode 100644 index 5c8703ffbe8..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Tests.Utils; -using Hex1b.Automation; -using Xunit; - -namespace Aspire.Cli.EndToEnd.Tests; - -/// -/// End-to-end tests for Aspire CLI with Empty AppHost template. -/// Each test class runs as a separate CI job for parallelization. -/// -public sealed class EmptyAppHostTemplateTests(ITestOutputHelper output) -{ - [CaptureWorkspaceOnFailure] - [Fact] - public async Task CreateAndRunEmptyAppHostProject() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var strategy = CliInstallStrategy.Detect(output.WriteLine); - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - - await auto.AspireNewAsync("AspireEmptyApp", counter, template: AspireTemplate.EmptyAppHost); - - // Start the empty AppHost to verify the scaffolded project works - await auto.TypeAsync("cd AspireEmptyApp"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.AspireStartAsync(counter); - await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 9f20237a613..3d08ef50d16 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -215,7 +215,7 @@ private static string BuildAspireNewEmptyAppHostCommand( $"aspire new {AspireCliShellCommandHelpers.QuoteBashArg(templateName)} " + $"--name {AspireCliShellCommandHelpers.QuoteBashArg(projectName)} " + $"--output {AspireCliShellCommandHelpers.QuoteBashArg(output)}" + - $"{channelArgument} --localhost-tld {localhostTldValue}"; + $"{channelArgument} --localhost-tld {localhostTldValue} --suppress-agent-init"; } private static async Task WaitForAspireNewEmptyAppHostCompletionAsync( @@ -672,6 +672,89 @@ internal static async Task EnableExperimentalJavaSupportAsync( await auto.WaitForSuccessPromptAsync(counter); } + /// + /// Enables the hidden dotnet-template entries for aspire new. + /// + internal static async Task EnableShowAllTemplatesAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter) + { + await auto.TypeAsync("aspire config set features:showAllTemplates true --global --non-interactive"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } + + /// + /// Runs aspire new <template> non-interactively for a specific template subcommand. + /// + internal static async Task AspireNewSubcommandAsync( + this Hex1bTerminalAutomator auto, + string templateName, + string projectName, + SequenceCounter counter, + params string[] extraArgs) + { + var commandParts = new List + { + "aspire", + "new", + templateName, + "--name", + AspireCliShellCommandHelpers.QuoteBashArg(projectName), + "--output", + AspireCliShellCommandHelpers.QuoteBashArg($"./{projectName}"), + "--suppress-agent-init", + }; + + foreach (var arg in extraArgs) + { + commandParts.Add(arg); + } + + await auto.TypeAsync(string.Join(" ", commandParts)); + await auto.EnterAsync(); + await auto.CompleteAspireNewSubcommandPromptsAsync(counter); + } + + private static async Task CompleteAspireNewSubcommandPromptsAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); + var devLocalhostPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + var agentInitPrompt = new CellPatternSearcher() + .Find("configure AI agent environments"); + + var devLocalhostPromptFound = false; + await auto.WaitUntilAsync(snapshot => + { + if (devLocalhostPrompt.Search(snapshot).Count > 0) + { + devLocalhostPromptFound = true; + return true; + } + + if (agentInitPrompt.Search(snapshot).Count > 0) + { + return true; + } + + var successSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" OK] $ "); + return successSearcher.Search(snapshot).Count > 0; + }, timeout: effectiveTimeout, description: $"dev localhost prompt, agent init prompt, or success prompt [{counter.Value} OK] $"); + + if (devLocalhostPromptFound) + { + await auto.EnterAsync(); + } + + await auto.DeclineAgentInitPromptAsync(counter, effectiveTimeout); + } + /// /// Installs a specific GA version of the Aspire CLI using the install script. /// @@ -686,6 +769,228 @@ internal static async Task InstallAspireCliVersionAsync( await auto.RunCommandFailFastAsync(command, counter, TimeSpan.FromSeconds(300)); } + /// + /// Runs aspire run, captures its transcript in the workspace, and waits for the AppHost to be ready. + /// + internal static async Task AspireRunUntilReadyAsync( + this Hex1bTerminalAutomator auto, + TemporaryWorkspace workspace, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(5); + var hostOutputPath = CliE2ETestHelpers.GetWorkspaceFilePath(workspace, "_aspire-run.log"); + var containerOutputPath = CliE2ETestHelpers.ToContainerPath(hostOutputPath, workspace); + + CliE2ETestHelpers.RegisterCaptureFile("_aspire-run.log", hostOutputPath); + + await auto.TypeAsync( + "(set -o pipefail; aspire run 2>&1 | tee " + + AspireCliShellCommandHelpers.QuoteBashArg(containerOutputPath) + + "; s=${PIPESTATUS[0]}; if [ \"$s\" -eq 130 ]; then exit 0; fi; exit \"$s\")"); + await auto.EnterAsync(); + + await auto.WaitUntilAsync(s => + { + if (s.ContainsText("Select an AppHost to use:")) + { + throw new InvalidOperationException( + "Unexpected apphost selection prompt detected! " + + "This indicates multiple apphosts were incorrectly detected."); + } + + return s.ContainsText("Press CTRL+C to stop the AppHost and exit.") + || s.ContainsText("Press CTRL+C to stop the apphost and exit."); + }, timeout: effectiveTimeout, description: "Press CTRL+C message (aspire run started)"); + } + + /// + /// Copies the latest aspire start --format json transcript into the mounted workspace. + /// + internal static async Task PersistAspireStartJsonAsync( + this Hex1bTerminalAutomator auto, + TemporaryWorkspace workspace, + SequenceCounter counter) + { + var hostOutputPath = CliE2ETestHelpers.GetWorkspaceFilePath(workspace, "_aspire-start.json"); + var containerOutputPath = CliE2ETestHelpers.ToContainerPath(hostOutputPath, workspace); + + CliE2ETestHelpers.RegisterCaptureFile("_aspire-start.json", hostOutputPath); + + await auto.RunCommandAsync( + $"if [ -f {AspireCliShellCommandHelpers.QuoteBashArg(AspireStartJsonFile)} ]; then cp {AspireCliShellCommandHelpers.QuoteBashArg(AspireStartJsonFile)} {AspireCliShellCommandHelpers.QuoteBashArg(containerOutputPath)}; fi", + counter); + } + + /// + /// Runs a JSON-producing CLI command and writes stdout to a workspace file for host-side assertions. + /// + internal static async Task CaptureJsonOutputAsync( + this Hex1bTerminalAutomator auto, + string command, + TemporaryWorkspace workspace, + SequenceCounter counter, + string outputFileName) + { + var (hostOutputPath, containerOutputPath) = RegisterWorkspaceCaptureFile(workspace, outputFileName); + + await auto.RunCommandAsync($"{command} > {AspireCliShellCommandHelpers.QuoteBashArg(containerOutputPath)}", counter); + return hostOutputPath; + } + + /// + /// Runs a shell command and captures stdout and stderr to a workspace file. + /// + internal static async Task CaptureCommandOutputAsync( + this Hex1bTerminalAutomator auto, + string command, + TemporaryWorkspace workspace, + SequenceCounter counter, + string outputFileName, + TimeSpan? timeout = null) + { + var (hostOutputPath, containerOutputPath) = RegisterWorkspaceCaptureFile(workspace, outputFileName); + + await auto.RunCommandFailFastAsync( + $"{command} > {AspireCliShellCommandHelpers.QuoteBashArg(containerOutputPath)} 2>&1", + counter, + timeout ?? TimeSpan.FromSeconds(500)); + + return hostOutputPath; + } + + /// + /// Runs a shell command expected to fail and captures stdout and stderr to a workspace file. + /// + internal static async Task CaptureFailingCommandOutputAsync( + this Hex1bTerminalAutomator auto, + string command, + TemporaryWorkspace workspace, + SequenceCounter counter, + string outputFileName, + TimeSpan? timeout = null) + { + var (hostOutputPath, containerOutputPath) = RegisterWorkspaceCaptureFile(workspace, outputFileName); + var expectedCounter = counter.Value; + var succeeded = false; + + await auto.TypeAsync($"{command} > {AspireCliShellCommandHelpers.QuoteBashArg(containerOutputPath)} 2>&1"); + await auto.EnterAsync(); + await auto.WaitUntilAsync(snapshot => + { + var successSearcher = new CellPatternSearcher() + .FindPattern(expectedCounter.ToString()) + .RightText(" OK] $ "); + if (successSearcher.Search(snapshot).Count > 0) + { + succeeded = true; + return true; + } + + var errorSearcher = new CellPatternSearcher() + .FindPattern(expectedCounter.ToString()) + .RightText(" ERR:"); + + return errorSearcher.Search(snapshot).Count > 0; + }, timeout: timeout ?? TimeSpan.FromSeconds(500), description: $"expected failing command [{expectedCounter} ERR]"); + + counter.Increment(); + + if (succeeded) + { + throw new InvalidOperationException($"Expected command to fail, but it succeeded. Output captured at {hostOutputPath}. Command: {command}"); + } + + return hostOutputPath; + } + + /// + /// Copies the latest Aspire CLI log matching a glob from the container into the mounted workspace. + /// + internal static async Task CaptureLatestAspireLogAsync( + this Hex1bTerminalAutomator auto, + string logGlob, + TemporaryWorkspace workspace, + SequenceCounter counter, + string outputFileName) + { + var (hostOutputPath, containerOutputPath) = RegisterWorkspaceCaptureFile(workspace, outputFileName); + + await auto.RunCommandAsync( + $"LOG=$(ls -t {logGlob} 2>/dev/null | head -1) && test -n \"$LOG\" && cp \"$LOG\" {AspireCliShellCommandHelpers.QuoteBashArg(containerOutputPath)}", + counter); + + return hostOutputPath; + } + + /// + /// Verifies a URL responds with HTTP 200 from inside the CLI test environment. + /// + internal static async Task AssertUrlRespondsAsync( + this Hex1bTerminalAutomator auto, + string url, + string label, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var failurePrefix = $"{label}-http-status="; + + await auto.TypeAsync( + $"status=$(curl -ksSL -o /dev/null -w '%{{http_code}}' {AspireCliShellCommandHelpers.QuoteBashArg(url)}) && " + + $"{{ [ \"$status\" = \"200\" ] || {{ printf '%s%s\\n' {AspireCliShellCommandHelpers.QuoteBashArg(failurePrefix)} \"$status\"; exit 1; }}; }}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptFailFastAsync(counter, timeout ?? TimeSpan.FromSeconds(30)); + } + + private static async Task ExtractLocalHiveArchiveAsync( + this Hex1bTerminalAutomator auto, + string archivePath, + SequenceCounter counter) + { + await auto.RunCommandAsync($"mkdir -p ~/.aspire && tar -xzf {archivePath} -C ~/.aspire 2>/dev/null", counter, TimeSpan.FromSeconds(30)); + } + + private static async Task ConfigureLocalHiveAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter) + { + await auto.RunCommandAsync("aspire config set channel local -g", counter); + await auto.RunCommandAsync("SDK_VER=$(ls ~/.aspire/hives/local/packages/Aspire.Hosting.*.nupkg 2>/dev/null | head -1 | sed 's/.*Aspire\\.Hosting\\.//;s/\\.nupkg//') && aspire config set sdk.version \"$SDK_VER\" -g", counter); + } + + private static (string HostOutputPath, string ContainerOutputPath) RegisterWorkspaceCaptureFile( + TemporaryWorkspace workspace, + string outputFileName) + { + var hostOutputPath = CliE2ETestHelpers.GetWorkspaceFilePath(workspace, outputFileName); + var containerOutputPath = CliE2ETestHelpers.ToContainerPath(hostOutputPath, workspace); + + CliE2ETestHelpers.RegisterCaptureFile(outputFileName, hostOutputPath); + + return (hostOutputPath, containerOutputPath); + } + + private static async Task RunCommandAsync( + this Hex1bTerminalAutomator auto, + string command, + SequenceCounter counter, + TimeSpan? timeout = null) + { + await auto.TypeAsync(command); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, timeout); + } + + private static async Task RunCommandFailFastAsync( + this Hex1bTerminalAutomator auto, + string command, + SequenceCounter counter, + TimeSpan timeout) + { + await auto.TypeAsync(command); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptFailFastAsync(counter, timeout); + } + /// /// Starts an Aspire AppHost with aspire start --format json, extracts the dashboard URL, /// and verifies the dashboard is reachable. Caller is responsible for calling @@ -854,13 +1159,16 @@ await auto.TypeAsync( } /// - /// Stops a running Aspire AppHost with aspire stop. + /// Stops running Aspire AppHosts with aspire stop --all. + /// In the isolated Docker E2E environment each test owns its own container, so stopping all + /// avoids interactive selection prompts when the CLI cannot correlate the current directory to + /// the running AppHost path. /// internal static async Task AspireStopAsync( this Hex1bTerminalAutomator auto, SequenceCounter counter) { - await auto.TypeAsync("aspire stop"); + await auto.TypeAsync("aspire stop --all"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index b92a2b4d373..08af24d412b 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Xml.Linq; @@ -497,6 +498,197 @@ internal static string ToContainerPath(string hostPath, TemporaryWorkspace works return $"/workspace/{workspace.WorkspaceRoot.Name}/" + relativePath.Replace('\\', '/'); } + /// + /// Gets a file path rooted under the mounted workspace. + /// + internal static string GetWorkspaceFilePath(TemporaryWorkspace workspace, string relativePath) + { + return Path.Combine(workspace.WorkspaceRoot.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + /// + /// Verifies the persisted aspire start --format json transcript contains the expected contract fields. + /// + internal static void AssertAspireStartJsonContract(TemporaryWorkspace workspace) + { + var jsonPath = GetWorkspaceFilePath(workspace, "_aspire-start.json"); + WithJsonFailureDiagnostics("aspire start JSON", jsonPath, content => + { + using var document = JsonDocument.Parse(content); + var root = document.RootElement; + + Assert.True(root.ValueKind is JsonValueKind.Object, $"Expected JSON object in {jsonPath}"); + Assert.True(root.TryGetProperty("appHostPath", out var appHostPath) && + appHostPath.ValueKind is JsonValueKind.String && + !string.IsNullOrWhiteSpace(appHostPath.GetString()), + $"Expected non-empty appHostPath in {jsonPath}"); + Assert.True(root.TryGetProperty("appHostPid", out var appHostPid) && + HasNonEmptyStringOrNumber(appHostPid), + $"Expected non-empty appHostPid in {jsonPath}"); + Assert.True(root.TryGetProperty("dashboardUrl", out var dashboardUrl) && + dashboardUrl.ValueKind is JsonValueKind.String && + !string.IsNullOrWhiteSpace(dashboardUrl.GetString()), + $"Expected non-empty dashboardUrl in {jsonPath}"); + + return true; + }); + } + + /// + /// Verifies the aspire ps --format json output contains at least one AppHost entry with the expected keys. + /// + internal static void AssertPsJsonContract(string jsonPath) + { + WithJsonFailureDiagnostics("aspire ps JSON", jsonPath, content => + { + using var document = JsonDocument.Parse(content); + var root = document.RootElement; + + Assert.True(root.ValueKind is JsonValueKind.Array, $"Expected JSON array in {jsonPath}"); + + var sawItem = false; + foreach (var item in root.EnumerateArray()) + { + sawItem = true; + + Assert.True(item.TryGetProperty("appHostPath", out var appHostPath) && + appHostPath.ValueKind is JsonValueKind.String && + !string.IsNullOrWhiteSpace(appHostPath.GetString()), + $"Expected non-empty appHostPath in {jsonPath}"); + Assert.True(item.TryGetProperty("appHostPid", out var appHostPid) && + HasNonEmptyStringOrNumber(appHostPid), + $"Expected non-empty appHostPid in {jsonPath}"); + } + + Assert.True(sawItem, $"Expected at least one AppHost entry in {jsonPath}"); + return true; + }); + } + + /// + /// Extracts the first localhost resource endpoint URL from a JSON file. + /// + internal static string GetFirstLocalhostUrlFromJsonFile(string jsonPath) + { + return WithJsonFailureDiagnostics("resource JSON", jsonPath, content => + { + using var document = JsonDocument.Parse(content); + foreach (var resource in EnumerateResources(document.RootElement)) + { + if (!resource.TryGetProperty("urls", out var urls) || + urls.ValueKind is not JsonValueKind.Array) + { + continue; + } + + foreach (var url in urls.EnumerateArray()) + { + var urlString = GetResourceUrlString(url); + if (urlString is not null && + Uri.TryCreate(urlString, UriKind.Absolute, out var uri) && + (uri.Scheme is "http" or "https") && + string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return urlString; + } + } + } + + throw new InvalidOperationException($"Expected a localhost resource endpoint URL in {jsonPath}"); + }); + } + + private static IEnumerable EnumerateResources(JsonElement root) + { + if (root.ValueKind is JsonValueKind.Object && + root.TryGetProperty("resources", out var resources) && + resources.ValueKind is JsonValueKind.Array) + { + foreach (var resource in resources.EnumerateArray()) + { + yield return resource; + } + + yield break; + } + + if (root.ValueKind is JsonValueKind.Array) + { + foreach (var resource in root.EnumerateArray()) + { + yield return resource; + } + + yield break; + } + + if (root.ValueKind is JsonValueKind.Object) + { + yield return root; + } + } + + private static string? GetResourceUrlString(JsonElement url) + { + if (url.ValueKind is JsonValueKind.String) + { + return url.GetString(); + } + + return url.ValueKind is JsonValueKind.Object && + url.TryGetProperty("url", out var value) && + value.ValueKind is JsonValueKind.String + ? value.GetString() + : null; + } + + private static bool HasNonEmptyStringOrNumber(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.Number => true, + JsonValueKind.String => !string.IsNullOrWhiteSpace(value.GetString()), + _ => false + }; + } + + private static T WithJsonFailureDiagnostics(string label, string jsonPath, Func assertion) + { + string? content = null; + + try + { + Assert.True(File.Exists(jsonPath), $"Expected JSON file at {jsonPath}"); + content = File.ReadAllText(jsonPath); + return assertion(content); + } + catch + { + WriteJsonDiagnostic(label, jsonPath, content); + throw; + } + } + + private static void WriteJsonDiagnostic(string label, string jsonPath, string? content) + { + var testContext = TestContext.Current; + var header = $"=== {label}: {jsonPath} ==="; + var body = content ?? ""; + var footer = $"=== END {label} ==="; + + if (testContext.TestOutputHelper is { } outputHelper) + { + outputHelper.WriteLine(header); + outputHelper.WriteLine(body); + outputHelper.WriteLine(footer); + return; + } + + testContext.SendDiagnosticMessage(header); + testContext.SendDiagnosticMessage("{0}", body); + testContext.SendDiagnosticMessage(footer); + } + /// /// Reads the VersionPrefix (e.g., "13.3.0") from eng/Versions.props by parsing /// the MajorVersion, MinorVersion, and PatchVersion MSBuild properties. @@ -544,7 +736,7 @@ internal static bool IsStabilizedBuild() return string.Equals(stabilize, "true", StringComparison.OrdinalIgnoreCase); } - private static void RegisterCaptureFile(string fileName, string path) + internal static void RegisterCaptureFile(string fileName, string path) { if (TestContext.Current is null) { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs deleted file mode 100644 index 9c32d1a5355..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Tests.Utils; -using Hex1b.Automation; -using Xunit; - -namespace Aspire.Cli.EndToEnd.Tests; - -/// -/// End-to-end tests for the Java empty AppHost template (aspire-java-empty). -/// Validates that aspire new creates a working Java AppHost project -/// and that aspire start runs it successfully. -/// -public sealed class JavaEmptyAppHostTemplateTests(ITestOutputHelper output) -{ - [Fact] - [CaptureWorkspaceOnFailure] - public async Task CreateAndRunJavaEmptyAppHostProject() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var strategy = CliInstallStrategy.Detect(output.WriteLine); - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - await auto.EnableExperimentalJavaSupportAsync(counter); - - await auto.AspireNewAsync("JavaEmptyApp", counter, template: AspireTemplate.JavaEmptyAppHost); - - GitIgnoreAssertions.AssertContainsEntry( - Path.Combine(workspace.WorkspaceRoot.FullName, "JavaEmptyApp"), - ".aspire/"); - - await auto.TypeAsync("cd JavaEmptyApp"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.AspireStartAsync(counter); - await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs deleted file mode 100644 index d092a928b88..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Tests.Utils; -using Hex1b.Automation; -using Hex1b.Input; -using Xunit; - -namespace Aspire.Cli.EndToEnd.Tests; - -/// -/// End-to-end tests for Aspire CLI with ASP.NET Core/React (TypeScript/C#) template. -/// Each test class runs as a separate CI job for parallelization. -/// -public sealed class JsReactTemplateTests(ITestOutputHelper output) -{ - [Fact] - [CaptureWorkspaceOnFailure] - public async Task CreateAndRunJsReactProject() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var strategy = CliInstallStrategy.Detect(output.WriteLine); - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - - await auto.AspireNewAsync("AspireJsReactApp", counter, template: AspireTemplate.JsReact, useRedisCache: false); - - // Run the project with aspire run - await auto.TypeAsync("aspire run"); - await auto.EnterAsync(); - - // Regression test for https://github.com/microsoft/aspire/issues/13971 - await auto.WaitUntilAsync(s => - { - if (s.ContainsText("Select an AppHost to use:")) - { - throw new InvalidOperationException( - "Unexpected apphost selection prompt detected! " + - "This indicates multiple apphosts were incorrectly detected."); - } - return s.ContainsText("Press CTRL+C to stop the AppHost and exit."); - }, timeout: TimeSpan.FromMinutes(2), description: "Press CTRL+C message (aspire run started)"); - - await auto.Ctrl().KeyAsync(Hex1bKey.C); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs deleted file mode 100644 index 9d16f783c7b..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Tests.Utils; -using Hex1b.Automation; -using Xunit; - -namespace Aspire.Cli.EndToEnd.Tests; - -/// -/// End-to-end tests for Aspire CLI with Python/React (FastAPI/Vite) template. -/// Each test class runs as a separate CI job for parallelization. -/// -public sealed class PythonReactTemplateTests(ITestOutputHelper output) -{ - [Fact] - [CaptureWorkspaceOnFailure] - public async Task CreateAndRunPythonReactProject() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var strategy = CliInstallStrategy.Detect(output.WriteLine); - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - - // Step 1: Create project using aspire new, selecting the FastAPI/React template - await auto.AspireNewAsync("AspirePyReactApp", counter, template: AspireTemplate.PythonReact, useRedisCache: false); - - GitIgnoreAssertions.AssertContainsEntry( - Path.Combine(workspace.WorkspaceRoot.FullName, "AspirePyReactApp"), - ".aspire/"); - - // Step 2: Navigate into the project directory so config resolution finds the - // project-level aspire.config.json (which has the packages section). - // See https://github.com/microsoft/aspire/issues/15623 - await auto.TypeAsync("cd AspirePyReactApp"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - // Step 3: Verify the generated TypeScript AppHost builds successfully. - await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); - - // Step 4: Start and stop the project - await auto.AspireStartAsync(counter); - await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index 1a9a812d25b..21707833134 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -12,14 +12,43 @@ namespace Aspire.Cli.EndToEnd.Tests; /// -/// End-to-end tests for Aspire CLI run command (creating and launching projects). -/// Each test class runs as a separate CI job for parallelization. +/// End-to-end smoke tests for the core Aspire CLI starter scenarios and JSON contracts. /// public sealed class SmokeTests(ITestOutputHelper output) { [CaptureWorkspaceOnFailure] [Fact] - public async Task CreateAndRunAspireStarterProject() + public async Task CreateAndRunAspireStarterProjectWithBundle() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync("BundleStarterApp", counter); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [CaptureWorkspaceOnFailure] + [Fact] + [Trait("azdo-template-coverage", "true")] + public async Task CreateAndRunDefaultAspireStarterProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(output.WriteLine); @@ -39,26 +68,11 @@ public async Task CreateAndRunAspireStarterProject() // Install the Aspire CLI await auto.InstallAspireCliAsync(strategy, counter); - // Create a new project using aspire new + // Create a new project using aspire new with the default starter options. await auto.AspireNewAsync("AspireStarterApp", counter); - // Run the project with aspire run - await auto.TypeAsync("aspire run"); - await auto.EnterAsync(); - - // Regression test for https://github.com/microsoft/aspire/issues/13971 - // If the apphost selection prompt appears, it means multiple apphosts were - // incorrectly detected (e.g., AppHost.cs was incorrectly treated as a single-file apphost) - await auto.WaitUntilAsync(s => - { - if (s.ContainsText("Select an AppHost to use:")) - { - throw new InvalidOperationException( - "Unexpected apphost selection prompt detected! " + - "This indicates multiple apphosts were incorrectly detected."); - } - return s.ContainsText("Press CTRL+C to stop the AppHost and exit."); - }, timeout: TimeSpan.FromMinutes(2), description: "Press CTRL+C message (aspire run started)"); + // Run the project with aspire run and persist the transcript for failed-run debugging. + await auto.AspireRunUntilReadyAsync(workspace); // Stop the running apphost with Ctrl+C await auto.Ctrl().KeyAsync(Hex1bKey.C); @@ -155,6 +169,172 @@ public async Task LatestCliCanStartStableChannelTypeScriptAppHost() await pendingRun; } + [CaptureWorkspaceOnFailure] + [Fact] + public async Task CreateAndRunEmptyAppHostProject() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync("AspireEmptyApp", counter, template: AspireTemplate.EmptyAppHost); + + await auto.TypeAsync("cd AspireEmptyApp"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [CaptureWorkspaceOnFailure] + [Fact] + [Trait("azdo-template-coverage", "true")] + public async Task CreateAndRunDotNetEmptyTemplateProject() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.EnableShowAllTemplatesAsync(counter); + + await auto.AspireNewSubcommandAsync("aspire", "DotNetEmptyApp", counter, "--localhost-tld", "false"); + + await auto.TypeAsync("cd DotNetEmptyApp"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [CaptureWorkspaceOnFailure] + [Fact] + [Trait("azdo-template-coverage", "true")] + public async Task CreateAndRunDotNetAppHostTemplateProject() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.EnableShowAllTemplatesAsync(counter); + + await auto.AspireNewSubcommandAsync("aspire-apphost", "DotNetAppHost", counter, "--localhost-tld", "false"); + + await auto.TypeAsync("cd DotNetAppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + /// + /// Creates a starter project, starts it with aspire start --format json, + /// validates machine-readable JSON contracts, and verifies the web frontend endpoint + /// responds with HTTP 200. + /// + [CaptureWorkspaceOnFailure] + [Fact] + public async Task StarterJsonContractsAndEndpointsRespond() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync("EndpointTest", counter, useRedisCache: false); + + await auto.TypeAsync("cd EndpointTest"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.AspireStartAsync(counter); + await auto.PersistAspireStartJsonAsync(workspace, counter); + CliE2ETestHelpers.AssertAspireStartJsonContract(workspace); + + await auto.TypeAsync("aspire wait webfrontend --status up --timeout 300"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("is up (running).", timeout: TimeSpan.FromMinutes(6)); + await auto.WaitForSuccessPromptAsync(counter); + + var psJsonPath = await auto.CaptureJsonOutputAsync( + "aspire ps --format json", + workspace, + counter, + "ps.json"); + CliE2ETestHelpers.AssertPsJsonContract(psJsonPath); + + await auto.AssertResourcesExistAsync(counter, "webfrontend", "apiservice"); + + var webfrontendJsonPath = await auto.CaptureJsonOutputAsync( + "aspire describe webfrontend --format json", + workspace, + counter, + "webfrontend.json"); + var webUrl = CliE2ETestHelpers.GetFirstLocalhostUrlFromJsonFile(webfrontendJsonPath); + await auto.AssertUrlRespondsAsync(webUrl, "webfrontend", counter); + + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + private static string GetAppHostSdkVersion(string appHostPath) { if (!File.Exists(appHostPath)) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StarterTemplateBehaviorTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StarterTemplateBehaviorTests.cs new file mode 100644 index 00000000000..fd82cce8783 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/StarterTemplateBehaviorTests.cs @@ -0,0 +1,360 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end regression tests for starter-template behaviors that were previously covered by the template harness. +/// +public sealed class StarterTemplateBehaviorTests(ITestOutputHelper output) +{ + [Theory] + [InlineData("StarterXunitV2", "xUnit.net", "v2")] + [InlineData("Starter & With.1", "xUnit.net", "v3mtp")] + [InlineData("StarterNUnit", "NUnit", null)] + [InlineData("StarterMSTest", "MSTest", null)] + [CaptureWorkspaceOnFailure] + public async Task StarterTemplateCanGenerateAndRunBuiltInTests(string projectName, string testFramework, string? xunitVersion) + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + var args = new List + { + "--use-redis-cache", "false", + "--test-framework", testFramework + }; + + if (xunitVersion is not null) + { + args.Add("--xunit-version"); + args.Add(xunitVersion); + } + + await auto.AspireNewSubcommandAsync("aspire-starter", projectName, counter, [.. args]); + + var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var testsProjectPath = ResolveGeneratedTestsProjectPath(projectRoot); + var testsDirectory = Path.GetDirectoryName(testsProjectPath) + ?? throw new InvalidOperationException($"Could not determine generated tests directory for {testsProjectPath}."); + + Assert.True(Directory.Exists(testsDirectory), $"Expected generated tests directory at {testsDirectory}."); + Assert.True(File.Exists(testsProjectPath), $"Expected generated tests project at {testsProjectPath}."); + + await auto.TypeAsync($"cd {AspireCliShellCommandHelpers.QuoteBashArg(CliE2ETestHelpers.ToContainerPath(testsDirectory, workspace))}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync($"dotnet test {Quote(Path.GetFileName(testsProjectPath))}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4)); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task StarterTemplateWithRedisHasCacheResourceAndApiResponds() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewSubcommandAsync("aspire-starter", "StarterRedisApp", counter, "--use-redis-cache", "true", "--test-framework", "None"); + + await auto.TypeAsync("cd StarterRedisApp"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.AspireStartAsync(counter); + + await auto.AssertResourcesExistAsync(counter, "webfrontend", "apiservice", "cache"); + + var resourceCommandNamePath = CliE2ETestHelpers.GetWorkspaceFilePath(workspace, "resource-command-name.txt"); + var quotedResourceCommandNamePath = AspireCliShellCommandHelpers.QuoteBashArg(CliE2ETestHelpers.ToContainerPath(resourceCommandNamePath, workspace)); + CliE2ETestHelpers.RegisterCaptureFile("resource-command-name.txt", resourceCommandNamePath); + + await auto.TypeAsync($"if aspire resource --help >/dev/null 2>&1; then echo resource > {quotedResourceCommandNamePath}; elif aspire command --help >/dev/null 2>&1; then echo command > {quotedResourceCommandNamePath}; else echo none > {quotedResourceCommandNamePath}; fi"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + var resourceCommandName = File.ReadAllText(resourceCommandNamePath).Trim(); + if (resourceCommandName is "resource" or "command") + { + await auto.TypeAsync($"aspire {resourceCommandName} webfrontend start"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); + + var webfrontendJsonPath = CliE2ETestHelpers.GetWorkspaceFilePath(workspace, "webfrontend.json"); + var containerWebfrontendJsonPath = CliE2ETestHelpers.ToContainerPath(webfrontendJsonPath, workspace); + var quotedContainerWebfrontendJsonPath = AspireCliShellCommandHelpers.QuoteBashArg(containerWebfrontendJsonPath); + CliE2ETestHelpers.RegisterCaptureFile("webfrontend.json", webfrontendJsonPath); + + await auto.TypeAsync( + $"for i in $(seq 1 60); do " + + $"aspire describe webfrontend --format json > {quotedContainerWebfrontendJsonPath}; " + + $"if grep -q '\"url\"[[:space:]]*:' {quotedContainerWebfrontendJsonPath}; then break; fi; " + + $"sleep 2; " + + $"done; if ! grep -q '\"url\"[[:space:]]*:' {quotedContainerWebfrontendJsonPath}; then cat {quotedContainerWebfrontendJsonPath}; false; fi"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(3)); + + var webUrl = CliE2ETestHelpers.GetFirstLocalhostUrlFromJsonFile(webfrontendJsonPath); + await auto.AssertUrlRespondsAsync(webUrl, "redis-webfrontend", counter); + } + else + { + Assert.Fail($"Expected 'resource' or 'command' subcommand support; the installed Aspire CLI reported: '{resourceCommandName}'."); + } + + var apiJsonPath = await auto.CaptureJsonOutputAsync( + "aspire describe apiservice --format json", + workspace, + counter, + "apiservice.json"); + var apiUrl = GetFirstApiServiceUrl(apiJsonPath); + + var hostWeatherPath = CliE2ETestHelpers.GetWorkspaceFilePath(workspace, "weather.json"); + var containerWeatherPath = CliE2ETestHelpers.ToContainerPath(hostWeatherPath, workspace); + CliE2ETestHelpers.RegisterCaptureFile("weather.json", hostWeatherPath); + + await auto.TypeAsync("aspire wait apiservice --status up --timeout 300"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("is up (running).", timeout: TimeSpan.FromMinutes(6)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync($"curl -ksSL {Quote($"{apiUrl.TrimEnd('/')}/weatherforecast")} > {Quote(containerWeatherPath)}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + using var document = JsonDocument.Parse(File.ReadAllText(hostWeatherPath)); + Assert.True(document.RootElement.ValueKind is JsonValueKind.Array, $"Expected JSON array in {hostWeatherPath}."); + Assert.Equal(5, document.RootElement.GetArrayLength()); + + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + private static string GetFirstApiServiceUrl(string jsonPath) + { + using var document = JsonDocument.Parse(File.ReadAllText(jsonPath)); + var resources = document.RootElement.GetProperty("resources"); + foreach (var resource in resources.EnumerateArray()) + { + if (!resource.TryGetProperty("displayName", out var displayName) || + !string.Equals(displayName.GetString(), "apiservice", StringComparison.Ordinal)) + { + continue; + } + + if (!resource.TryGetProperty("urls", out var urls)) + { + continue; + } + + foreach (var url in urls.EnumerateArray()) + { + if (!url.TryGetProperty("url", out var value)) + { + continue; + } + + var urlString = value.GetString(); + if (!string.IsNullOrWhiteSpace(urlString)) + { + return urlString; + } + } + } + + throw new InvalidOperationException($"Expected an api service URL in {jsonPath}."); + } + + private static string ResolveGeneratedTestsProjectPath(string projectRoot) + { + Assert.True(Directory.Exists(projectRoot), $"Expected generated project directory at {projectRoot}."); + + var candidates = Directory.EnumerateFiles(projectRoot, "*.Tests.csproj", SearchOption.AllDirectories) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + + return Assert.Single(candidates); + } + + private static string Quote(string value) => $"\"{value}\""; +} + +public sealed class SupportProjectTemplateBehaviorTests(ITestOutputHelper output) +{ + [Theory] + [InlineData("aspire-xunit", "SupportTemplate.Xunit", null)] + [InlineData("aspire-xunit", "SupportTemplate.XunitV3", "v3")] + [InlineData("aspire-nunit", "SupportTemplate.NUnit", null)] + [InlineData("aspire-mstest", "SupportTemplate.MSTest", null)] + [CaptureWorkspaceOnFailure] + public async Task SupportProjectTemplatesRunAgainstGeneratedAppHost(string templateName, string projectName, string? xunitVersion) + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await DotNetTemplateBehaviorTestHelpers.CreateTemplateBootstrapAsync(auto, counter); + + string[] args = xunitVersion is null ? [] : ["--xunit-version", xunitVersion]; + var testProjectDirectory = await CreateSupportTemplateInBootstrapAsync(auto, counter, workspace, templateName, projectName, args); + + const string appHostProjectName = "TemplateBootstrap.AppHost"; + var testProjectPath = Path.Combine(testProjectDirectory, $"{projectName}.csproj"); + + Assert.True(Directory.Exists(testProjectDirectory), $"Expected generated tests directory at {testProjectDirectory}."); + Assert.True(File.Exists(testProjectPath), $"Expected generated tests project at {testProjectPath}."); + + PrepareSupportProject(testProjectPath, appHostProjectName); + PrepareSupportTestSource(testProjectDirectory, templateName, appHostProjectName); + + await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(testProjectDirectory, workspace)}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("dotnet test"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6)); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + private static async Task CreateSupportTemplateInBootstrapAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter, + TemporaryWorkspace workspace, + string templateName, + string projectName, + IReadOnlyList extraArgs) + { + var commandParts = new List + { + "dotnet", + "new", + templateName, + "-n", + Quote(projectName), + "-o", + Quote($"./{projectName}") + }; + + commandParts.AddRange(extraArgs); + + await auto.TypeAsync(string.Join(" ", commandParts)); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + return DotNetTemplateBehaviorTestHelpers.ResolveGeneratedTemplateDirectory(workspace, projectName, "IntegrationTest1.cs"); + } + + private static void PrepareSupportProject(string testProjectPath, string appHostProjectName) + { + var document = XDocument.Load(testProjectPath); + var project = document.Root ?? throw new InvalidOperationException($"Project root not found in {testProjectPath}."); + + project.Add(new XElement("ItemGroup", + new XElement("ProjectReference", + new XAttribute("Include", Path.Combine("..", appHostProjectName, $"{appHostProjectName}.csproj"))))); + + document.Save(testProjectPath); + } + + private static void PrepareSupportTestSource(string testProjectDirectory, string templateName, string appHostProjectName) + { + var testSourcePath = Path.Combine(testProjectDirectory, "IntegrationTest1.cs"); + Assert.True(File.Exists(testSourcePath), $"Expected generated test source at {testSourcePath}."); + + var marker = templateName switch + { + "aspire-nunit" => "[Test]", + "aspire-mstest" => "[TestMethod]", + "aspire-xunit" => "[Fact]", + _ => throw new ArgumentOutOfRangeException(nameof(templateName), templateName, "Unknown support template name.") + }; + + var uncomment = false; + var lines = File.ReadAllLines(testSourcePath); + for (var i = 0; i < lines.Length; i++) + { + if (!uncomment && lines[i].Contains(marker, StringComparison.Ordinal)) + { + uncomment = true; + } + + if (uncomment) + { + lines[i] = UncommentLine(lines[i]); + } + } + + var generatedAppHostClassName = Regex.Replace(appHostProjectName, "[^A-Za-z0-9_]", "_"); + var updatedSource = string.Join(Environment.NewLine, lines) + .Replace("Projects.MyAspireApp_AppHost", $"Projects.{generatedAppHostClassName}", StringComparison.Ordinal); + + File.WriteAllText(testSourcePath, updatedSource + Environment.NewLine); + } + + private static string UncommentLine(string line) + { + var trimmed = line.TrimStart(); + if (!trimmed.StartsWith("//", StringComparison.Ordinal)) + { + return line; + } + + var indentation = line[..(line.Length - trimmed.Length)]; + var uncommented = trimmed.Length > 2 && trimmed[2] == ' ' ? trimmed[3..] : trimmed[2..]; + return indentation + uncommented; + } + + private static string Quote(string value) => $"\"{value}\""; +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TemplateVariantSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TemplateVariantSmokeTests.cs new file mode 100644 index 00000000000..9d6f864e8b0 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/TemplateVariantSmokeTests.cs @@ -0,0 +1,219 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Hex1b.Input; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end smoke tests for non-default Aspire CLI template variants. +/// +public sealed class TemplateVariantSmokeTests(ITestOutputHelper output) +{ + [Fact] + [CaptureWorkspaceOnFailure] + public async Task CreateAndRunJavaEmptyAppHostProject() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( + repoRoot, + strategy, + output, + variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, + mountDockerSocket: true, + workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.EnableExperimentalJavaSupportAsync(counter); + + await auto.AspireNewAsync("JavaEmptyApp", counter, template: AspireTemplate.JavaEmptyAppHost); + + GitIgnoreAssertions.AssertContainsEntry( + Path.Combine(workspace.WorkspaceRoot.FullName, "JavaEmptyApp"), + ".aspire/"); + + await auto.TypeAsync("cd JavaEmptyApp"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task CreateAndRunJsReactProject() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync("AspireJsReactApp", counter, template: AspireTemplate.JsReact, useRedisCache: false); + + await auto.AspireRunUntilReadyAsync(workspace); + + await auto.Ctrl().KeyAsync(Hex1bKey.C); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task CreateAndRunPythonReactProject() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync("AspirePyReactApp", counter, template: AspireTemplate.PythonReact, useRedisCache: false); + + GitIgnoreAssertions.AssertContainsEntry( + Path.Combine(workspace.WorkspaceRoot.FullName, "AspirePyReactApp"), + ".aspire/"); + + await auto.TypeAsync("cd AspirePyReactApp"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task CreateAndRunTypeScriptEmptyAppHostProject() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync("TsEmptyApp", counter, template: AspireTemplate.TypeScriptEmptyAppHost); + + GitIgnoreAssertions.AssertContainsEntry( + Path.Combine(workspace.WorkspaceRoot.FullName, "TsEmptyApp"), + ".aspire/"); + + await auto.TypeAsync("cd TsEmptyApp"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task CreateAndRunTypeScriptStarterProject() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync("TsStarterApp", counter, template: AspireTemplate.ExpressReact); + + var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "TsStarterApp"); + GitIgnoreAssertions.AssertContainsEntry(projectRoot, ".aspire/"); + var modulesDir = Path.Combine(projectRoot, ".modules"); + + if (!Directory.Exists(modulesDir)) + { + Assert.Fail($".modules directory was not created at {modulesDir}"); + } + + var aspireModulePath = Path.Combine(modulesDir, "aspire.ts"); + if (!File.Exists(aspireModulePath)) + { + Assert.Fail($"Expected generated file not found: {aspireModulePath}"); + } + + await auto.TypeAsync("cd TsStarterApp"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + + await auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs deleted file mode 100644 index d76991579d5..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Tests.Utils; -using Hex1b.Automation; -using Xunit; - -namespace Aspire.Cli.EndToEnd.Tests; - -/// -/// End-to-end tests for the TypeScript Empty AppHost template (aspire-ts-empty). -/// Validates that aspire new creates a working TypeScript AppHost project -/// and that aspire start runs it successfully. -/// -public sealed class TypeScriptEmptyAppHostTemplateTests(ITestOutputHelper output) -{ - [Fact] - [CaptureWorkspaceOnFailure] - public async Task CreateAndRunTypeScriptEmptyAppHostProject() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var strategy = CliInstallStrategy.Detect(output.WriteLine); - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - - await auto.AspireNewAsync("TsEmptyApp", counter, template: AspireTemplate.TypeScriptEmptyAppHost); - - GitIgnoreAssertions.AssertContainsEntry( - Path.Combine(workspace.WorkspaceRoot.FullName, "TsEmptyApp"), - ".aspire/"); - - // Start the empty TypeScript AppHost to verify the scaffolded project works - await auto.TypeAsync("cd TsEmptyApp"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); - - await auto.AspireStartAsync(counter); - await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs index 2da46bd52aa..0934a4e8db2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs @@ -64,31 +64,17 @@ public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("aspire restore"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireRestoreAndTypeCheckTypeScriptAsync(counter, typeCheckCommand: "npx --no-install tsc --noEmit"); var helperModulesDirectory = Path.Combine(helperDirectory.FullName, ".modules"); Assert.True(Directory.Exists(helperModulesDirectory), $".modules directory was not created for helper package at {helperModulesDirectory}"); Assert.Contains("addRedis", File.ReadAllText(Path.Combine(helperModulesDirectory, "aspire.ts"))); - await auto.TypeAsync("npx tsc --noEmit"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); - await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(appDirectory.FullName, workspace)}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("aspire restore"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("npx tsc --noEmit"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireRestoreAndTypeCheckTypeScriptAsync(counter, typeCheckCommand: "npx --no-install tsc --noEmit"); await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs deleted file mode 100644 index c0ae40c8d8f..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Tests.Utils; -using Hex1b.Automation; -using Xunit; - -namespace Aspire.Cli.EndToEnd.Tests; - -/// -/// End-to-end tests for the TypeScript Express/React starter template (aspire-ts-starter). -/// Validates that aspire new creates a working Express API + React frontend project -/// and that aspire run starts it successfully. -/// -public sealed class TypeScriptStarterTemplateTests(ITestOutputHelper output) -{ - [CaptureWorkspaceOnFailure] - [Fact] - public async Task CreateAndRunTypeScriptStarterProject() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var strategy = CliInstallStrategy.Detect(output.WriteLine); - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - - // Step 1: Create project using aspire new, selecting the Express/React template - await auto.AspireNewAsync("TsStarterApp", counter, template: AspireTemplate.ExpressReact); - - // Step 1.5: Verify starter creation also restored the generated TypeScript SDK. - var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "TsStarterApp"); - GitIgnoreAssertions.AssertContainsEntry(projectRoot, ".aspire/"); - var modulesDir = Path.Combine(projectRoot, ".modules"); - - if (!Directory.Exists(modulesDir)) - { - throw new InvalidOperationException($".modules directory was not created at {modulesDir}"); - } - - var aspireModulePath = Path.Combine(modulesDir, "aspire.ts"); - if (!File.Exists(aspireModulePath)) - { - throw new InvalidOperationException($"Expected generated file not found: {aspireModulePath}"); - } - - // Step 2: Navigate into the project and start it - await auto.TypeAsync("cd TsStarterApp"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); - - await auto.AspireStartAsync(counter); - await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } -} diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index e2641fb04e3..0232719ff60 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -131,7 +131,11 @@ public async Task WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod() Assert.True(execution.Start()); - var exitCode = await execution.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(30)); + var waitForExitTimeout = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? TimeSpan.FromSeconds(60) + : TimeSpan.FromSeconds(30); + + var exitCode = await execution.WaitForExitAsync(CancellationToken.None).WaitAsync(waitForExitTimeout); await releaseTask.WaitAsync(TimeSpan.FromSeconds(1)); Assert.Equal(0, exitCode); diff --git a/tests/Aspire.EndToEnd.Tests/Directory.Build.props b/tests/Aspire.EndToEnd.Tests/Directory.Build.props index 0f87452ee44..d42fd1eb754 100644 --- a/tests/Aspire.EndToEnd.Tests/Directory.Build.props +++ b/tests/Aspire.EndToEnd.Tests/Directory.Build.props @@ -1,11 +1,6 @@ - - - true - - - $([MSBuild]::NormalizeDirectory($([System.IO.Path]::GetTempPath()), $([System.IO.Path]::GetRandomFileName()))) + $([MSBuild]::NormalizeDirectory($([System.IO.Path]::GetTempPath()), $([System.IO.Path]::GetRandomFileName()))) diff --git a/tests/Aspire.Templates.Tests/AppHostTemplateTests.cs b/tests/Aspire.Templates.Tests/AppHostTemplateTests.cs deleted file mode 100644 index dab50938b6a..00000000000 --- a/tests/Aspire.Templates.Tests/AppHostTemplateTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; -using System.Text.RegularExpressions; - -namespace Aspire.Templates.Tests; - -public partial class AppHostTemplateTests : TemplateTestsBase -{ - public AppHostTemplateTests(ITestOutputHelper testOutput) - : base(testOutput) - { - } - - [Fact] - public async Task EnsureProjectsReferencing8_1_0AppHostWithNewerWorkloadCanBuild() - { - string projectId = "aspire-can-reference-8.1.0"; - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - projectId, - "aspire-apphost", - _testOutput, - BuildEnvironment.ForDefaultFramework, - addEndpointsHook: false); - - var projectPath = Path.Combine(project.RootDir, $"{projectId}.csproj"); - - // Replace the reference to Aspire.Hosting.AppHost with version 8.1.0 - var newContents = AppHostPackageReferenceRegex().Replace(File.ReadAllText(projectPath), @"$1""8.1.0"""); - - File.WriteAllText(projectPath, newContents); - - // Ensure project builds successfully - await project.BuildAsync(workingDirectory: project.RootDir); - } - - [GeneratedRegex(@"(PackageReference\s.*""Aspire\.Hosting\.AppHost.*Version=)""[^""]+""")] - private static partial Regex AppHostPackageReferenceRegex(); -} diff --git a/tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj b/tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj deleted file mode 100644 index 567c2635495..00000000000 --- a/tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj +++ /dev/null @@ -1,58 +0,0 @@ - - - - $(DefaultTargetFramework) - - true - true - - xunit.runner.json - $(TestArchiveTestsDirForTemplateTests) - - true - true - true - - true - Aspire.Templates.Tests - - - true - true - true - - - false - false - - - true - - - 20m - 15m - - - $(NoWarn);xUnit1051 - - - true - $(TestRunnerAdditionalArguments) --filter-trait category=basic-build - - - - - - - - - - - - diff --git a/tests/Aspire.Templates.Tests/BuildAndRunStarterTemplateBuiltInTest.cs b/tests/Aspire.Templates.Tests/BuildAndRunStarterTemplateBuiltInTest.cs deleted file mode 100644 index b5cb174babf..00000000000 --- a/tests/Aspire.Templates.Tests/BuildAndRunStarterTemplateBuiltInTest.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Xunit; - -namespace Aspire.Templates.Tests; - -public class BuildAndRunStarterTemplateBuiltInTest : TemplateTestsBase -{ - public BuildAndRunStarterTemplateBuiltInTest(ITestOutputHelper testOutput) - : base(testOutput) - {} - - public static TheoryData TestFrameworkTypeWithConfig() - { - var data = new TheoryData(); - foreach (var testType in TemplateTestsBase.TestFrameworkTypes) - { - data.Add("Debug", testType); - if (!PlatformDetection.IsRunningPRValidation) - { - data.Add("Release", testType); - } - } - return data; - } - - [Theory] - [MemberData(nameof(TestFrameworkTypeWithConfig))] - [RequiresFeature(TestFeature.Docker | TestFeature.SSLCertificate)] - [Trait("category", "basic-build")] - public async Task BuildAndRunStarterTemplateBuiltInTest_Test(string config, string testType) - { - string id = TemplateTestsBase.GetNewProjectId(prefix: $"starter test.{config}-{testType.Replace(".", "_")}"); - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - "aspire-starter", - _testOutput, - buildEnvironment: BuildEnvironment.ForDefaultFramework, - extraArgs: $"-t {testType}"); - - await TemplateTestsBase.AssertTestProjectRunAsync(project.TestsProjectDirectory, testType, _testOutput, config); - } -} diff --git a/tests/Aspire.Templates.Tests/BuildAndRunTemplateTests.cs b/tests/Aspire.Templates.Tests/BuildAndRunTemplateTests.cs deleted file mode 100644 index 7c4fc4ce163..00000000000 --- a/tests/Aspire.Templates.Tests/BuildAndRunTemplateTests.cs +++ /dev/null @@ -1,250 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.RegularExpressions; -using Aspire.Hosting; -using Aspire.TestUtilities; -using Xunit; - -namespace Aspire.Templates.Tests; - -// This class has tests that start projects on their own -public partial class BuildAndRunTemplateTests : TemplateTestsBase -{ - public BuildAndRunTemplateTests(ITestOutputHelper testOutput) - : base(testOutput) - {} - - public static TheoryData BuildConfigurationsForTestData() - { - var data = new TheoryData() { "Debug" }; - if (!PlatformDetection.IsRunningPRValidation) - { - data.Add("Release"); - } - - return data; - } - - [Theory] - [MemberData(nameof(BuildConfigurationsForTestData))] - [RequiresFeature(TestFeature.SSLCertificate), RequiresFeature(TestFeature.Playwright)] - [Trait("category", "basic-build")] - [OuterloopTest("playwright test")] - public async Task BuildAndRunAspireTemplate(string config) - { - string id = GetNewProjectId(prefix: $"aspire_{config}"); - await using var project = await AspireProject.CreateNewTemplateProjectAsync(id, "aspire", _testOutput, buildEnvironment: BuildEnvironment.ForDefaultFramework); - - await project.BuildAsync(extraBuildArgs: [$"-c {config}"]); - await project.StartAppHostAsync(extraArgs: [$"-c {config}"]); - - await using var context = await CreateNewBrowserContextAsync(); - var page = await project.OpenDashboardPageAsync(context); - await CheckDashboardHasResourcesAsync(page, [], logPath: project.LogPath); - } - - [Fact] - public async Task BuildAndRunAspireTemplateWithCentralPackageManagement() - { - string id = GetNewProjectId(prefix: "aspire_CPM"); - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - "aspire", - _testOutput, - buildEnvironment: BuildEnvironment.ForDefaultFramework); - - string version = ExtractVersionFromSdkAndAddRedisPackageReference(project); - - CreateCPMFile(project, version); - - await project.BuildAsync(); - await project.StartAppHostAsync(); - await project.StopAppHostAsync(); - - static string ExtractVersionFromSdkAndAddRedisPackageReference(AspireProject project) - { - var projectName = Directory.GetFiles(project.AppHostProjectDirectory, "*.csproj").FirstOrDefault(); - Assert.False(string.IsNullOrEmpty(projectName)); - - var projectContents = File.ReadAllText(projectName); - - var match = ProjectSdkVersionRegex().Match(projectContents); - - File.WriteAllText( - projectName, - ProjectClosingTagRegex().Replace(projectContents, - """ - - - - - """) - ); - - var version = match.Groups[1].Value; - Assert.NotNull(version); - - return version; - } - - static void CreateCPMFile(AspireProject project, string version) - { - var cpmFilePath = Path.Combine(project.RootDir, "Directory.Packages.props"); - var cpmContent = $""" - - - true - - NU1507;$(NoWarn) - - - - - - """; - - File.WriteAllText(cpmFilePath, cpmContent); - } - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task BuildAndRunAspireTemplateWithExplicitSdkReference(bool includeAspireHostingAppHostPackageReference) - { - string id = GetNewProjectId(prefix: "aspire_explicit_SDK"); - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - "aspire", - _testOutput, - buildEnvironment: BuildEnvironment.ForDefaultFramework); - - UpdateSdkReferencesAndAddAppHostPackageReference(project, includeAspireHostingAppHostPackageReference); - - await project.BuildAsync(); - await project.StartAppHostAsync(); - await project.StopAppHostAsync(); - - static void UpdateSdkReferencesAndAddAppHostPackageReference(AspireProject project, bool addPackageRef) - { - var projectName = Directory.GetFiles(project.AppHostProjectDirectory, "*.csproj").FirstOrDefault(); - Assert.False(string.IsNullOrEmpty(projectName)); - - var projectContents = File.ReadAllText(projectName); - - var match = ProjectSdkVersionRegex().Match(projectContents); - var version = match.Groups[1].Value; - Assert.NotNull(version); - - File.WriteAllText( - projectName, - ProjectSdkVersionRegex().Replace(projectContents, - $""" - - - """) - ); - - if (addPackageRef) - { - File.WriteAllText( - projectName, - ProjectClosingTagRegex().Replace(projectContents, - $""" - - - - - """)); - } - } - } - - [Theory] - [MemberData(nameof(BuildConfigurationsForTestData))] - [RequiresFeature(TestFeature.SSLCertificate), RequiresFeature(TestFeature.Playwright)] - [Trait("category", "basic-build")] - [OuterloopTest("playwright test")] - public async Task StarterTemplateNewAndRunWithoutExplicitBuild(string config) - { - var id = GetNewProjectId(prefix: $"aspire_starter_run_{config}"); - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - "aspire-starter", - _testOutput, - buildEnvironment: BuildEnvironment.ForDefaultFramework); - - await using var context = await CreateNewBrowserContextAsync(); - await AssertStarterTemplateRunAsync(context, project, config, _testOutput); - } - - [Fact] - [RequiresFeature(TestFeature.Playwright)] - [OuterloopTest("playwright test")] - [ActiveIssue("https://github.com/microsoft/aspire/issues/9155", typeof(PlatformDetection), nameof(PlatformDetection.IsMacOS))] - public async Task ProjectWithNoHTTPSRequiresExplicitOverrideWithEnvironmentVariable() - { - string id = GetNewProjectId(prefix: "aspire"); - // Using a copy so envvars can be modified without affecting other tests - var testSpecificBuildEnvironment = new BuildEnvironment(BuildEnvironment.ForDefaultFramework); - - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - "aspire", - _testOutput, - buildEnvironment: testSpecificBuildEnvironment, - extraArgs: "--no-https"); - - await project.BuildAsync(); - using var buildCmd = new DotNetCommand(_testOutput, buildEnv: testSpecificBuildEnvironment, label: "first-run") - .WithWorkingDirectory(project.AppHostProjectDirectory); - - var res = await buildCmd.ExecuteAsync("run"); - Assert.True(res.ExitCode != 0, $"Expected the app run to fail"); - Assert.Contains($"setting must be an https address unless the '{KnownConfigNames.AllowUnsecuredTransport}'", res.Output); - - // Run with the environment variable set - testSpecificBuildEnvironment.EnvVars[KnownConfigNames.AllowUnsecuredTransport] = "true"; - await project.StartAppHostAsync(); - - await using var context = await CreateNewBrowserContextAsync(); - var page = await project.OpenDashboardPageAsync(context); - await CheckDashboardHasResourcesAsync(page, [], logPath: project.LogPath); - } - - [Theory] - [InlineData("9.*-*")] - [InlineData("[9.0.0]")] - [Trait("category", "basic-build")] - public async Task CreateAndModifyAspireAppHostTemplate(string version) - { - string id = GetNewProjectId(prefix: $"aspire_apphost_{version.Replace("*", "wildcard").Replace("[", "").Replace("]", "")}"); - await using var project = await AspireProject.CreateNewTemplateProjectAsync(id, "aspire-apphost", _testOutput, buildEnvironment: BuildEnvironment.ForDefaultFramework, addEndpointsHook: false); - - ModifyProjectFile(project, version); - - await project.BuildAsync(workingDirectory: project.RootDir); - - static void ModifyProjectFile(AspireProject project, string version) - { - var projectName = Directory.GetFiles(project.RootDir, "*.csproj").FirstOrDefault(); - Assert.False(string.IsNullOrEmpty(projectName)); - - var projectContents = File.ReadAllText(projectName); - - var modifiedContents = AppHostVersionRegex().Replace(projectContents, $@""); - - File.WriteAllText(projectName, modifiedContents); - } - } - - [GeneratedRegex(@"")] - private static partial Regex AppHostVersionRegex(); - - [GeneratedRegex(@"")] - private static partial Regex ProjectClosingTagRegex(); - - [GeneratedRegex(@"")] - private static partial Regex ProjectSdkVersionRegex(); -} diff --git a/tests/Aspire.Templates.Tests/Directory.Build.props b/tests/Aspire.Templates.Tests/Directory.Build.props deleted file mode 100644 index 853e73e2ac5..00000000000 --- a/tests/Aspire.Templates.Tests/Directory.Build.props +++ /dev/null @@ -1,8 +0,0 @@ - - - - true - - - - diff --git a/tests/Aspire.Templates.Tests/EmptyTemplateRunFixture.cs b/tests/Aspire.Templates.Tests/EmptyTemplateRunFixture.cs deleted file mode 100644 index a11f2399a6e..00000000000 --- a/tests/Aspire.Templates.Tests/EmptyTemplateRunFixture.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit.Sdk; - -namespace Aspire.Templates.Tests; - -/// -/// This fixture ensures the TestProject.AppHost application is started before a test is executed. -/// -/// Represents the the IntegrationServiceA project in the test application used to send HTTP requests -/// to the project's endpoints. -/// -public sealed class EmptyTemplateRunFixture : TemplateAppFixture -{ - public EmptyTemplateRunFixture(IMessageSink diagnosticMessageSink) - : base(diagnosticMessageSink, "aspire") - { - } -} diff --git a/tests/Aspire.Templates.Tests/EmptyTemplateRunTests.cs b/tests/Aspire.Templates.Tests/EmptyTemplateRunTests.cs deleted file mode 100644 index 1397691e086..00000000000 --- a/tests/Aspire.Templates.Tests/EmptyTemplateRunTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Xunit; - -namespace Aspire.Templates.Tests; - -public class EmptyTemplateRunTests : TemplateTestsBase, IClassFixture -{ - private readonly EmptyTemplateRunFixture _testFixture; - - public EmptyTemplateRunTests(EmptyTemplateRunFixture fixture, ITestOutputHelper testOutput) - : base(testOutput) - { - _testFixture = fixture; - } - - [Fact] - [RequiresFeature(TestFeature.Playwright)] - [RequiresFeature(TestFeature.SSLCertificate)] - [OuterloopTest("Resource-intensive Playwright browser test")] - public async Task ResourcesShowUpOnDashboad() - { - await using var context = await CreateNewBrowserContextAsync(); - await CheckDashboardHasResourcesAsync( - await _testFixture.Project!.OpenDashboardPageAsync(context), - [], - timeoutSecs: 1_000, - logPath: _testFixture.Project.LogPath); - } -} diff --git a/tests/Aspire.Templates.Tests/LocalhostTldHostnameTests.cs b/tests/Aspire.Templates.Tests/LocalhostTldHostnameTests.cs deleted file mode 100644 index 4ed489c2461..00000000000 --- a/tests/Aspire.Templates.Tests/LocalhostTldHostnameTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using System.Text.RegularExpressions; -using Xunit; - -namespace Aspire.Templates.Tests; - -public partial class LocalhostTldHostnameTests(ITestOutputHelper testOutput) : TemplateTestsBase(testOutput) -{ - [GeneratedRegex(@"://([^:]+)\.dev\.localhost:")] - private static partial Regex HostnamePattern(); - - public static TheoryData LocalhostTldHostname_TestData() => new() - { - // templateName, projectName, expectedHostname - { "aspire", "my.namespace.app", "my-namespace-app" }, - { "aspire", ".StartWithDot", "startwithdot" }, - { "aspire", "EndWithDot.", "endwithdot" }, - { "aspire", "My..Test__Project", "my-test-project" }, - { "aspire", "Project123.Test456", "project123-test456" }, - { "aspire-apphost", "my.service.name", "my-service-name" }, - { "aspire-apphost-singlefile", "-my.service..name-", "my-service-name" }, - { "aspire-starter", "Test_App.1", "test-app-1" }, - { "aspire-ts-cs-starter", "My-App.Test", "my-app-test" } - }; - - [Theory] - [MemberData(nameof(LocalhostTldHostname_TestData))] - [Trait("category", "basic-build")] - public async Task LocalhostTld_GeneratesDnsCompliantHostnames(string templateName, string projectName, string expectedHostname) - { - var id = GetNewProjectId(prefix: $"localhost_tld_{templateName}"); - - var targetFramework = templateName switch - { - "aspire-apphost-singlefile" => TestTargetFramework.None, // These templates do not support -f argument - _ => TestTargetFramework.Next // LocalhostTld only available on net10.0 - }; - - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - templateName, - _testOutput, - buildEnvironment: BuildEnvironment.ForNextSdkOnly, // Need Next SDK for net10.0 - extraArgs: $"--localhost-tld -n \"{projectName}\"", - targetFramework, - addEndpointsHook: false); // Don't add endpoint hook since we're just checking file generation - - // When using -n, the template still outputs to the -o directory (id), - // but the project names inside use the -n value - // Find the launchSettings.json file - it will be in a directory named after the project - var launchSettingsPath = templateName switch - { - "aspire-ts-cs-starter" or "aspire-starter" => Path.Combine(project.RootDir, $"{projectName}.AppHost", "Properties", "launchSettings.json"), - "aspire" => Path.Combine(project.RootDir, $"{projectName}.AppHost", "Properties", "launchSettings.json"), - "aspire-apphost" => Path.Combine(project.RootDir, "Properties", "launchSettings.json"), - "aspire-apphost-singlefile" => Path.Combine(project.RootDir, "apphost.run.json"), - _ => throw new ArgumentException($"Unknown template: {templateName}") - }; - - Assert.True(File.Exists(launchSettingsPath), $"launchSettings.json/apphost.run.json file not found at {launchSettingsPath}"); - - var launchSettingsContent = await File.ReadAllTextAsync(launchSettingsPath); - using var launchSettings = JsonDocument.Parse(launchSettingsContent); - - var profiles = launchSettings.RootElement.GetProperty("profiles"); - - var foundDevLocalhost = false; - foreach (var profile in profiles.EnumerateObject()) - { - if (profile.Value.TryGetProperty("applicationUrl", out var applicationUrl)) - { - var urls = applicationUrl.GetString(); - if (!string.IsNullOrEmpty(urls) && urls.Contains(".dev.localhost:")) - { - foundDevLocalhost = true; - - // Verify the hostname in the URL matches expected DNS-compliant format - Assert.Contains($"{expectedHostname}.dev.localhost:", urls); - - // Verify no underscores in hostname (RFC 952/1123 compliance) - var matches = HostnamePattern().Matches(urls); - foreach (Match match in matches) - { - var hostname = match.Groups[1].Value; - Assert.DoesNotContain("_", hostname, StringComparison.Ordinal); - Assert.DoesNotContain(".", hostname, StringComparison.Ordinal); - Assert.False(hostname.StartsWith("-", StringComparison.Ordinal), - $"Hostname '{hostname}' should not start with hyphen (RFC 952/1123 violation)"); - Assert.False(hostname.EndsWith("-", StringComparison.Ordinal), - $"Hostname '{hostname}' should not end with hyphen (RFC 952/1123 violation)"); - } - } - } - } - - Assert.True(foundDevLocalhost, "No .dev.localhost URLs found in launchSettings.json"); - } -} diff --git a/tests/Aspire.Templates.Tests/NewUpAndBuildStandaloneTemplateTests.cs b/tests/Aspire.Templates.Tests/NewUpAndBuildStandaloneTemplateTests.cs deleted file mode 100644 index b0e1678bbbe..00000000000 --- a/tests/Aspire.Templates.Tests/NewUpAndBuildStandaloneTemplateTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; - -namespace Aspire.Templates.Tests; - -public class NewUpAndBuildStandaloneTemplateTests(ITestOutputHelper testOutput) : TemplateTestsBase(testOutput) -{ - [Theory] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire", ""])] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-starter", ""])] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-ts-cs-starter", ""])] - [Trait("category", "basic-build")] - public async Task CanNewAndBuild(string templateName, string extraArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - var id = GetNewProjectId(prefix: $"new_build_{templateName}_{tfm.ToTFMString()}"); - - var buildEnvToUse = sdk switch - { - TestSdk.Current => BuildEnvironment.ForCurrentSdkOnly, - TestSdk.Previous => BuildEnvironment.ForPreviousSdkOnly, - TestSdk.Next => BuildEnvironment.ForNextSdkOnly, - TestSdk.NextSdkWithCurrentAndPreviousRuntime => BuildEnvironment.ForNextSdkWithCurrentAndPreviousRuntimes, - _ => throw new ArgumentOutOfRangeException(nameof(sdk)) - }; - - try - { - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - templateName, - _testOutput, - buildEnvironment: buildEnvToUse, - extraArgs: extraArgs, - targetFramework: tfm); - - Assert.True(error is null, $"Expected to throw an exception with message: {error}"); - - await project.BuildAsync(extraBuildArgs: [$"-c Debug"]); - } - catch (ToolCommandException tce) when (error is not null) - { - Assert.NotNull(tce.Result); - Assert.Contains(error, tce.Result.Value.Output); - } - } -} diff --git a/tests/Aspire.Templates.Tests/NewUpAndBuildSupportProjectTemplatesTests.cs b/tests/Aspire.Templates.Tests/NewUpAndBuildSupportProjectTemplatesTests.cs deleted file mode 100644 index 96e1a4eb3ac..00000000000 --- a/tests/Aspire.Templates.Tests/NewUpAndBuildSupportProjectTemplatesTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; - -namespace Aspire.Templates.Tests; - -public abstract class NewUpAndBuildSupportProjectTemplatesBase(ITestOutputHelper testOutput) : TemplateTestsBase(testOutput) -{ - [Trait("category", "basic-build")] - protected async Task CanNewAndBuildActual(string templateName, string extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - var id = GetNewProjectId(prefix: $"new_build_{FixupSymbolName(templateName)}"); - var topLevelDir = Path.Combine(BuildEnvironment.TestRootPath, id + "_root"); - string config = "Debug"; - - var buildEnvToUse = sdk switch - { - TestSdk.Current => BuildEnvironment.ForCurrentSdkOnly, - TestSdk.Previous => BuildEnvironment.ForPreviousSdkOnly, - TestSdk.Next => BuildEnvironment.ForNextSdkOnly, - TestSdk.NextSdkWithCurrentAndPreviousRuntime => BuildEnvironment.ForNextSdkWithCurrentAndPreviousRuntimes, - _ => throw new ArgumentOutOfRangeException(nameof(sdk)) - }; - - if (Directory.Exists(topLevelDir)) - { - Directory.Delete(topLevelDir, recursive: true); - } - Directory.CreateDirectory(topLevelDir); - - try - { - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id: id + ".AppHost", - template: "aspire-apphost", - testOutput: _testOutput, - buildEnvironment: buildEnvToUse, - targetFramework: tfm, - addEndpointsHook: false, - overrideRootDir: topLevelDir); - project.AppHostProjectDirectory = Path.Combine(topLevelDir, id + ".AppHost"); - - var testProjectDir = await CreateAndAddTestTemplateProjectAsync( - id: id, - testTemplateName: templateName, - project: project, - tfm: tfm, - buildEnvironment: buildEnvToUse, - extraArgs: extraTestCreationArgs, - overrideRootDir: topLevelDir); - - await project.BuildAsync(extraBuildArgs: [$"-c {config}"], workingDirectory: testProjectDir); - } - catch (ToolCommandException tce) when (error is not null) - { - Assert.NotNull(tce.Result); - Assert.Contains(error, tce.Result.Value.Output); - } - } -} - -public class NUnit_NewUpAndBuildSupportProjectTemplatesTests(ITestOutputHelper testOutput) : NewUpAndBuildSupportProjectTemplatesBase(testOutput) -{ - [Theory] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-nunit", ""])] - public Task CanNewAndBuild(string templateName, string extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - return CanNewAndBuildActual(templateName, extraTestCreationArgs, sdk, tfm, error); - } -} - -public class XUnit_Default_NewUpAndBuildSupportProjectTemplatesTests(ITestOutputHelper testOutput) : NewUpAndBuildSupportProjectTemplatesBase(testOutput) -{ - [Theory] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-xunit", ""])] - public Task CanNewAndBuild(string templateName, string extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - return CanNewAndBuildActual(templateName, extraTestCreationArgs, sdk, tfm, error); - } -} - -public class XUnit_V2_NewUpAndBuildSupportProjectTemplatesTests(ITestOutputHelper testOutput) : NewUpAndBuildSupportProjectTemplatesBase(testOutput) -{ - [Theory] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-xunit", "--xunit-version v2"])] - public Task CanNewAndBuild(string templateName, string extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - return CanNewAndBuildActual(templateName, extraTestCreationArgs, sdk, tfm, error); - } -} - -public class XUnit_V3_NewUpAndBuildSupportProjectTemplatesTests(ITestOutputHelper testOutput) : NewUpAndBuildSupportProjectTemplatesBase(testOutput) -{ - [Theory] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-xunit", "--xunit-version v3"])] - public Task CanNewAndBuild(string templateName, string extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - return CanNewAndBuildActual(templateName, extraTestCreationArgs, sdk, tfm, error); - } -} - -public class XUnit_V3MTP_NewUpAndBuildSupportProjectTemplatesTests(ITestOutputHelper testOutput) : NewUpAndBuildSupportProjectTemplatesBase(testOutput) -{ - [Theory] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-xunit", "--xunit-version v3mtp"])] - public Task CanNewAndBuild(string templateName, string extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - return CanNewAndBuildActual(templateName, extraTestCreationArgs, sdk, tfm, error); - } -} - -public class XUnit_NewUpAndBuildSupportProjectTemplatesTests(ITestOutputHelper testOutput) : NewUpAndBuildSupportProjectTemplatesBase(testOutput) -{ - [Theory] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-xunit", ""])] - public Task CanNewAndBuild(string templateName, string extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - return CanNewAndBuildActual(templateName, extraTestCreationArgs, sdk, tfm, error); - } -} - -public class MSTest_NewUpAndBuildSupportProjectTemplatesTests(ITestOutputHelper testOutput) : NewUpAndBuildSupportProjectTemplatesBase(testOutput) -{ - [Theory] - [MemberData(nameof(TestDataForNewAndBuildTemplateTests), arguments: ["aspire-mstest", ""])] - public Task CanNewAndBuild(string templateName, string extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, string? error) - { - return CanNewAndBuildActual(templateName, extraTestCreationArgs, sdk, tfm, error); - } -} diff --git a/tests/Aspire.Templates.Tests/PerTestFrameworkTemplatesTests.cs b/tests/Aspire.Templates.Tests/PerTestFrameworkTemplatesTests.cs deleted file mode 100644 index f5104a4136c..00000000000 --- a/tests/Aspire.Templates.Tests/PerTestFrameworkTemplatesTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Microsoft.Playwright; -using Xunit; - -namespace Aspire.Templates.Tests; - -public abstract partial class PerTestFrameworkTemplatesTests : TemplateTestsBase -{ - private readonly string _testTemplateName; - - public PerTestFrameworkTemplatesTests(string testType, ITestOutputHelper testOutput) : base(testOutput) - { - _testTemplateName = testType; - } - - public static TheoryData ProjectNames_TestData() => new(GetProjectNamesForTest()); - - [Theory] - [MemberData(nameof(ProjectNames_TestData))] - [RequiresFeature(TestFeature.Playwright)] - [Trait("category", "basic-build")] - [OuterloopTest("playwright test")] - [ActiveIssue("https://github.com/microsoft/aspire/issues/8011")] - public async Task TemplatesForIndividualTestFrameworks(string prefix) - { - var id = $"{prefix}-{_testTemplateName}"; - var config = "Debug"; - - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - "aspire", - _testOutput, - buildEnvironment: BuildEnvironment.ForDefaultFramework); - - await project.BuildAsync(extraBuildArgs: [$"-c {config}"]); - if (RequiresFeatureAttribute.IsFeatureSupported(TestFeature.SSLCertificate)) - { - await using (var context = await CreateNewBrowserContextAsync()) - { - await AssertBasicTemplateAsync(context); - } - } - - var testProjectDir = await CreateAndAddTestTemplateProjectAsync(id, _testTemplateName, project); - - using var cmd = new DotNetCommand(_testOutput, label: $"test-{_testTemplateName}") - .WithWorkingDirectory(testProjectDir) - .WithTimeout(TimeSpan.FromMinutes(3)); - - var res = await cmd.ExecuteAsync($"test -c {config}"); - - Assert.True(res.ExitCode != 0, $"Expected the tests project run to fail"); - Assert.Matches("Failed! * - Failed: *1, Passed: *0, Skipped: *0, Total: *1", res.Output); - - async Task AssertBasicTemplateAsync(IBrowserContext context) - { - await project.StartAppHostAsync(extraArgs: [$"-c {config}"]); - - try - { - var page = await project.OpenDashboardPageAsync(context); - await CheckDashboardHasResourcesAsync(page, [], logPath: project.LogPath); - } - finally - { - await project.StopAppHostAsync(); - } - } - } -} - -// Individual class for each test framework so the tests can run in separate helix jobs -public class MSTest_PerTestFrameworkTemplatesTests : PerTestFrameworkTemplatesTests -{ - public MSTest_PerTestFrameworkTemplatesTests(ITestOutputHelper testOutput) : base("aspire-mstest", testOutput) - { - } -} - -public class Xunit_PerTestFrameworkTemplatesTests : PerTestFrameworkTemplatesTests -{ - public Xunit_PerTestFrameworkTemplatesTests(ITestOutputHelper testOutput) : base("aspire-xunit", testOutput) - { - } -} - -public class Nunit_PerTestFrameworkTemplatesTests : PerTestFrameworkTemplatesTests -{ - public Nunit_PerTestFrameworkTemplatesTests(ITestOutputHelper testOutput) : base("aspire-nunit", testOutput) - { - } -} diff --git a/tests/Aspire.Templates.Tests/README.md b/tests/Aspire.Templates.Tests/README.md deleted file mode 100644 index 4cbb8d238bd..00000000000 --- a/tests/Aspire.Templates.Tests/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# Aspire.Template.Tests - -The purpose of the `Aspire.Template.Tests` project is to run end-to-end tests against pre-built NuGet packages (nupkgs). These tests validate the ability to create projects from templates just like a user would, and then build, run, and interact with Aspire projects to ensure compatibility with CI pipelines and local development environments. - -For pull-requests in CI the tests are run via GitHub actions defined in `tests-templates.yml`. - -## TL;DR or How do I use this? - -1. [Install the SDK](#install-the-sdk) -2. Run/debug the tests normally now, and they will be using the SDK - -## (details) What are *template* tests? - -The individual tests need to create projects from templates just like a user would, and then run, and validate them. For this we need: - - a SDK installation with the necessary components installed - - the components should use packages from the locally built NuGet packages - - and the NuGet packages should be used from the locally built ones - -### Solution (SDK): - -- SDK is installed with `$(SdkVersionForTemplateTesting)` set to the version in `global.json` by default. -- The necessary Aspire components are installed using NuGet packages from `artifacts/packages/*/Shipping` -- Then, with a custom `nuget.config` which points to the built NuGet packages in `artifacts`, the SDK is configured to use local packages - - which installs the components using the NuGet packages from the `artifacts` into `artifacts/bin/dotnet-tests` -- This simulates the SDK being installed on a user's machine, and being independent of the aspire repo. -- At this point the SDK is usable from outside the repo by using `source /path-to-aspire-repo/dogfood.sh` -- The nuget versions for the locally built packages are like `8.0.0-dev` or `8.0.0-ci`. - -### Helix - -- The SDK is sent to helix, and used by all the tests for creating/running projects. - -## Install the SDK - -1. `.\build.cmd -pack` - to build all the NuGet packages (or `./build.sh -pack`) -2. `dotnet build tests/workloads.proj /p:Configuration=` - - this will install the SDK, and the necessary components using the NuGet packages from `artifacts/packages/*/Shipping` into `artifacts/bin/dotnet-tests` - - note: `artifacts/bin/dotnet-none` contains the SDK with component manifests but NO components - -The SDK in `artifacts/bin/dotnet-tests` is usable outside the repo at this point. - -## Using the SDK outside the repo - -- Follow the steps to [install the SDK](#install-the-sdk). - -- The environment needs to be set up to use this. It can be done manually with: - - Add `/path-to-aspire-repo/artifacts/bin/dotnet-tests` to `PATH`. - - Add `artifacts/packages/$(Configuration)/Shipping` as a NuGet source for your projects, so the locally built packages can be picked up. - - `tests/Shared/TemplateTesting/data/nuget8.config` can be used as a template for this, or you can add it manually to your `nuget.config` - - This `nuget8.config` uses the environment variable `BUILT_NUGETS_PATH`, so set `BUILT_NUGETS_PATH=/path-to-aspire/artifacts/packages/$(Configuration)/Shipping` - -- An alternative way is to use `source /path-to-aspire-repo/dogfood.sh` on Linux/macOS. - - Copy `tests/Shared/TemplateTesting/data/nuget8.config nuget.config` - - and set `BUILT_NUGETS_PATH=/path-to-aspire/artifacts/packages/$(Configuration)/Shipping` - -## Inner loop tips - -- The sdk+workload is never updated automatically. In other words, once installed the workload packs don't get overwritten even when the source binaries changes in `artifacts`. This may change in future. - -There are three categories of NuGet packages used by the workload: - -1. `Aspire.Dashboard.Sdk.osx-arm64`, `Aspire.Hosting.Orchestration.osx-arm64`, and `Aspire.AppHost.Sdk` - - these are installed in `artifacts/bin/dotnet-tests/packs/` - - Once the workload is installed, these are never updated automatically, so any changes made locally won't show up in the tests - -2. All the other `Aspire` NuGet packages - - These are not part of the workload itself, but can be referenced from the user projects. - - When tests build a project referencing such a NuGet package, it gets resolved from the `artifacts`. - - And a local tests-specific cache is used for this like `/path-to-aspire-repo/artifacts/bin/Aspire.Template.Tests/Release/net8.0/nuget-cache-Net80` - - this is printed at the start of the test suite run - -3. Project templates installed in `artifacts/bin/dotnet-tests/template-packs`. - -### Testing with local changes - -- If there are changes to bits that would be part of the workload packs, then those would need to be updated (copied over) manually -- The project templates don't follow the above model though. The NuGet packages for those need to be built, and then copied to `artifacts/bin/dotnet-tests/template-packs/aspire.projecttemplates.*.nupkg` for the changes to be usable in the tests - -- For changes related to other NuGet packages, there are a couple of options: - 1. rebuild the NuGet package; delete the unpacked NuGet package from the cache - 2. Since the cache is never automatically deleted, copy over any changed files directly in the NuGet cache. - - Subsequent test runs would pick up the changes. diff --git a/tests/Aspire.Templates.Tests/StarterTemplateFixture.cs b/tests/Aspire.Templates.Tests/StarterTemplateFixture.cs deleted file mode 100644 index efccc99fcb8..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateFixture.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit.Sdk; - -namespace Aspire.Templates.Tests; - -/// -/// This fixture creates a new project using the `aspire-starter` template, and runs that -/// -public sealed class StarterTemplateFixture : TemplateAppFixture -{ - public StarterTemplateFixture(IMessageSink diagnosticMessageSink) - : base(diagnosticMessageSink, "aspire-starter") - { - } -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateFixture_PreviousTFM.cs b/tests/Aspire.Templates.Tests/StarterTemplateFixture_PreviousTFM.cs deleted file mode 100644 index f313d85f9ca..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateFixture_PreviousTFM.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit.Sdk; - -namespace Aspire.Templates.Tests; - -public sealed class StarterTemplateFixture_PreviousTFM : TemplateAppFixture -{ - public StarterTemplateFixture_PreviousTFM(IMessageSink diagnosticMessageSink) - : base(diagnosticMessageSink, "aspire-starter", tfm: TestTargetFramework.Previous) - { - } -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateProjectNamesTests.cs b/tests/Aspire.Templates.Tests/StarterTemplateProjectNamesTests.cs deleted file mode 100644 index 47a0e5ce840..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateProjectNamesTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Xunit; - -namespace Aspire.Templates.Tests; - -public abstract class StarterTemplateProjectNamesTests : TemplateTestsBase -{ - private readonly string _testType; - public StarterTemplateProjectNamesTests(string testType, ITestOutputHelper testOutput) - : base(testOutput) - { - _testType = testType; - } - - public static TheoryData ProjectNames_TestData() - => new(GetProjectNamesForTest()); - - [Theory] - [MemberData(nameof(ProjectNames_TestData))] - [RequiresFeature(TestFeature.SSLCertificate)] - [RequiresFeature(TestFeature.Playwright)] - [OuterloopTest("playwright test")] - public async Task StarterTemplateWithTest_ProjectNames(string prefix) - { - string id = $"{prefix}-{_testType}"; - string config = "Debug"; - - await using var project = await AspireProject.CreateNewTemplateProjectAsync( - id, - "aspire-starter", - _testOutput, - BuildEnvironment.ForDefaultFramework, - $"-t {_testType}"); - - await using var context = await CreateNewBrowserContextAsync(); - _testOutput.WriteLine($"Checking the starter template project"); - await AssertStarterTemplateRunAsync(context, project, config, _testOutput); - - _testOutput.WriteLine($"Checking the starter template project tests"); - await AssertTestProjectRunAsync(project.TestsProjectDirectory, _testType, _testOutput, config); - } -} - -// Individual class for each test framework so the tests can run in separate helix jobs -public class None_StarterTemplateProjectNamesTests : StarterTemplateProjectNamesTests -{ - public None_StarterTemplateProjectNamesTests(ITestOutputHelper testOutput) : base("none", testOutput) - { - } -} - -public class MSTest_StarterTemplateProjectNamesTests : StarterTemplateProjectNamesTests -{ - public MSTest_StarterTemplateProjectNamesTests(ITestOutputHelper testOutput) : base("mstest", testOutput) - { - } -} - -public class Xunit_StarterTemplateProjectNamesTests : StarterTemplateProjectNamesTests -{ - public Xunit_StarterTemplateProjectNamesTests(ITestOutputHelper testOutput) : base("xunit.net", testOutput) - { - } -} - -public class Nunit_StarterTemplateProjectNamesTests : StarterTemplateProjectNamesTests -{ - public Nunit_StarterTemplateProjectNamesTests(ITestOutputHelper testOutput) : base("nunit", testOutput) - { - } -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateRunTests.cs b/tests/Aspire.Templates.Tests/StarterTemplateRunTests.cs deleted file mode 100644 index b618998acc2..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateRunTests.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Xunit; - -namespace Aspire.Templates.Tests; - -[RequiresFeature(TestFeature.SSLCertificate)] -public class StarterTemplateRunTests : StarterTemplateRunTestsBase -{ - public StarterTemplateRunTests(StarterTemplateFixture fixture, ITestOutputHelper testOutput) - : base(fixture, testOutput) - { - } -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateRunTestsBase.cs b/tests/Aspire.Templates.Tests/StarterTemplateRunTestsBase.cs deleted file mode 100644 index f58790c30ea..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateRunTestsBase.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Playwright; -using static Microsoft.Playwright.Assertions; -using Xunit; -using Aspire.Hosting.Redis; -using System.Net.Http.Json; -using Aspire.TestUtilities; - -namespace Aspire.Templates.Tests; - -public abstract class StarterTemplateRunTestsBase : TemplateTestsBase, IClassFixture where T : TemplateAppFixture -{ - protected readonly T _testFixture; - protected bool HasRedisCache; - protected virtual int DashboardResourcesWaitTimeoutSecs => 120; - - public StarterTemplateRunTestsBase(T fixture, ITestOutputHelper testOutput) - : base(testOutput) - { - _testFixture = fixture; - } - - [Fact] - [RequiresFeature(TestFeature.Playwright)] - [OuterloopTest("Resource-intensive Playwright browser test")] - public async Task ResourcesShowUpOnDashboard() - { - await using var context = await CreateNewBrowserContextAsync(); - await CheckDashboardHasResourcesAsync( - await _testFixture.Project!.OpenDashboardPageAsync(context), - GetExpectedResources(_testFixture.Project!, hasRedisCache: HasRedisCache), - timeoutSecs: DashboardResourcesWaitTimeoutSecs, - logPath: _testFixture.Project.LogPath); - } - - [Theory] - [InlineData("http://")] - [InlineData("https://")] - [RequiresFeature(TestFeature.Playwright)] - [OuterloopTest("Resource-intensive Playwright browser test")] - public async Task WebFrontendWorks(string urlPrefix) - { - await using var context = await CreateNewBrowserContextAsync(); - var resourceRows = await CheckDashboardHasResourcesAsync( - await _testFixture.Project!.OpenDashboardPageAsync(context), - GetExpectedResources(_testFixture.Project!, hasRedisCache: HasRedisCache), - timeoutSecs: DashboardResourcesWaitTimeoutSecs, - logPath: _testFixture.Project.LogPath); - - string url = _testFixture.Project.InfoTable["webfrontend"].Endpoints - .First(e => e.Uri.StartsWith(urlPrefix)) - .Uri; - await CheckWebFrontendWorksAsync(context, url, _testOutput, _testFixture.Project.LogPath, hasRedisCache: HasRedisCache); - } - - [Theory] - [InlineData("http://")] - [InlineData("https://")] - [RequiresFeature(TestFeature.Playwright)] - [OuterloopTest("Resource-intensive Playwright browser test")] - [Trait("category", "basic-build")] - public async Task ApiServiceWorks(string urlPrefix) - { - await using var context = await CreateNewBrowserContextAsync(); - var resourceRows = await CheckDashboardHasResourcesAsync( - await _testFixture.Project!.OpenDashboardPageAsync(context), - GetExpectedResources(_testFixture.Project!, hasRedisCache: HasRedisCache), - timeoutSecs: DashboardResourcesWaitTimeoutSecs, - logPath: _testFixture.Project.LogPath); - - string url = _testFixture.Project.InfoTable["apiservice"].Endpoints - .First(e => e.Uri.StartsWith(urlPrefix)) - .Uri; - await CheckApiServiceWorksAsync(url, _testOutput, _testFixture.Project.LogPath); - } - - public static async Task CheckApiServiceWorksAsync(string url, ITestOutputHelper testOutput, string logPath) - { - var uri = new UriBuilder(url) { Path = "weatherforecast" }.Uri; - - using var httpClient = new HttpClient(); - var response = await httpClient.GetFromJsonAsync(uri); - - Assert.NotNull(response); - Assert.Equal(5, response.Length); - } - - public static async Task CheckWebFrontendWorksAsync(IBrowserContext context, string url, ITestOutputHelper testOutput, string logPath, bool hasRedisCache = false) - { - var pageWrapper = await context.NewPageWithLoggingAsync(testOutput); - - try - { - // Enabling routing disables the http cache - await pageWrapper.Page.RouteAsync("**", async route => await route.ContinueAsync()); - await pageWrapper.Page.GotoAsync(url); - - await pageWrapper.Page.GetByRole(AriaRole.Link, new PageGetByRoleOptions { Name = "Weather" }).ClickAsync(); - - var tableLoc = pageWrapper.Page.Locator("//table[//thead/tr/th/text()='Date']"); - await Expect(tableLoc).ToBeVisibleAsync(); - - if (hasRedisCache) - { - // Compare weather data after refreshes - var firstLoadText = string.Join(',', (await GetAndValidateCellTexts(tableLoc)).SelectMany(r => r)); - await Task.Delay(10_000); - - await pageWrapper.Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.Load }); - - var secondLoadText = string.Join(',', (await GetAndValidateCellTexts(tableLoc)).SelectMany(r => r)); - Assert.NotEqual(firstLoadText, secondLoadText); - } - } - catch (Exception ex) - { - testOutput.WriteLine($"Error: {ex}"); - string screenshotPath = Path.Combine(logPath, "webfrontend-fail.png"); - await pageWrapper.Page.ScreenshotAsync(new PageScreenshotOptions { Path = screenshotPath }); - throw; - } - - static async Task> GetAndValidateCellTexts(ILocator tableLoc) - { - List cellTexts = []; - var rowsLoc = tableLoc.Locator("//tbody/tr"); - foreach (var row in await rowsLoc.AllAsync()) - { - var texts = (await row.Locator("//td").AllAsync()) - .Select(cell => cell.InnerHTMLAsync()) - .Select(t => t.Result) - .ToArray(); - cellTexts.Add(texts); - } - - foreach (var row in cellTexts) - { - Assert.Collection(row, - r => Assert.True(DateTime.TryParse(r, out _)), - r => Assert.True(int.TryParse(r, out var actualTempC) && actualTempC >= -20 && actualTempC <= 55), - r => Assert.True(int.TryParse(r, out var actualTempF) && actualTempF >= -5 && actualTempF <= 133), - r => Assert.Contains(r, new HashSet { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" })); - } - - return cellTexts; - } - } - - public static List GetExpectedResources(AspireProject project, bool hasRedisCache) - { - var expectedResources = new List - { - new(Type: "Project", - Name: "apiservice", - State: "Running", - SourceContains: $"{project.Id}.ApiService.csproj"), - - new(Type: "Project", - Name: "webfrontend", - State: "Running", - SourceContains: $"{project.Id}.Web.csproj") - }; - - if (hasRedisCache) - { - expectedResources.Add( - new ResourceRow(Type: "Container", - Name: "cache", - State: "Running", - SourceContains: $"{RedisContainerImageTags.Registry}/{RedisContainerImageTags.Image}:{RedisContainerImageTags.Tag}")); - } - - return expectedResources; - } -} - -public sealed record ResourceRow(string Type, string Name, string State, string SourceContains); - -public sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateRunTests_PreviousTFM.cs b/tests/Aspire.Templates.Tests/StarterTemplateRunTests_PreviousTFM.cs deleted file mode 100644 index 8536b15ff8e..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateRunTests_PreviousTFM.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Xunit; - -namespace Aspire.Templates.Tests; - -[RequiresFeature(TestFeature.SSLCertificate)] -public class StarterTemplateRunTests_PreviousTFM : StarterTemplateRunTestsBase -{ - public StarterTemplateRunTests_PreviousTFM(StarterTemplateFixture_PreviousTFM fixture, ITestOutputHelper testOutput) - : base(fixture, testOutput) - { - } -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheFixture.cs b/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheFixture.cs deleted file mode 100644 index e079b43ec01..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheFixture.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit.Sdk; - -namespace Aspire.Templates.Tests; - -/// -/// This fixture runs a aspire-starter template created with --use-redis-cache -/// -public sealed class StarterTemplateWithRedisCacheFixture : TemplateAppFixture -{ - public StarterTemplateWithRedisCacheFixture(IMessageSink diagnosticMessageSink) - : base(diagnosticMessageSink, "aspire-starter", "--use-redis-cache") - { - } -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheFixture_PreviousTFM.cs b/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheFixture_PreviousTFM.cs deleted file mode 100644 index 7d24bcaa6a2..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheFixture_PreviousTFM.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit.Sdk; - -namespace Aspire.Templates.Tests; - -public sealed class StarterTemplateWithRedisCacheFixture_PreviousTFM : TemplateAppFixture -{ - public StarterTemplateWithRedisCacheFixture_PreviousTFM(IMessageSink diagnosticMessageSink) - : base(diagnosticMessageSink, "aspire-starter", "--use-redis-cache", tfm: TestTargetFramework.Previous) - { - } -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheTests.cs b/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheTests.cs deleted file mode 100644 index c1c25a06ac1..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Xunit; - -namespace Aspire.Templates.Tests; - -[RequiresFeature(TestFeature.Docker)] -[RequiresFeature(TestFeature.SSLCertificate)] -public class StarterTemplateWithRedisCacheTests : StarterTemplateRunTestsBase -{ - protected override int DashboardResourcesWaitTimeoutSecs => 300; - - public StarterTemplateWithRedisCacheTests(StarterTemplateWithRedisCacheFixture fixture, ITestOutputHelper testOutput) - : base(fixture, testOutput) - { - HasRedisCache = true; - } -} diff --git a/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheTests_PreviousTFM.cs b/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheTests_PreviousTFM.cs deleted file mode 100644 index 30feb8c5218..00000000000 --- a/tests/Aspire.Templates.Tests/StarterTemplateWithRedisCacheTests_PreviousTFM.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Xunit; - -namespace Aspire.Templates.Tests; - -[RequiresFeature(TestFeature.Docker)] -[RequiresFeature(TestFeature.SSLCertificate)] -public class StarterTemplateWithRedisCacheTests_PreviousTFM : StarterTemplateRunTestsBase -{ - protected override int DashboardResourcesWaitTimeoutSecs => 300; - - public StarterTemplateWithRedisCacheTests_PreviousTFM(StarterTemplateWithRedisCacheFixture_PreviousTFM fixture, ITestOutputHelper testOutput) - : base(fixture, testOutput) - { - HasRedisCache = true; - } -} diff --git a/tests/Aspire.Templates.Tests/TemplateAppFixture.cs b/tests/Aspire.Templates.Tests/TemplateAppFixture.cs deleted file mode 100644 index 62fd10ec859..00000000000 --- a/tests/Aspire.Templates.Tests/TemplateAppFixture.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; -using Xunit.Sdk; - -namespace Aspire.Templates.Tests; - -/// -/// This fixture runs a project created from a given template -/// -public class TemplateAppFixture : IAsyncLifetime -{ - private readonly IMessageSink _diagnosticMessageSink; - private readonly TestOutputWrapper _testOutput; - private readonly string _dotnetNewArgs; - private readonly string _config; - private readonly TestTargetFramework? _tfm; - - public AspireProject? Project { get; private set; } - - public string Id { get; init; } - public string TemplateName { get; set; } - - public TemplateAppFixture(IMessageSink diagnosticMessageSink, string templateName, string? dotnetNewArgs = null, string config = "Debug", TestTargetFramework tfm = TestTargetFramework.Current) - { - _diagnosticMessageSink = diagnosticMessageSink; - _testOutput = new TestOutputWrapper(messageSink: _diagnosticMessageSink); - _dotnetNewArgs = dotnetNewArgs ?? string.Empty; - _config = config; - Id = TemplateTestsBase.GetNewProjectId(prefix: $"{templateName}_{tfm.ToTFMString()}"); - TemplateName = templateName; - _tfm = tfm; - } - - public async ValueTask InitializeAsync() - { - Project = await AspireProject.CreateNewTemplateProjectAsync( - Id, - TemplateName, - _testOutput, - extraArgs: _dotnetNewArgs, - buildEnvironment: BuildEnvironment.ForDefaultFramework, - targetFramework: _tfm); - - await Project.BuildAsync(extraBuildArgs: [$"-c {_config}"]); - await Project.StartAppHostAsync(extraArgs: [$"-c {_config}"]); - } - - public async ValueTask DisposeAsync() - { - if (Project is not null) - { - await Project.DisposeAsync(); - } - } - - public void EnsureAppHostRunning() - { - if (Project!.AppHostProcess is null || Project.AppHostProcess.HasExited || Project.AppExited?.Task.IsCompleted == true) - { - throw new InvalidOperationException($"The app host process is not running. {Project.AppHostProcess?.HasExited}, {Project.AppExited?.Task.IsCompleted}"); - } - } -} diff --git a/tests/Aspire.Templates.Tests/TemplateTestsBase.cs b/tests/Aspire.Templates.Tests/TemplateTestsBase.cs deleted file mode 100644 index fa0454a3186..00000000000 --- a/tests/Aspire.Templates.Tests/TemplateTestsBase.cs +++ /dev/null @@ -1,402 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text; -using System.Text.RegularExpressions; -using System.Xml; -using Aspire.TestUtilities; -using Microsoft.Playwright; -using Xunit; -using static Aspire.Templates.Tests.TestExtensions; - -namespace Aspire.Templates.Tests; - -public partial class TemplateTestsBase -{ - [GeneratedRegex(@"^\s*//")] - private static partial Regex CommentLineRegex(); - - // Regex is from src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets - _GeneratedClassNameFixupRegex - [GeneratedRegex(@"(((?<=\.)|^)(?=\d)|\W)")] - private static partial Regex GeneratedClassNameFixupRegex(); - private static Lazy Browser => new(CreateBrowser); - private static readonly XmlWriterSettings s_xmlWriterSettings = new() { ConformanceLevel = ConformanceLevel.Fragment }; - protected readonly TestOutputWrapper _testOutput; - - public static readonly string[] TestFrameworkTypes = ["none", "mstest", "nunit", "xunit.net"]; - - public TemplateTestsBase(ITestOutputHelper testOutput) - { - _testOutput = new TestOutputWrapper(testOutput); - } - - private static IBrowser CreateBrowser() - { - var t = Task.Run(async () => await PlaywrightProvider.CreateBrowserAsync()); - - // default timeout for playwright.Chromium.LaunchAsync is 30secs, - // so using a timeout here as a fallback - if (!t.Wait(45 * 1000)) - { - throw new TimeoutException("Browser creation timed out"); - } - - return t.Result; - } - - public async Task CreateAndAddTestTemplateProjectAsync( - string id, - string testTemplateName, - AspireProject project, - TestTargetFramework? tfm = null, - BuildEnvironment? buildEnvironment = null, - string? extraArgs = null, - Func? onBuildAspireProject = null, - string? overrideRootDir = null) - { - buildEnvironment ??= BuildEnvironment.ForDefaultFramework; - var tmfArg = tfm is not null ? $"-f {tfm.Value.ToTFMString()}" : ""; - - string rootDirToUse = overrideRootDir ?? project.RootDir; - // Add test project - var testProjectName = $"{id}.{FixupSymbolName(testTemplateName)}Tests"; - using var newTestCmd = new DotNetNewCommand( - _testOutput, - label: $"new-test-{testTemplateName}", - buildEnv: buildEnvironment) - .WithWorkingDirectory(rootDirToUse); - var res = await newTestCmd.ExecuteAsync($"{testTemplateName} {tmfArg} -o \"{testProjectName}\" {extraArgs}"); - res.EnsureSuccessful(); - - var testProjectDir = Path.Combine(rootDirToUse, testProjectName); - Assert.True(Directory.Exists(testProjectDir), $"Expected tests project at {testProjectDir}"); - - var testProjectPath = Path.Combine(testProjectDir, testProjectName + ".csproj"); - Assert.True(File.Exists(testProjectPath), $"Expected tests project file at {testProjectPath}"); - - var appHostProjectName = Path.GetFileName(project.AppHostProjectDirectory)!; - PrepareTestCsFile( - id: project.Id, - projectDir: testProjectDir, - appHostProjectName: appHostProjectName, - testTemplateName: testTemplateName); - PrepareTestProject( - project: project, - projectPath: testProjectPath, - appHostProjectName: appHostProjectName); - - return testProjectDir; - - static void PrepareTestProject(AspireProject project, string projectPath, string appHostProjectName) - { - // Insert in the project file - - // taken from https://raw.githubusercontent.com/dotnet/templating/a325ffa18edd1590f9b340cf83d51d8eb567ebdc/src/Microsoft.TemplateEngine.Orchestrator.RunnableProjects/ValueForms/XmlEncodeValueFormFactory.cs - StringBuilder output = new(); - using (var w = XmlWriter.Create(output, s_xmlWriterSettings)) - { - w.WriteString(appHostProjectName); - } - var xmlEncodedId = output.ToString(); - - var projectReference = $@""; - - var newContents = File.ReadAllText(projectPath) - .Replace("", $"{projectReference}\n"); - File.WriteAllText(projectPath, newContents); - } - - static void PrepareTestCsFile(string id, string projectDir, string appHostProjectName, string testTemplateName) - { - var testCsPath = Path.Combine(projectDir, "IntegrationTest1.cs"); - var sb = new StringBuilder(); - - // Uncomment everything after the marker line - var inTest = false; - var marker = testTemplateName switch - { - "aspire-nunit" or "aspire-nunit-9" => "// [Test]", - "aspire-mstest" or "aspire-mstest-9" => "// [TestMethod]", - "aspire-xunit" or "aspire-xunit-9" => "// [Fact]", - _ => throw new NotImplementedException($"Unknown test template: {testTemplateName}") - }; - - foreach (var line in File.ReadAllLines(testCsPath)) - { - if (!inTest && line.Contains(marker)) - { - inTest = true; - } - - if (inTest && CommentLineRegex().IsMatch(line)) - { - sb.AppendLine(CommentLineRegex().Replace(line, " ")); - continue; - } - - sb.AppendLine(line); - } - - var classNameFromId = GeneratedClassNameFixupRegex().Replace(appHostProjectName, "_"); - sb.Replace("Projects.MyAspireApp_AppHost", $"Projects.{classNameFromId}"); - File.WriteAllText(testCsPath, sb.ToString()); - } - } - - public static Task CreateNewBrowserContextAsync() - => PlaywrightProvider.HasPlaywrightSupport - ? Browser.Value.NewContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true }) - : throw new InvalidOperationException("Playwright is not available"); - - protected Task CheckDashboardHasResourcesAsync(WrapperForIPage dashboardPageWrapper, IEnumerable expectedResources, string logPath, int timeoutSecs = 120) - => CheckDashboardHasResourcesAsync(dashboardPageWrapper, expectedResources, _testOutput, logPath, timeoutSecs); - - protected static async Task CheckDashboardHasResourcesAsync(WrapperForIPage dashboardPageWrapper, - IEnumerable expectedResources, - ITestOutputHelper testOutput, - string logPath, - int timeoutSecs = 120) - { - try - { - return await CheckDashboardHasResourcesActualAsync(dashboardPageWrapper, expectedResources, testOutput, timeoutSecs); - } - catch - { - string screenshotPath = Path.Combine(logPath, $"dashboard-fail-{Guid.NewGuid().ToString()[..8]}.png"); - await dashboardPageWrapper.Page.ScreenshotAsync(new PageScreenshotOptions { Path = screenshotPath }); - testOutput.WriteLine($"Dashboard screenshot saved to {screenshotPath}"); - throw; - } - } - - private static async Task CheckDashboardHasResourcesActualAsync(WrapperForIPage dashboardPageWrapper, IEnumerable expectedResources, ITestOutputHelper testOutput, int timeoutSecs = 120) - { - // FIXME: check the page has 'Resources' label - // fluent-toolbar/h1 resources - - var numAttempts = 0; - testOutput.WriteLine($"Waiting for resources to appear on the dashboard"); - await Task.Delay(500); - - var expectedRowsTable = expectedResources.ToDictionary(r => r.Name); - HashSet foundNames = []; - List foundRows = []; - - var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromSeconds(timeoutSecs)); - - while (foundNames.Count < expectedRowsTable.Count && !cts.IsCancellationRequested) - { - if (dashboardPageWrapper.HasErrors) - { - if (numAttempts >= 3) - { - throw new InvalidOperationException($"Failed to load dashboard page after {numAttempts} attempts"); - } - - testOutput.WriteLine($"----- Reloading dashboard page"); - await dashboardPageWrapper.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.Load }); - numAttempts++; - } - - await Task.Delay(500); - - // _testOutput.WriteLine($"Checking for rows again"); - var rowsLocator = dashboardPageWrapper.Page.Locator("//tr[@class='fluent-data-grid-row hover resource-row']"); - var allRows = await rowsLocator.AllAsync(); - // _testOutput.WriteLine($"found rows#: {allRows.Count}"); - if (allRows.Count == 0) - { - // Console.WriteLine ($"** no rows found ** elapsed: {sw.Elapsed.TotalSeconds} secs"); - continue; - } - - foreach (var rowLoc in allRows) - { - // get the cells - var cellLocs = await rowLoc.Locator("//td[@role='gridcell']").AllAsync(); - - // is the resource name expected? - var resourceNameCell = cellLocs[0]; - var resourceName = await resourceNameCell.InnerTextAsync(); - resourceName = resourceName.Trim(); - if (!expectedRowsTable.TryGetValue(resourceName, out var expectedRow)) - { - Assert.Fail($"Row with unknown name found: '{resourceName}'. Expected values: {string.Join(", ", expectedRowsTable.Keys.Select(k => $"'{k}'"))}"); - } - if (foundNames.Contains(resourceName)) - { - continue; - } - - AssertEqual(expectedRow.Name, resourceName, $"Name for '{resourceName}'"); - - var stateCell = cellLocs[1]; - var actualState = await stateCell.InnerTextAsync().ConfigureAwait(false); - actualState = actualState.Trim(); - if (expectedRow.State != actualState && actualState != "Finished" && !actualState.Contains("failed", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - AssertEqual(expectedRow.State, actualState, $"State for {resourceName}"); - - // Check 'Source' column - var sourceCell = cellLocs[3]; - // Since this will be the entire command, we can just confirm that the path of the executable contains - // the expected source (executable/project) - Assert.Contains(expectedRow.SourceContains, await sourceCell.InnerTextAsync()); - - foundRows.Add(expectedRow); - foundNames.Add(resourceName); - } - } - - if (foundNames.Count != expectedRowsTable.Count) - { - Assert.Fail($"Expected rows not found: {string.Join(", ", expectedRowsTable.Keys.Except(foundNames))}"); - } - - return foundRows.ToArray(); - } - - // Don't fixup the prefix so it can have characters meant for testing, like spaces - public static string GetNewProjectId(string? prefix = null) - => (prefix is null ? "" : $"{prefix}_") + FixupSymbolName(Path.GetRandomFileName()); - - public static IEnumerable GetProjectNamesForTest() - { - if (!PlatformDetection.IsRunningPRValidation && !EnvironmentVariables.RunOnlyBasicBuildTemplatesTests) - { - // Avoid running these cases on PR validation - - yield return "aspire_龦唉丂荳_㐁ᠭ_ᠤསྲིདخەلꌠ_1ᥕ项目1"; // sln should have UTF-8 byte order mark - yield return "aspire_starter.1period then.34letters"; - yield return "aspire-starter & with.1"; - - // ActiveIssue: https://github.com/dotnet/aspnetcore/issues/56277 - // yield return "aspire_😀"; - } - - // basic case - yield return "aspire"; - } - - public static async Task AssertStarterTemplateRunAsync(IBrowserContext? context, AspireProject project, string config, ITestOutputHelper _testOutput) - { - await project.StartAppHostAsync(extraArgs: [$"-c {config}"], noBuild: false); - - if (context is not null) - { - var page = await project.OpenDashboardPageAsync(context); - await CheckDashboardHasResourcesAsync( - page, - StarterTemplateRunTestsBase.GetExpectedResources(project, hasRedisCache: false), - _testOutput, - project.LogPath).ConfigureAwait(false); - - string apiServiceUrl = project.InfoTable["apiservice"].Endpoints[0].Uri; - await StarterTemplateRunTestsBase.CheckApiServiceWorksAsync(apiServiceUrl, _testOutput, project.LogPath); - - string webFrontEnd = project.InfoTable["webfrontend"].Endpoints[0].Uri; - await StarterTemplateRunTestsBase.CheckWebFrontendWorksAsync(context, webFrontEnd, _testOutput, project.LogPath); - } - else - { - _testOutput.WriteLine($"Skipping playwright part of the test"); - } - - await project.StopAppHostAsync(); - } - - public static async Task AssertTestProjectRunAsync(string testProjectDirectory, string testType, ITestOutputHelper testOutput, string config = "Debug", int testRunTimeoutSecs = 3 * 60) - { - if (testType == "none") - { - Assert.False(Directory.Exists(testProjectDirectory), "Expected no tests project to be created"); - return null; - } - else - { - Assert.True(Directory.Exists(testProjectDirectory), $"Expected tests project at {testProjectDirectory}"); - - // Build first, because `dotnet test` does not show test results if all the tests pass - using var buildCmd = new DotNetCommand(testOutput, label: $"test-{testType}") - .WithWorkingDirectory(testProjectDirectory) - .WithTimeout(TimeSpan.FromSeconds(testRunTimeoutSecs)); - - (await buildCmd.ExecuteAsync($"test -c {config}")).EnsureSuccessful(); - - // .. then test with --no-build - using var testCmd = new DotNetCommand(testOutput, label: $"test-{testType}") - .WithWorkingDirectory(testProjectDirectory) - .WithTimeout(TimeSpan.FromSeconds(testRunTimeoutSecs)); - - var testRes = (await testCmd.ExecuteAsync($"test -c {config} --no-build")) - .EnsureSuccessful(); - - Assert.Matches("Passed! * - Failed: *0, Passed: *1, Skipped: *0, Total: *1", testRes.Output); - return testRes; - } - } - - public static TheoryData TestDataForNewAndBuildTemplateTests(string templateName, string extraArgs) => new() - { - // Previous Sdk, Previous TFM - { templateName, extraArgs, TestSdk.Previous, TestTargetFramework.Previous, null }, - // Previous Sdk - Current TFM - { templateName, extraArgs, TestSdk.Previous, TestTargetFramework.Current, "The current .NET SDK does not support targeting .NET 9.0" }, - - // Current SDK, Previous TFM - { templateName, extraArgs, TestSdk.Current, TestTargetFramework.Previous, null }, - // Current SDK, Current TFM - { templateName, extraArgs, TestSdk.Current, TestTargetFramework.Current, null }, - // Current SDK, Next TFM - { templateName, extraArgs, TestSdk.Current, TestTargetFramework.Next, "The current .NET SDK does not support targeting .NET 10.0" }, - - // Next SDK, Previous TFM - { templateName, extraArgs, TestSdk.Next, TestTargetFramework.Previous, null }, - // Next SDK, Current TFM - { templateName, extraArgs, TestSdk.Next, TestTargetFramework.Current, null }, - // Next SDK, Next TFM - { templateName, extraArgs, TestSdk.Next, TestTargetFramework.Next, null }, - - // Current SDK + previous runtime, Previous TFM - { templateName, extraArgs, TestSdk.NextSdkWithCurrentAndPreviousRuntime, TestTargetFramework.Previous, null }, - // Current SDK + previous runtime, Current TFM - { templateName, extraArgs, TestSdk.NextSdkWithCurrentAndPreviousRuntime, TestTargetFramework.Current, null }, - // Next SDK + current runtime, Next TFM - { templateName, extraArgs, TestSdk.NextSdkWithCurrentAndPreviousRuntime, TestTargetFramework.Next, null }, - }; - - // Taken from dotnet/runtime src/tasks/Common/Utils.cs - private static readonly char[] s_charsToReplace = ['.', '-', '+', '<', '>']; - public static string FixupSymbolName(string name) - { - UTF8Encoding utf8 = new(); - byte[] bytes = utf8.GetBytes(name); - StringBuilder sb = new(); - - foreach (byte b in bytes) - { - if ((b >= (byte)'0' && b <= (byte)'9') || - (b >= (byte)'a' && b <= (byte)'z') || - (b >= (byte)'A' && b <= (byte)'Z') || - (b == (byte)'_')) - { - sb.Append((char)b); - } - else if (s_charsToReplace.Contains((char)b)) - { - sb.Append('_'); - } - else - { - sb.Append($"_{b:X}_"); - } - } - - return sb.ToString(); - } - -} diff --git a/tests/Aspire.Templates.Tests/TestSdk.cs b/tests/Aspire.Templates.Tests/TestSdk.cs deleted file mode 100644 index 874fdac76ec..00000000000 --- a/tests/Aspire.Templates.Tests/TestSdk.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Templates.Tests; - -public enum TestSdk -{ - Previous, - Current, - Next, - NextSdkWithCurrentAndPreviousRuntime -} diff --git a/tests/Aspire.Templates.Tests/xunit.runner.json b/tests/Aspire.Templates.Tests/xunit.runner.json deleted file mode 100644 index 6a881a7a913..00000000000 --- a/tests/Aspire.Templates.Tests/xunit.runner.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "diagnosticMessages": true, - "longRunningTestSeconds": 120, - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index efb884f8ee9..a961aa89b8d 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -6,7 +6,6 @@ $([MSBuild]::NormalizeDirectory($(ArtifactsDir), 'helix', 'tests')) - $([MSBuild]::NormalizeDirectory($(ArtifactsDir), 'helix', 'templates-tests')) $([MSBuild]::NormalizeDirectory($(ArtifactsDir), 'helix', 'e2e-tests')) $([MSBuild]::NormalizeDirectory($(ArtifactsDir), 'helix', 'build-on-helix-tests')) $([MSBuild]::NormalizeDirectory($(ArtifactsDir), 'helix', 'cli-e2e-tests')) @@ -22,12 +21,6 @@ false - - - - $(ArtifactsShippingPackagesDir) - - diff --git a/tests/Infrastructure.Tests/PowerShellScripts/BuildTestMatrixTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/BuildTestMatrixTests.cs index f7abe073564..a522dd675d7 100644 --- a/tests/Infrastructure.Tests/PowerShellScripts/BuildTestMatrixTests.cs +++ b/tests/Infrastructure.Tests/PowerShellScripts/BuildTestMatrixTests.cs @@ -342,33 +342,6 @@ public async Task UsesUncollectedMtpBaseArgsForUncollectedEntry() Assert.Equal("--hangdump-timeout 20m --timeout 45m", uncollectedEntry.MtpBaseArgs); } - [Fact] - [RequiresTools(["pwsh"])] - public async Task PassesRequiresTestSdkProperty() - { - // Arrange - var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); - Directory.CreateDirectory(artifactsDir); - - TestDataBuilder.CreateTestsMetadataJson( - Path.Combine(artifactsDir, "SdkProject.tests-metadata.json"), - projectName: "SdkProject", - testProjectPath: "tests/SdkProject/SdkProject.csproj", - requiresTestSdk: true); - - var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); - - // Act - var result = await RunScript(artifactsDir, outputFile); - - // Assert - result.EnsureSuccessful(); - - var matrix = ParseCanonicalMatrix(outputFile); - var entry = Assert.Single(matrix.Tests); - Assert.True(entry.Properties.GetValueOrDefault("requiresTestSdk")); - } - [Fact] [RequiresTools(["pwsh"])] public async Task PreservesSupportedOSes() diff --git a/tests/Infrastructure.Tests/Shared/TestDataBuilder.cs b/tests/Infrastructure.Tests/Shared/TestDataBuilder.cs index 1b58f4bd335..9e7705ccad0 100644 --- a/tests/Infrastructure.Tests/Shared/TestDataBuilder.cs +++ b/tests/Infrastructure.Tests/Shared/TestDataBuilder.cs @@ -27,7 +27,6 @@ public static string CreateTestsMetadataJson( string? shortName = null, string? mtpBaseArgs = null, bool requiresNugets = false, - bool requiresTestSdk = false, bool requiresCliArchive = false, bool enablePlaywrightInstall = false, string? extraTestArgs = null, @@ -44,7 +43,6 @@ public static string CreateTestsMetadataJson( Properties = new Dictionary { ["requiresNugets"] = requiresNugets, - ["requiresTestSdk"] = requiresTestSdk, ["requiresCliArchive"] = requiresCliArchive, ["enablePlaywrightInstall"] = enablePlaywrightInstall }, @@ -74,7 +72,6 @@ public static string CreateSplitTestsMetadataJson( string? mtpBaseArgs = null, string? uncollectedMtpBaseArgs = null, bool requiresNugets = false, - bool requiresTestSdk = false, bool requiresCliArchive = false, bool enablePlaywrightInstall = false, string[]? supportedOSes = null, @@ -91,7 +88,6 @@ public static string CreateSplitTestsMetadataJson( Properties = new Dictionary { ["requiresNugets"] = requiresNugets, - ["requiresTestSdk"] = requiresTestSdk, ["requiresCliArchive"] = requiresCliArchive, ["enablePlaywrightInstall"] = enablePlaywrightInstall }, @@ -190,7 +186,6 @@ public static CanonicalMatrixEntry CreateMatrixEntry( string? extraTestArgs = null, string mtpBaseArgs = "", bool requiresNugets = false, - bool requiresTestSdk = false, bool requiresCliArchive = false, bool enablePlaywrightInstall = false, string[]? supportedOSes = null, @@ -211,7 +206,6 @@ public static CanonicalMatrixEntry CreateMatrixEntry( Properties = new Dictionary { ["requiresNugets"] = requiresNugets, - ["requiresTestSdk"] = requiresTestSdk, ["requiresCliArchive"] = requiresCliArchive, ["enablePlaywrightInstall"] = enablePlaywrightInstall }, diff --git a/tests/Infrastructure.Tests/WorkflowScripts/AutoRerunTransientCiFailuresTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/AutoRerunTransientCiFailuresTests.cs index 8ddbefc4b7b..f43730217a6 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/AutoRerunTransientCiFailuresTests.cs +++ b/tests/Infrastructure.Tests/WorkflowScripts/AutoRerunTransientCiFailuresTests.cs @@ -735,7 +735,6 @@ public async Task RepresentativeWorkflowFixturesStayAlignedWithCurrentWorkflowDe [ "- name: Checkout code", "- name: Set up .NET Core", - "- name: Install sdk for nuget based testing", "- name: Build test project", "- name: Run tests (Windows)", "- name: Upload logs, and test results", diff --git a/tests/README.md b/tests/README.md index f6b3c93e1a1..ca76486e056 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,14 +3,7 @@ The Helix CI job builds `tests/helix/send-to-helix-ci.proj`, which in turns builds the `Test` target on `tests/helix/send-to-helix-inner.proj`. This inner project uses the Helix SDK to construct `@(HelixWorkItem)`s, and send them to Helix to run. - `tests/helix/send-to-helix-basictests.targets` - this prepares all the tests that don't need special preparation -- `tests/helix/send-to-helix-endtoend-tests.targets` - this is for tests that require a SDK installed - -## Install SDK from artifacts - -1. `.\build.cmd -pack` -2. `dotnet build tests\workloads.proj` - -.. which results in `artifacts\bin\dotnet-tests` which has a SDK (version from `global.json`) with the necessary components installed using packs from `artifacts/packages`. +- `tests/helix/send-to-helix-endtoendtests.targets` - this is for end-to-end scenario tests that require Docker ## Controlling test runs on CI @@ -29,6 +22,6 @@ Individual test projects can be opted-out by setting appropriate MSBuild propert - Use `--filter-method`, `--filter-class`, or `--filter-namespace` (after `--`) to run specific tests. - Set `TestCaptureOutput=false` as an environment variable to see the output on the command line. -- Use `-tl:false` to disable msbuild's terminal logger so live output can be seen. +- Set `MSBUILDTERMINALLOGGER=false` to disable MSBuild's terminal logger so live output can be seen. -Example: `dotnet test --project tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj --no-launch-profile -tl:false -- --filter-class "*.NewUpAndBuildStandaloneTemplateTests"` +Example: `MSBUILDTERMINALLOGGER=false dotnet test --project tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj --no-launch-profile -- --filter-class "*.TemplateVariantSmokeTests"` diff --git a/tests/Shared/Aspire.Templates.Testing.props b/tests/Shared/Aspire.Templates.Testing.props deleted file mode 100644 index 81eff702fb6..00000000000 --- a/tests/Shared/Aspire.Templates.Testing.props +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/tests/Shared/Aspire.Templates.Testing.targets b/tests/Shared/Aspire.Templates.Testing.targets deleted file mode 100644 index 906d4a3b609..00000000000 --- a/tests/Shared/Aspire.Templates.Testing.targets +++ /dev/null @@ -1,146 +0,0 @@ - - - - $(DotNetSdkCurrentVersionForTesting) - $(DotNetSdkPreviousVersionForTesting) - - - <_GlobalJsonContent>$([System.IO.File]::ReadAllText('$(RepoRoot)global.json')) - <_DotNetCliVersionFromGlobalJson>$([System.Text.RegularExpressions.Regex]::Match($(_GlobalJsonContent), '(%3F<="dotnet": ").*(%3F=")')) - $(_DotNetCliVersionFromGlobalJson) - - $(ArtifactsBinDir)dotnet-9\ - $(SdkDirForCurrentTFM).version-$(SdkVersionForCurrentTFM).stamp - - $(ArtifactsBinDir)dotnet-tests\ - $(SdkDirForCurrentAndPreviousTFM).version-$(SdkVersionForCurrentTFM)-$(SdkVersionForPreviousTFM).stamp - - $(ArtifactsBinDir)dotnet-8\ - $(SdkDirForPreviousTFM).version-$(SdkVersionForPreviousTFM).stamp - - $(ArtifactsBinDir)dotnet-10\ - $(SdkDirForNextTFM).version-$(SdkVersionForNextTFM).stamp - - $(ArtifactsBinDir)dotnet-tests\ - $(SdkDirForNextWithCurrentAndPreviousRuntimes).version-$(SdkVersionForNextTFM)-$(SdkVersionForCurrentTFM).stamp - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <_SdkDirToInstallTo>$(SdkDirForNextWithCurrentAndPreviousRuntimes) - - - - - - - - - <_SdkNextFile Include="$(SdkDirForNextTFM)\**\*" /> - - - - - - - - - - - - - - - - - - - <_SrcProjects Include="$(RepoRoot)src\**\*.csproj" - Exclude="$(RepoRoot)src\Aspire.ProjectTemplates\templates\**\*.csproj" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/Shared/Docker/Dockerfile.e2e b/tests/Shared/Docker/Dockerfile.e2e index ee66407869e..0aec0a87e2e 100644 --- a/tests/Shared/Docker/Dockerfile.e2e +++ b/tests/Shared/Docker/Dockerfile.e2e @@ -22,12 +22,14 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG SKIP_SOURCE_BUILD=false ARG UBUNTU_APT_MIRROR= +COPY tests/Shared/Docker/apt-retry.sh /usr/local/bin/apt-retry +RUN chmod +x /usr/local/bin/apt-retry + # Install native AOT build toolchain. COPY tests/Shared/Docker/configure-ubuntu-apt-mirror.sh /usr/local/bin/ RUN sh /usr/local/bin/configure-ubuntu-apt-mirror.sh "$UBUNTU_APT_MIRROR" RUN if [ "$SKIP_SOURCE_BUILD" != "true" ]; then \ - apt-get update -qq && \ - apt-get install -y --no-install-recommends clang zlib1g-dev && \ + apt-retry install clang zlib1g-dev && \ rm -rf /var/lib/apt/lists/*; \ fi @@ -54,39 +56,48 @@ ARG UBUNTU_APT_MIRROR= # --- Common tooling (shared between dotnet and polyglot variants) --- +COPY tests/Shared/Docker/apt-retry.sh /usr/local/bin/apt-retry +RUN chmod +x /usr/local/bin/apt-retry + # Install gh CLI (needed by get-aspire-cli-pr.sh to download PR artifacts). # Install Docker CLI plus the buildx/compose plugins used by publish and deploy flows. COPY tests/Shared/Docker/configure-ubuntu-apt-mirror.sh /usr/local/bin/ RUN sh /usr/local/bin/configure-ubuntu-apt-mirror.sh "$UBUNTU_APT_MIRROR" -RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends gpg docker.io docker-buildx docker-compose-v2 unzip && \ +RUN apt-retry install gpg docker.io docker-buildx docker-compose-v2 unzip && \ curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ > /etc/apt/sources.list.d/github-cli.list && \ - apt-get update -qq && \ - apt-get install -y --no-install-recommends gh && \ + apt-retry install gh && \ rm -rf /var/lib/apt/lists/* # Install Node.js plus supported TypeScript AppHost toolchains. RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y --no-install-recommends nodejs && \ + apt-retry install nodejs && \ npm install -g yarn@1.22.22 pnpm@10 && \ - curl -fsSL https://bun.sh/install | bash && \ + for attempt in 1 2 3 4 5; do \ + if curl -fsSL https://bun.sh/install -o /tmp/bun-install.sh && \ + bash /tmp/bun-install.sh && \ + [ -x /root/.bun/bin/bun ]; then \ + rm -f /tmp/bun-install.sh; \ + break; \ + fi; \ + rm -f /tmp/bun-install.sh; \ + if [ "$attempt" = "5" ]; then exit 1; fi; \ + sleep 10; \ + done && \ ln -s /root/.bun/bin/bun /usr/local/bin/bun && \ ln -s /root/.bun/bin/bunx /usr/local/bin/bunx && \ rm -rf /var/lib/apt/lists/* # Install Python and uv (needed for Python templates). -RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \ +RUN apt-retry install python3 python3-pip python3-venv && \ rm -rf /var/lib/apt/lists/* RUN curl -LsSf https://astral.sh/uv/install.sh | sh ENV PATH="/root/.local/bin:${PATH}" # Install Java (needed for Java AppHost tests). -RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends openjdk-21-jdk && \ +RUN apt-retry install openjdk-21-jdk && \ rm -rf /var/lib/apt/lists/* # --- Aspire CLI setup --- diff --git a/tests/Shared/Docker/Dockerfile.e2e-podman b/tests/Shared/Docker/Dockerfile.e2e-podman index 72e619f1dcd..919d49665c4 100644 --- a/tests/Shared/Docker/Dockerfile.e2e-podman +++ b/tests/Shared/Docker/Dockerfile.e2e-podman @@ -7,9 +7,11 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 ARG UBUNTU_APT_MIRROR= COPY tests/Shared/Docker/configure-ubuntu-apt-mirror.sh /usr/local/bin/ +COPY tests/Shared/Docker/apt-retry.sh /usr/local/bin/apt-retry +RUN chmod +x /usr/local/bin/apt-retry RUN sh /usr/local/bin/configure-ubuntu-apt-mirror.sh "$UBUNTU_APT_MIRROR" -RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends \ + +RUN apt-retry install \ curl \ ca-certificates \ gpg \ @@ -23,8 +25,7 @@ RUN apt-get update -qq && \ | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ > /etc/apt/sources.list.d/github-cli.list && \ - apt-get update -qq && \ - apt-get install -y --no-install-recommends gh && \ + apt-retry install gh && \ rm -rf /var/lib/apt/lists/* # Rootful Podman-in-container is reliable here when it uses a dedicated vfs-backed storage root. diff --git a/tests/Shared/Docker/Dockerfile.e2e-polyglot-base b/tests/Shared/Docker/Dockerfile.e2e-polyglot-base index a4a6e095aa3..6bc9ddb130c 100644 --- a/tests/Shared/Docker/Dockerfile.e2e-polyglot-base +++ b/tests/Shared/Docker/Dockerfile.e2e-polyglot-base @@ -23,12 +23,14 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG SKIP_SOURCE_BUILD=false ARG UBUNTU_APT_MIRROR= +COPY tests/Shared/Docker/apt-retry.sh /usr/local/bin/apt-retry +RUN chmod +x /usr/local/bin/apt-retry + # Install native AOT build toolchain. COPY tests/Shared/Docker/configure-ubuntu-apt-mirror.sh /usr/local/bin/ RUN sh /usr/local/bin/configure-ubuntu-apt-mirror.sh "$UBUNTU_APT_MIRROR" RUN if [ "$SKIP_SOURCE_BUILD" != "true" ]; then \ - apt-get update -qq && \ - apt-get install -y --no-install-recommends clang zlib1g-dev && \ + apt-retry install clang zlib1g-dev && \ rm -rf /var/lib/apt/lists/*; \ fi @@ -53,11 +55,13 @@ FROM ubuntu:24.04 AS runtime ARG UBUNTU_APT_MIRROR= -# Install base tools and Docker CLI. COPY tests/Shared/Docker/configure-ubuntu-apt-mirror.sh /usr/local/bin/ +COPY tests/Shared/Docker/apt-retry.sh /usr/local/bin/apt-retry +RUN chmod +x /usr/local/bin/apt-retry RUN sh /usr/local/bin/configure-ubuntu-apt-mirror.sh "$UBUNTU_APT_MIRROR" -RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends \ + +# Install base tools and Docker CLI. +RUN apt-retry install \ ca-certificates curl gpg docker.io git libicu-dev unzip && \ rm -rf /var/lib/apt/lists/* @@ -66,15 +70,24 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ > /etc/apt/sources.list.d/github-cli.list && \ - apt-get update -qq && \ - apt-get install -y --no-install-recommends gh && \ + apt-retry install gh && \ rm -rf /var/lib/apt/lists/* # Install Node.js plus supported TypeScript AppHost toolchains. RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y --no-install-recommends nodejs && \ + apt-retry install nodejs && \ npm install -g yarn@1.22.22 pnpm@10 && \ - curl -fsSL https://bun.sh/install | bash && \ + for attempt in 1 2 3 4 5; do \ + if curl -fsSL https://bun.sh/install -o /tmp/bun-install.sh && \ + bash /tmp/bun-install.sh && \ + [ -x /root/.bun/bin/bun ]; then \ + rm -f /tmp/bun-install.sh; \ + break; \ + fi; \ + rm -f /tmp/bun-install.sh; \ + if [ "$attempt" = "5" ]; then exit 1; fi; \ + sleep 10; \ + done && \ ln -s /root/.bun/bin/bun /usr/local/bin/bun && \ ln -s /root/.bun/bin/bunx /usr/local/bin/bunx && \ rm -rf /var/lib/apt/lists/* diff --git a/tests/Shared/Docker/Dockerfile.e2e-polyglot-java b/tests/Shared/Docker/Dockerfile.e2e-polyglot-java index eeb3edd7dc1..7c80f4fc33a 100644 --- a/tests/Shared/Docker/Dockerfile.e2e-polyglot-java +++ b/tests/Shared/Docker/Dockerfile.e2e-polyglot-java @@ -6,7 +6,9 @@ FROM aspire-e2e-polyglot-base ARG UBUNTU_APT_MIRROR= COPY tests/Shared/Docker/configure-ubuntu-apt-mirror.sh /usr/local/bin/ +COPY tests/Shared/Docker/apt-retry.sh /usr/local/bin/apt-retry +RUN chmod +x /usr/local/bin/apt-retry RUN sh /usr/local/bin/configure-ubuntu-apt-mirror.sh "$UBUNTU_APT_MIRROR" -RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends openjdk-25-jdk && \ + +RUN apt-retry install openjdk-25-jdk && \ rm -rf /var/lib/apt/lists/* diff --git a/tests/Shared/Docker/apt-retry.sh b/tests/Shared/Docker/apt-retry.sh new file mode 100644 index 00000000000..f25946721d8 --- /dev/null +++ b/tests/Shared/Docker/apt-retry.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ] || [ "$1" != "install" ]; then + echo "Usage: apt-retry install " >&2 + exit 64 +fi + +shift + +ubuntu_mirror=${APT_RETRY_UBUNTU_MIRROR-http://azure.archive.ubuntu.com/ubuntu} +if [ -n "$ubuntu_mirror" ]; then + ubuntu_mirror=${ubuntu_mirror%/} + ubuntu_sources_configured=false + + for source_file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + [ -f "$source_file" ] || continue + + if grep -Eq 'http://(azure\.)?archive\.ubuntu\.com/ubuntu|http://security\.ubuntu\.com/ubuntu' "$source_file"; then + ubuntu_sources_configured=true + fi + + sed -i \ + -e "s#http://azure.archive.ubuntu.com/ubuntu#$ubuntu_mirror#g" \ + -e "s#http://archive.ubuntu.com/ubuntu#$ubuntu_mirror#g" \ + -e "s#http://security.ubuntu.com/ubuntu#$ubuntu_mirror#g" \ + "$source_file" + done + + if [ "$ubuntu_sources_configured" = "true" ]; then + echo "using Ubuntu apt mirror: $ubuntu_mirror" >&2 + fi +fi + +for attempt in 1 2 3; do + echo "apt install attempt $attempt/3: $*" >&2 + rm -rf /var/lib/apt/lists/* + + apt-get \ + -o Acquire::Retries=2 \ + -o Acquire::http::Timeout=20 \ + -o Acquire::https::Timeout=20 \ + -o APT::Update::Error-Mode=any \ + update -qq && + apt-get \ + -o Acquire::Retries=2 \ + -o Acquire::http::Timeout=20 \ + -o Acquire::https::Timeout=20 \ + -o Dpkg::Use-Pty=0 \ + install -y --no-install-recommends "$@" && + exit 0 + status=$? + + if [ "$attempt" = "3" ]; then + exit "$status" + fi + + echo "apt install failed on attempt $attempt/3; retrying..." >&2 + sleep 5 +done diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index cb735929ccc..7ac876981cc 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -457,12 +457,19 @@ internal static async Task DeclineAgentInitPromptAsync( var agentInitPrompt = new CellPatternSearcher() .Find("configure AI agent environments"); + var agentInitWorkflowPromptFound = false; var agentInitFound = false; var errorPromptFound = false; // Wait for either the agent init prompt (new CLI) or the success prompt (old CLI). await auto.WaitUntilAsync(s => { + if (IsAgentInitWorkflowPromptVisible(s)) + { + agentInitWorkflowPromptFound = true; + return true; + } + if (agentInitPrompt.Search(s).Count > 0) { agentInitFound = true; @@ -488,6 +495,13 @@ await auto.WaitUntilAsync(s => throw new InvalidOperationException($"Command failed with error prompt [{counter.Value} ERR:*] while waiting for the agent init prompt or success prompt."); } + if (agentInitWorkflowPromptFound) + { + await auto.Ctrl().KeyAsync(Hex1bKey.C); + await auto.WaitForAnyPromptAsync(counter, effectiveTimeout); + return; + } + if (!agentInitFound) { counter.Increment(); @@ -497,13 +511,65 @@ await auto.WaitUntilAsync(s => await auto.WaitAsync(500); await auto.TypeAsync("n"); - // Do not send Enter after typing "n" — the Spectre Console [Y/n] confirmation - // prompt accepts a single character. Sending Enter risks a race: if aspire init - // exits after reading "n" but before the Enter is delivered, bash receives the - // Enter and executes a phantom blank command, advancing CMDCOUNT and desyncing - // the test counter from the shell counter. + // Some prompts accept a single "n" immediately, while others require Enter. + // Only send Enter if the command did not already finish to avoid a phantom + // blank shell command that advances CMDCOUNT and desyncs prompt tracking. + var successPromptFound = false; + try + { + await auto.WaitUntilAsync(s => + { + var successSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" OK] $ "); + successPromptFound = successSearcher.Search(s).Count > 0; + + return successPromptFound; + }, timeout: TimeSpan.FromSeconds(2), description: $"success prompt [{counter.Value} OK] $ after agent init response"); + } + catch (Hex1bAutomationException) + { + } + + if (successPromptFound) + { + counter.Increment(); + return; + } + var agentInitWorkflowStarted = false; + try + { + await auto.WaitUntilAsync(s => + { + agentInitWorkflowStarted = IsAgentInitWorkflowPromptVisible(s); + return agentInitWorkflowStarted; + }, timeout: TimeSpan.FromMilliseconds(500), description: "agent init workflow prompt after agent init response"); + } + catch (Hex1bAutomationException) + { + } + + if (agentInitWorkflowStarted) + { + await auto.Ctrl().KeyAsync(Hex1bKey.C); + await auto.WaitForAnyPromptAsync(counter, effectiveTimeout); + return; + } + + await auto.EnterAsync(); await auto.WaitForSuccessPromptFailFastAsync(counter, effectiveTimeout); + + static bool IsAgentInitWorkflowPromptVisible(Hex1bTerminalSnapshot snapshot) + { + var skillLocationPrompt = new CellPatternSearcher() + .Find("Where should skill files be installed?"); + var skillsPrompt = new CellPatternSearcher() + .Find("Which skills should be installed?"); + + return skillLocationPrompt.Search(snapshot).Count > 0 + || skillsPrompt.Search(snapshot).Count > 0; + } } /// @@ -536,7 +602,7 @@ await auto.WaitUntilAsync( case AspireTemplate.JsReact: await auto.DownAsync(); await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("> Starter App (ASP.NET Core/React, C# AppHost)").Search(s).Count > 0, + s => new CellPatternSearcher().Find("> Starter App (ASP.NET Core/React").Search(s).Count > 0, timeout: TimeSpan.FromSeconds(5), description: "JS React template selected"); await auto.EnterAsync(); @@ -546,7 +612,7 @@ await auto.WaitUntilAsync( await auto.DownAsync(); await auto.DownAsync(); await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("> Starter App (Express/React, TypeScript AppHost)").Search(s).Count > 0, + s => new CellPatternSearcher().Find("> Starter App (Express/React").Search(s).Count > 0, timeout: TimeSpan.FromSeconds(5), description: "Express React template selected"); await auto.EnterAsync(); @@ -557,7 +623,7 @@ await auto.WaitUntilAsync( await auto.DownAsync(); await auto.DownAsync(); await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("> Starter App (FastAPI/React, TypeScript AppHost)").Search(s).Count > 0, + s => new CellPatternSearcher().Find("> Starter App (FastAPI/React").Search(s).Count > 0, timeout: TimeSpan.FromSeconds(5), description: "Python React template selected"); await auto.EnterAsync(); @@ -636,6 +702,7 @@ await auto.WaitUntilAsync( if (!useRedisCache) { await auto.TypeAsync("n"); + await auto.EnterAsync(); } else { @@ -657,6 +724,35 @@ await auto.WaitUntilAsync( await auto.DeclineAgentInitPromptAsync(counter); } + /// + /// Installs dependencies, runs aspire restore, and then type-checks the generated TypeScript project. + /// + internal static async Task AspireRestoreAndTypeCheckTypeScriptAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string typeCheckCommand = "npx --no-install tsc --noEmit --project tsconfig.apphost.json", + TimeSpan? installTimeout = null, + TimeSpan? restoreTimeout = null, + TimeSpan? typeCheckTimeout = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(typeCheckCommand); + + await auto.TypeAsync("npm install"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptFailFastAsync(counter, installTimeout ?? TimeSpan.FromMinutes(5)); + + await auto.TypeAsync("aspire restore"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync( + "SDK code restored successfully", + timeout: restoreTimeout ?? TimeSpan.FromMinutes(3)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync(typeCheckCommand); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptFailFastAsync(counter, typeCheckTimeout ?? TimeSpan.FromMinutes(2)); + } + /// /// Runs aspire init --language csharp and handles the NuGet.config, URLs, and agent init prompts. /// diff --git a/tests/Shared/InstallSdk.props b/tests/Shared/InstallSdk.props deleted file mode 100644 index 168af4a8953..00000000000 --- a/tests/Shared/InstallSdk.props +++ /dev/null @@ -1,8 +0,0 @@ - - - <_DotNetInstallScriptName Condition="!$([MSBuild]::IsOSPlatform('windows'))">dotnet-install.sh - <_DotNetInstallScriptName Condition=" $([MSBuild]::IsOSPlatform('windows'))">dotnet-install.ps1 - - <_DotNetInstallScriptPath>$(ArtifactsObjDir)$(_DotNetInstallScriptName) - - diff --git a/tests/Shared/InstallSdk.targets b/tests/Shared/InstallSdk.targets deleted file mode 100644 index 5d91da67242..00000000000 --- a/tests/Shared/InstallSdk.targets +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - <_DotNetInstallCommand Condition="!$([MSBuild]::IsOSPlatform('windows'))" - >$(_DotNetInstallScriptPath) -i $(SdkTargetDir) -v $(SdkVersionToInstall) - <_DotNetInstallCommand Condition="$([MSBuild]::IsOSPlatform('windows'))" - >$(_DotNetInstallScriptPath) -InstallDir $(SdkTargetDir) -Version $(SdkVersionToInstall) - - - - - - - - - - - - - - - diff --git a/tests/Shared/TemplatesTesting/BuildEnvironment.cs b/tests/Shared/TemplatesTesting/BuildEnvironment.cs index 424372f92bd..789a7997181 100644 --- a/tests/Shared/TemplatesTesting/BuildEnvironment.cs +++ b/tests/Shared/TemplatesTesting/BuildEnvironment.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.InteropServices; -using Microsoft.Extensions.Diagnostics.Latency; -using Xunit.Sdk; namespace Aspire.Templates.Tests; @@ -28,8 +26,8 @@ public class BuildEnvironment : Path.GetTempPath(); public static readonly TestTargetFramework DefaultTargetFramework = ComputeDefaultTargetFramework(); - public static readonly string TestAssetsPath = Path.Combine(AppContext.BaseDirectory, "testassets"); - public static readonly string TestRootPath = Path.Combine(TempDir, "templates-testroot"); + public static readonly string TestAssetsPath = Path.Combine(AppContext.BaseDirectory, "testassets"); + public static readonly string TestRootPath = Path.Combine(TempDir, "templates-testroot"); public static bool IsRunningOnHelix => Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT") is not null; public static bool IsRunningOnCIBuildMachine => Environment.GetEnvironmentVariable("BUILD_BUILDID") is not null; @@ -37,73 +35,27 @@ public class BuildEnvironment public static bool IsRunningOnCI => IsRunningOnHelix || IsRunningOnCIBuildMachine || IsRunningOnGithubActions; public static bool ShouldRunPlaywrightTests => PlaywrightProvider.HasPlaywrightSupport && !EnvironmentVariables.RunOnlyBasicBuildTemplatesTests; - private static readonly Lazy s_instance_80 = new(() => - new BuildEnvironment(sdkDirName: "dotnet-8")); + private static readonly Lazy s_instance = new(() => new BuildEnvironment()); - private static readonly Lazy s_instance_90 = new(() => - new BuildEnvironment(sdkDirName: "dotnet-9")); + public static BuildEnvironment ForDefaultFramework => s_instance.Value; - private static readonly Lazy s_instance_100 = new(() => - new BuildEnvironment(sdkDirName: "dotnet-10")); - - private static readonly Lazy s_instance_100_90_80 = new(() => - new BuildEnvironment(sdkDirName: "dotnet-tests")); - - public static BuildEnvironment ForPreviousSdkOnly => s_instance_80.Value; - public static BuildEnvironment ForCurrentSdkOnly => s_instance_90.Value; - public static BuildEnvironment ForNextSdkOnly => s_instance_100.Value; - public static BuildEnvironment ForNextSdkWithCurrentAndPreviousRuntimes => s_instance_100_90_80.Value; - - public static BuildEnvironment ForDefaultFramework => - DefaultTargetFramework switch - { - TestTargetFramework.Previous => ForPreviousSdkOnly, - - // Use current+previous to allow running tests on helix built with 9.0 sdk - // but targeting 8.0 tfm - TestTargetFramework.Current => ForNextSdkWithCurrentAndPreviousRuntimes, - - _ => throw new ArgumentOutOfRangeException(nameof(DefaultTargetFramework)) - }; - - public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotnet-tests") + public BuildEnvironment(bool useSystemDotNet = false) { - UsesCustomDotNet = !useSystemDotNet; RepoRoot = TestUtils.FindRepoRoot(); string sdkForTemplatePath; if (RepoRoot is not null) { - // Local run - if (!useSystemDotNet) + var repoDotNetPath = GetRepoDotNetPath(RepoRoot); + if (!useSystemDotNet && File.Exists(repoDotNetPath)) { - var sdkFromArtifactsPath = Path.Combine(RepoRoot!.FullName, "artifacts", "bin", sdkDirName); - if (Directory.Exists(sdkFromArtifactsPath)) - { - sdkForTemplatePath = Path.GetFullPath(sdkFromArtifactsPath); - } - else - { - string buildCmd = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".\\build.cmd" : "./build.sh"; - string workloadsProjString = Path.Combine("tests", "workloads.proj"); - throw new XunitException( - $"Could not find a SDK with the necessary components installed at {sdkFromArtifactsPath} computed from {nameof(RepoRoot)}={RepoRoot}." + - $" Build all the packages with '{buildCmd} -pack'." + - $" Then install the SDK with 'dotnet build {workloadsProjString}'." + - " See https://github.com/microsoft/aspire/tree/main/tests/Aspire.Templates.Tests#readme for more details."); - } + sdkForTemplatePath = Path.GetDirectoryName(repoDotNetPath)!; + UsesCustomDotNet = true; } else { - string? dotnetPath = Environment.GetEnvironmentVariable("PATH")! - .Split(Path.PathSeparator) - .Select(path => Path.Combine(path, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet")) - .FirstOrDefault(File.Exists); - if (dotnetPath is null) - { - throw new ArgumentException($"Could not find dotnet.exe in PATH={Environment.GetEnvironmentVariable("PATH")}"); - } - sdkForTemplatePath = Path.GetDirectoryName(dotnetPath)!; + sdkForTemplatePath = FindDotNetDirectory(); + UsesCustomDotNet = false; } #if RELEASE @@ -116,38 +68,12 @@ public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotne } else { - if (useSystemDotNet) - { - string? dotnetPath = Environment.GetEnvironmentVariable("PATH")! - .Split(Path.PathSeparator) - .Select(path => Path.Combine(path, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet")) - .FirstOrDefault(File.Exists); - if (dotnetPath is null) - { - throw new ArgumentException($"Could not find dotnet.exe in PATH={Environment.GetEnvironmentVariable("PATH")}"); - } - sdkForTemplatePath = Path.GetDirectoryName(dotnetPath)!; - } - else - { - // CI - helix - if (string.IsNullOrEmpty(EnvironmentVariables.SdkForTemplateTestingPath)) - { - throw new ArgumentException($"Environment variable SDK_FOR_TEMPLATES_TESTING_PATH is unset"); - } - - string? baseDir = Path.GetDirectoryName(EnvironmentVariables.SdkForTemplateTestingPath); - if (baseDir is null) - { - throw new ArgumentException($"Cannot find base directory for SDK_FOR_TEMPLATES_TESTING_PATH - {baseDir}"); - } - - sdkForTemplatePath = Path.Combine(baseDir, sdkDirName); - } + sdkForTemplatePath = FindDotNetDirectory(); + UsesCustomDotNet = false; if (string.IsNullOrEmpty(EnvironmentVariables.BuiltNuGetsPath) || !Directory.Exists(EnvironmentVariables.BuiltNuGetsPath)) { - throw new ArgumentException($"Cannot find 'BUILT_NUGETS_PATH={EnvironmentVariables.BuiltNuGetsPath}' or {BuiltNuGetsPath}"); + throw new ArgumentException($"Cannot find 'BUILT_NUGETS_PATH={EnvironmentVariables.BuiltNuGetsPath}'"); } BuiltNuGetsPath = EnvironmentVariables.BuiltNuGetsPath; } @@ -169,7 +95,10 @@ public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotne EnvVars["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; EnvVars["PATH"] = $"{sdkForTemplatePath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"; } - EnvVars["NUGET_PACKAGES"] = NuGetPackagesPath!; + if (NuGetPackagesPath is not null) + { + EnvVars["NUGET_PACKAGES"] = NuGetPackagesPath; + } EnvVars["BUILT_NUGETS_PATH"] = BuiltNuGetsPath; EnvVars["TreatWarningsAsErrors"] = "true"; // Set DEBUG_SESSION_PORT='' to avoid the app from the tests connecting @@ -187,11 +116,7 @@ public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotne EnvVars["ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_HTTPS_TERMINATION"] = "false"; } - DotNet = Path.Combine(sdkForTemplatePath!, "dotnet"); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - DotNet += ".exe"; - } + DotNet = Path.Combine(sdkForTemplatePath, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"); if (!string.IsNullOrEmpty(EnvironmentVariables.TestLogPath)) { @@ -208,7 +133,7 @@ public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotne Directory.CreateDirectory(TestRootPath); Console.WriteLine($"*** Using Sdk path: {sdkForTemplatePath}"); - if (UsesCustomDotNet) + if (NuGetPackagesPath is not null) { if (EnvironmentVariables.IsRunningOnCI) { @@ -220,7 +145,7 @@ public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotne } else { - if (NuGetPackagesPath is not null && Directory.Exists(NuGetPackagesPath)) + if (Directory.Exists(NuGetPackagesPath)) { foreach (var dir in Directory.GetDirectories(NuGetPackagesPath, "aspire*")) { @@ -284,6 +209,22 @@ public BuildEnvironment(BuildEnvironment otherBuildEnvironment) TemplatesCustomHive = otherBuildEnvironment.TemplatesCustomHive; } + private static string GetRepoDotNetPath(DirectoryInfo repoRoot) + => Path.Combine(repoRoot.FullName, ".dotnet", RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"); + + private static string FindDotNetDirectory() + { + var dotnetFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + var dotnetPath = Environment.GetEnvironmentVariable("PATH")! + .Split(Path.PathSeparator) + .Select(path => Path.Combine(path, dotnetFileName)) + .FirstOrDefault(File.Exists); + + return dotnetPath is not null + ? Path.GetDirectoryName(dotnetPath)! + : throw new ArgumentException($"Could not find {dotnetFileName} in PATH={Environment.GetEnvironmentVariable("PATH")}"); + } + private static TestTargetFramework ComputeDefaultTargetFramework() => EnvironmentVariables.DefaultTFMForTesting?.ToLowerInvariant() switch { @@ -292,7 +233,6 @@ private static TestTargetFramework ComputeDefaultTargetFramework() "net10.0" => TestTargetFramework.Next, _ => throw new ArgumentOutOfRangeException(nameof(EnvironmentVariables.DefaultTFMForTesting), EnvironmentVariables.DefaultTFMForTesting, "Invalid value") }; - } public enum TestTargetFramework diff --git a/tests/Shared/TemplatesTesting/EnvironmentVariables.cs b/tests/Shared/TemplatesTesting/EnvironmentVariables.cs index ba8513aece0..68abd4e7bbc 100644 --- a/tests/Shared/TemplatesTesting/EnvironmentVariables.cs +++ b/tests/Shared/TemplatesTesting/EnvironmentVariables.cs @@ -7,7 +7,6 @@ namespace Aspire.Templates.Tests; public static class EnvironmentVariables { - public static readonly string? SdkForTemplateTestingPath = Environment.GetEnvironmentVariable("SDK_FOR_TEMPLATES_TESTING_PATH"); public static readonly string? TestLogPath = Environment.GetEnvironmentVariable("TEST_LOG_PATH"); public static readonly string? SkipProjectCleanup = Environment.GetEnvironmentVariable("SKIP_PROJECT_CLEANUP"); public static readonly string? BuiltNuGetsPath = Environment.GetEnvironmentVariable("BUILT_NUGETS_PATH"); diff --git a/tests/helix/send-to-helix-basictests.targets b/tests/helix/send-to-helix-basictests.targets index 944f064920c..7d5e80ce4c7 100644 --- a/tests/helix/send-to-helix-basictests.targets +++ b/tests/helix/send-to-helix-basictests.targets @@ -4,8 +4,6 @@ $(TestArchiveTestsDir)**/*.zip $(BuildHelixWorkItemsDependsOn);BuildHelixWorkItemsForDefaultTests true - - true false ^(.*?)(-net[\d.]+)?$ diff --git a/tests/helix/send-to-helix-buildonhelixtests.targets b/tests/helix/send-to-helix-buildonhelixtests.targets index 2cbeed03d0b..1fcf678222a 100644 --- a/tests/helix/send-to-helix-buildonhelixtests.targets +++ b/tests/helix/send-to-helix-buildonhelixtests.targets @@ -2,7 +2,6 @@ $(BuildHelixWorkItemsDependsOn);BuildHelixWorkItemsForBuildOnHelixTests - true true true @@ -48,8 +47,6 @@ <_TestRunCommand>dotnet build -bl:$(_HelixLogsPath)/build.binlog /p:TreatWarningsAsErrors=true $(_ShellCommandSeparator) $(_TestRunCommand) <_TestRunCommand Condition="'$(HelixPerWorkItemPreCommand)' != ''">$(HelixPerWorkItemPreCommand) $(_ShellCommandSeparator) $(_TestRunCommand) - <_SetPathEnvVar Condition="'$(OS)' != 'Windows_NT'">PATH=${SDK_FOR_TEMPLATES_TESTING_PATH}:$PATH - <_SetPathEnvVar Condition="'$(OS)' == 'Windows_NT'">PATH=%SDK_FOR_TEMPLATES_TESTING_PATH%%3B%PATH% @@ -58,7 +55,7 @@ %(Identity) - $(_EnvVarSetKeyword) "TEST_NAME=%(FileName)" $(_ShellCommandSeparator) $(_EnvVarSetKeyword) "$(_SetPathEnvVar)" + $(_EnvVarSetKeyword) "TEST_NAME=%(FileName)" cd tests/%(FileName) %3B $(_TestRunCommand) 00:15:00 diff --git a/tests/helix/send-to-helix-ci.proj b/tests/helix/send-to-helix-ci.proj index cb898b769d2..ce67655f3d4 100644 --- a/tests/helix/send-to-helix-ci.proj +++ b/tests/helix/send-to-helix-ci.proj @@ -4,7 +4,6 @@ - diff --git a/tests/helix/send-to-helix-endtoendtests.targets b/tests/helix/send-to-helix-endtoendtests.targets index 1e9b2d70f33..38d1b65f661 100644 --- a/tests/helix/send-to-helix-endtoendtests.targets +++ b/tests/helix/send-to-helix-endtoendtests.targets @@ -2,7 +2,6 @@ $(BuildHelixWorkItemsDependsOn);BuildHelixWorkItemsForEnd2EndTests - true diff --git a/tests/helix/send-to-helix-inner.proj b/tests/helix/send-to-helix-inner.proj index 92481d23d8a..a7fc7b9d232 100644 --- a/tests/helix/send-to-helix-inner.proj +++ b/tests/helix/send-to-helix-inner.proj @@ -13,7 +13,7 @@ true - $(DotNetSdkPreviousVersionForTesting) + $(DotNetSdkVersionForHelixWorkItems) <_DotNetToolJsonPath>$(RepoRoot).config/dotnet-tools.json @@ -22,7 +22,6 @@ <_AzureFunctionsCliUrl Condition="'$(OS)' == 'Windows_NT'">https://github.com/Azure/azure-functions-core-tools/releases/download/4.0.7512/Azure.Functions.Cli.min.win-x64.4.0.7512.zip <_AzureFunctionsCliUrl Condition="'$(OS)' != 'Windows_NT'">https://github.com/Azure/azure-functions-core-tools/releases/download/4.0.7512/Azure.Functions.Cli.linux-x64.4.0.7512.zip - <_DefaultSdkDirNameForTests>dotnet-tests @@ -50,8 +49,8 @@ <_DisableRyukForTestcontainersWorkItemCommand Condition="'$(OS)' != 'Windows_NT'">if $(_UsesTestcontainersWorkItemCommand)%3B then echo "Disabling Testcontainers Ryuk for this Helix work item." $(_ShellCommandSeparator) export TESTCONTAINERS_RYUK_DISABLED=true%3B fi <_CleanupDockerForTestcontainersWorkItemCommand Condition="'$(OS)' != 'Windows_NT'">if $(_UsesTestcontainersWorkItemCommand)%3B then echo "Cleaning up Docker resources for this Testcontainers Helix work item." $(_ShellCommandSeparator) $(_ShutdownDockerContainersCommand) $(_ShellCommandSeparator) $(_DeleteDockerVolumesCommand) $(_ShellCommandSeparator) docker network prune -f%3B fi - <_CleanupProcessesCommand Condition="'$(OS)' == 'Windows_NT'">powershell -ExecutionPolicy ByPass -NoProfile -command "& get-ciminstance win32_process | where-object ExecutablePath -Match 'dotnet-tests|dcp.exe' | foreach-object { echo $_.ProcessId $_.ExecutablePath %3B stop-process -id $_.ProcessId -force -ErrorAction SilentlyContinue }" - <_CleanupProcessesCommand Condition="'$(OS)' != 'Windows_NT'">pgrep -lf "dotnet-tests|dcp.exe" | awk '{print %3B system("kill -9 "$1)}' + <_CleanupProcessesCommand Condition="'$(OS)' == 'Windows_NT'">powershell -ExecutionPolicy ByPass -NoProfile -command "& get-ciminstance win32_process | where-object ExecutablePath -Match 'dcp.exe' | foreach-object { echo $_.ProcessId $_.ExecutablePath %3B stop-process -id $_.ProcessId -force -ErrorAction SilentlyContinue }" + <_CleanupProcessesCommand Condition="'$(OS)' != 'Windows_NT'">pgrep -lf "dcp.exe" | awk '{print %3B system("kill -9 "$1)}' <_WaitForDcpCommand Condition="'$(OS)' != 'Windows_NT'">echo "Waiting for dcp process to exit..."; start_time=$(date +%s); echo "Start time: $start_time"; timeout=120; while pgrep -lf "dcp.exe" > /dev/null && [ $timeout -gt 0 ]; do sleep 1; timeout=$((timeout - 1)); done; end_time=$(date +%s); echo "End time: $end_time"; if pgrep -lf "dcp.exe" > /dev/null; then echo "dcp process did not exit within the timeout period."; exit 1; fi @@ -92,17 +91,7 @@ - - - - - - - - - - - + @@ -160,14 +149,8 @@ - - - set PATH=%HELIX_CORRELATION_PAYLOAD%\$(_DefaultSdkDirNameForTests)%3B%PATH% - export PATH=$HELIX_CORRELATION_PAYLOAD/$(_DefaultSdkDirNameForTests):$PATH - - $(HelixPerWorkItemPreCommand) & set DOTNET_ROOT=%HELIX_CORRELATION_PAYLOAD%\$(_DefaultSdkDirNameForTests) - $(HelixPerWorkItemPreCommand) && export DOTNET_ROOT=$HELIX_CORRELATION_PAYLOAD/$(_DefaultSdkDirNameForTests) - $(HelixPerWorkItemPreCommand) && $(_DisableRyukForTestcontainersWorkItemCommand) + + $(_DisableRyukForTestcontainersWorkItemCommand) @@ -194,8 +177,7 @@ - - + diff --git a/tests/helix/send-to-helix-templatestests.targets b/tests/helix/send-to-helix-templatestests.targets deleted file mode 100644 index 7f5a55cb605..00000000000 --- a/tests/helix/send-to-helix-templatestests.targets +++ /dev/null @@ -1,79 +0,0 @@ - - - - $(BuildHelixWorkItemsDependsOn);BuildHelixWorkItemsForTemplateTests - true - true - - Aspire.Templates.Tests - - - - - - - - - - - - - - - - <_TestRunCommandArguments Condition="'$(OS)' != 'Windows_NT'" Include="${TEST_ARGS}" /> - <_TestRunCommandArguments Condition="'$(OS)' == 'Windows_NT'" Include="%TEST_ARGS%" /> - - - - <_TestRunCommand Condition="'$(RunWithCodeCoverage)' == 'true'">@(_TestCoverageCommand, ' ') "@(_TestRunCommandArguments, ' ')" - <_TestRunCommand Condition="'$(RunWithCodeCoverage)' != 'true'">@(_TestRunCommandArguments, ' ') - <_TestRunCommand Condition="'$(HelixPerWorkItemPreCommand)' != ''">$(HelixPerWorkItemPreCommand) $(_ShellCommandSeparator) $(_TestRunCommand) - - - - - - - - - <_TemplateTestsClassNames Include="@(_TemplateTestsClassNamesRaw->'%(Identity)'->Replace('class:', ''))" /> - - - - - - - - - - - - <_TemplateTestsClassNames TestNameSuffix="$([System.String]::Copy('%(Identity)').Replace('Aspire.Template.Tests.', ''))" PreCommands="" /> - - - $(TestArchiveTestsDirForTemplateTests)\$(TestProjectName).zip - - $(_EnvVarSetKeyword) "TEST_NAME=%(FileName)" - %(PreCommands) $(_ShellCommandSeparator) $(_EnvVarSetKeyword) "CODE_COV_FILE_SUFFIX=-%(TestNameSuffix)" - %(PreCommands) $(_ShellCommandSeparator) $(_EnvVarSetKeyword) "TEST_NAME_SUFFIX=%(TestNameSuffix)" - - - %(PreCommands) $(_ShellCommandSeparator) set "TEST_ARGS=--filter-not-trait quarantined=true --filter-not-trait category=failing --filter-class %(Identity)" - %(PreCommands) $(_ShellCommandSeparator) export "TEST_ARGS=--filter-not-trait quarantined=true --filter-not-trait category=failing --filter-class %(Identity)" - - $(_TestRunCommand) - $(_workItemTimeout) - - logs/$(TestProjectName)-%(TestNameSuffix).trx - - - - diff --git a/tests/workloads.proj b/tests/workloads.proj deleted file mode 100644 index cea633c14d3..00000000000 --- a/tests/workloads.proj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Debug - - - - -