Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions .github/skills/cli-e2e-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,3 +596,207 @@ RUN_ID=$(gh run list --branch $(git branch --show-current) --workflow CI --limit
| Pattern not found but text is visible | Using `FindPattern` with regex special chars | Use `Find()` instead of `FindPattern()` for literal strings containing `(`, `)`, `/`, etc. |
| Test hangs indefinitely | Waiting for wrong prompt number | Verify `SequenceCounter` usage matches commands |
| Timeout waiting for dashboard URL | Project failed to build/run | Check recording for build errors |

## Sample Upgrade Tests

Sample upgrade tests validate that `aspire update` can upgrade external Git repos (e.g., `dotnet/aspire-samples`) to the PR/CI build and that the upgraded samples run correctly.

**Location**: `tests/Aspire.Cli.EndToEnd.Tests/SampleUpgrade*.cs` and `Helpers/SampleUpgradeHelpers.cs`

### Test Architecture

Each sample upgrade test follows this flow:

1. Create a Docker terminal with host networking and Docker socket access
2. Install the Aspire CLI from the PR build
3. Clone the external repo (e.g., `dotnet/aspire-samples`)
4. Run `aspire update --channel pr-{N}` to upgrade packages to the PR version
5. Verify the upgrade via mounted volume (read csproj, check versions)
6. Run `aspire run` and capture the dashboard URL
7. Verify the dashboard shows expected resources via Playwright
8. Poll HTTP endpoints from the host to confirm services are running
9. Take a dashboard screenshot and save it as a test artifact
10. Stop the apphost and exit

### Helper Classes

| Class | Description |
|-------|-------------|
| `SampleUpgradeHelpers` | Extension methods on `Hex1bTerminalAutomator` for clone, update, run, stop |
| `DashboardVerificationHelpers` | Playwright-based dashboard verification and HTTP endpoint polling |
| `AspireRunInfo` | Record returned by `AspireRunSampleAsync` containing the dashboard URL |

### Host Network Mode

Sample upgrade tests use Docker's host network mode (`c.Network = "host"`) so that services started by `aspire run` inside the container are directly accessible from the test process on the host. This enables:
- Playwright browser access to the Aspire dashboard
- `HttpClient` polling of app HTTP endpoints
- No port mapping configuration needed

```csharp
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
repoRoot, installMode, output,
mountDockerSocket: true,
useHostNetwork: true,
workspace: workspace,
additionalVolumes: [$"{workDir}:{containerWorkDir}"]);
```

### Dashboard URL Capture

`AspireRunSampleAsync` returns an `AspireRunInfo` record containing the dashboard login URL parsed from terminal output. The URL includes the auth token needed for Playwright access.

```csharp
var runInfo = await auto.AspireRunSampleAsync(
appHostRelativePath: AppHostCsproj,
startTimeout: TimeSpan.FromMinutes(5));

// runInfo.DashboardUrl is e.g.: http://localhost:18888/login?t=abc123
```

### Playwright Dashboard Verification

Use `DashboardVerificationHelpers.VerifyDashboardAsync` to navigate to the dashboard, verify resources are displayed, and take a screenshot:

```csharp
var screenshotPath = DashboardVerificationHelpers.GetScreenshotPath(
nameof(UpgradeAndRunAspireWithNodeSample));

await DashboardVerificationHelpers.VerifyDashboardAsync(
runInfo.DashboardUrl,
expectedResourceNames: ["cache", "weatherapi", "frontend"],
screenshotPath,
output,
timeout: TimeSpan.FromSeconds(90));
```

Playwright browsers are installed automatically on first use via `EnsureBrowsersInstalled()`. This installs Chromium with system dependencies.

Screenshots are saved to `testresults/screenshots/` within the test output directory and are included in CI artifacts.

### HTTP Endpoint Polling from Host

Use `DashboardVerificationHelpers.PollEndpointAsync` to verify HTTP endpoints are reachable from the host. Uses retry with exponential backoff:

```csharp
await DashboardVerificationHelpers.PollEndpointAsync(
"http://localhost:18888",
output,
expectedStatusCode: 200,
timeout: TimeSpan.FromSeconds(30));
```

### aspire update Interactive Prompts

When running `aspire update` with `--channel`, the helper handles these interactive prompts automatically:

| Prompt | Action | When it appears |
|--------|--------|-----------------|
| "Select a channel:" | Press Enter (default) | Hives exist, no `--channel` flag |
| "Which directory for NuGet.config file?" | Press Enter (accept default) | Explicit channel specified |
| "Apply these changes to NuGet.config?" | Press Enter (yes) | Explicit channel specified |
| "Perform updates?" | Press Enter (yes) | Always |
| "Would you like to update it now?" | Type "n", Enter | CLI self-update prompt |

### Writing a New Sample Upgrade Test

1. **Create a new test class** named `SampleUpgrade{SampleName}Tests.cs`
2. **Define constants**: sample path, AppHost csproj path, original version, expected resources
3. **Use `SampleUpgradeHelpers`** for clone, update, run, stop
4. **Use `DashboardVerificationHelpers`** for dashboard verification and endpoint polling
5. **Enable host networking** with `useHostNetwork: true`
6. **Mount a working directory** for direct file assertions from the host

```csharp
public sealed class SampleUpgradeMyNewSampleTests(ITestOutputHelper output)
{
private const string SamplePath = "aspire-samples/samples/my-new-sample";
private const string AppHostCsproj = "MyNewSample.AppHost/MyNewSample.AppHost.csproj";
private const string OriginalVersion = "13.1.0";
private static readonly string[] s_expectedResources = ["resource1", "resource2"];

[Fact]
public async Task UpgradeAndRunMyNewSample()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);

var workDir = Path.Combine(workspace.WorkspaceRoot.FullName, "sample-work");
Directory.CreateDirectory(workDir);

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
repoRoot, installMode, output,
mountDockerSocket: true,
useHostNetwork: true,
workspace: workspace,
additionalVolumes: [$"{workDir}:/sample-work"]);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(600));

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);

await auto.TypeAsync("cd /sample-work");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

await auto.CloneSampleRepoAsync(counter);

await auto.TypeAsync($"cd {SamplePath}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

string? channel = null;
if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest)
{
channel = $"pr-{CliE2ETestHelpers.GetRequiredPrNumber()}";
}

await auto.AspireUpdateInSampleAsync(counter, samplePath: ".",
channel: channel, timeout: TimeSpan.FromMinutes(5));

// Verify upgrade via mounted volume
var csproj = await File.ReadAllTextAsync(
Path.Combine(workDir, SamplePath, AppHostCsproj));
Assert.DoesNotContain(OriginalVersion, csproj);

// Run and verify dashboard
var runInfo = await auto.AspireRunSampleAsync(
appHostRelativePath: AppHostCsproj,
startTimeout: TimeSpan.FromMinutes(5));

if (runInfo.DashboardUrl is not null)
{
await DashboardVerificationHelpers.VerifyDashboardAsync(
runInfo.DashboardUrl,
s_expectedResources,
DashboardVerificationHelpers.GetScreenshotPath(nameof(UpgradeAndRunMyNewSample)),
output);
}

await auto.StopAspireRunAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
}
```

### Special Docker Images for Samples

Some samples may need additional tooling not in the base `Dockerfile.e2e` (e.g., Go, Java). For these:
- Create a new `DockerfileVariant` enum value
- Add a new Dockerfile (e.g., `Dockerfile.e2e-go`) extending the base image
- Pass the variant to `CreateDockerTestTerminal`

### CI Artifacts for Sample Tests

Sample upgrade tests produce these artifacts:
- **Asciinema recording** (`.cast`): Full terminal session replay for debugging
- **Dashboard screenshot** (`.png`): Visual proof of the dashboard state
- **Test output log**: Includes upgrade details, endpoint polling results, resource verification

All are uploaded to GitHub Actions artifacts under the test job's `logs-*` artifact.
1 change: 1 addition & 0 deletions src/Aspire.Cli/DotNet/DotNetCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ public async Task<int> BuildAsync(FileInfo projectFilePath, bool noRestore, DotN
options: options,
cancellationToken: cancellationToken);
}

public async Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
Expand Down
33 changes: 32 additions & 1 deletion src/Aspire.Cli/Projects/ProjectUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ internal interface IProjectUpdater

internal sealed partial class ProjectUpdater(ILogger<ProjectUpdater> logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser) : IProjectUpdater
{
private bool _isExplicitChannel;
public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default)
{
logger.LogDebug("Fetching '{AppHostPath}' items and properties.", projectFile.FullName);
Expand Down Expand Up @@ -75,6 +76,10 @@ public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile,
return new ProjectUpdateResult { UpdatedApplied = false };
}

// Track whether we're using an explicit channel (e.g., PR hive) so that
// individual dotnet package add calls can skip the implicit restore.
_isExplicitChannel = false;

if (channel.Type == PackageChannelType.Explicit)
{
var (configPathsExitCode, configPaths) = await runner.GetNuGetConfigPathsAsync(projectFile.Directory!, new(), cancellationToken);
Expand Down Expand Up @@ -114,6 +119,7 @@ public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile,
required: true,
cancellationToken: cancellationToken);

_isExplicitChannel = true;
var nugetConfigDirectory = new DirectoryInfo(selectedPathForNewNuGetConfigFile);
await NuGetConfigMerger.CreateOrUpdateAsync(nugetConfigDirectory, channel, AnalyzeAndConfirmNuGetConfigChanges, cancellationToken);
}
Expand All @@ -133,6 +139,24 @@ await interactionService.ShowStatusAsync(
return 0;
});

// When using an explicit channel with --no-restore on individual package adds,
// perform a final restore to ensure all packages resolve correctly together.
if (_isExplicitChannel)
{
await interactionService.ShowStatusAsync(
UpdateCommandStrings.RestoringPackages,
async () =>
{
var exitCode = await runner.RestoreAsync(projectFile, new(), cancellationToken);
if (exitCode != 0)
{
throw new ProjectUpdaterException(UpdateCommandStrings.FailedRestoreAfterUpdate);
}

return 0;
});
}

interactionService.DisplayEmptyLine();

interactionService.DisplaySuccess(UpdateCommandStrings.UpdateSuccessfulMessage);
Expand Down Expand Up @@ -877,12 +901,19 @@ private static async Task UpdatePackageVersionInDirectoryPackagesProps(string pa

private async Task UpdatePackageReferenceInProject(FileInfo projectFile, NuGetPackageCli package, CancellationToken cancellationToken)
{
// When using an explicit channel, skip the implicit restore during package add to
// avoid failures from NuGet package source mapping. The mapping routes Aspire* packages
// exclusively to the hive source, but during the restore triggered by dotnet package add,
// other Aspire packages not yet updated still reference old versions that don't exist in
// the hive. A final restore is performed after all packages are updated.
var noRestore = _isExplicitChannel;

var exitCode = await runner.AddPackageAsync(
projectFilePath: projectFile,
packageName: package.Id,
packageVersion: package.Version,
nugetSource: null,
noRestore: false,
noRestore: noRestore,
options: new(),
cancellationToken: cancellationToken);

Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/Aspire.Cli/Resources/UpdateCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,10 @@
<data name="SelfOptionDescription" xml:space="preserve">
<value>Update the Aspire CLI itself to the latest version</value>
</data>
<data name="RestoringPackages" xml:space="preserve">
<value>Restoring packages...</value>
</data>
<data name="FailedRestoreAfterUpdate" xml:space="preserve">
<value>Failed to restore packages after update. You may need to run 'dotnet restore' manually.</value>
</data>
</root>
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading