Skip to content
Open
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
14 changes: 13 additions & 1 deletion .github/skills/ci-test-failures/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,23 @@ artifact_0_TestName_os/
├── testresults/
│ ├── TestName_net10.0_timestamp.trx # Test results XML
│ ├── Aspire.*.Tests_*.log # Console output
│ └── recordings/ # Asciinema recordings (CLI E2E tests)
│ ├── recordings/ # Asciinema recordings (CLI E2E tests)
│ └── workspaces/ # Captured project workspaces (CLI E2E tests)
│ └── TestClassName.MethodName/ # Full generated project for failed tests
│ ├── apphost.ts
│ ├── aspire.config.json
│ ├── .modules/ # Generated SDK (aspire.js) - key for debugging
│ └── ...
├── *.crash.dmp # Crash dump (if test crashed)
└── test.binlog # MSBuild binary log
```
### CLI E2E Workspace Capture
CLI E2E tests annotated with `[CaptureWorkspaceOnFailure]` automatically capture the full generated project workspace when a test fails. This includes the generated SDK (`.modules/aspire.js`), template output, and config files — critical for debugging template generation or `aspire run` failures.
Look in `testresults/workspaces/{TestClassName.MethodName}/` inside the downloaded artifact.
## Parsing .trx Files
```powershell
Expand Down
28 changes: 25 additions & 3 deletions .github/skills/cli-e2e-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,9 +569,31 @@ testresults/
├── Aspire.Cli.EndToEnd.Tests_*.log # Console output log
├── *.crash.dmp # Crash dump (if test crashed)
├── test.binlog # MSBuild binary log
└── recordings/
├── CreateAndRunAspireStarterProject.cast # Asciinema recording
└── ...
├── recordings/
│ ├── CreateAndRunAspireStarterProject.cast # Asciinema recording
│ └── ...
└── workspaces/ # Captured project workspaces (on failure)
└── TestClassName.MethodName/ # Full generated project for debugging
├── apphost.ts
├── aspire.config.json
├── .modules/ # Generated SDK - check aspire.js for exports
└── ...
```

### Workspace Capture

Tests annotated with `[CaptureWorkspaceOnFailure]` automatically copy the generated project workspace into the test artifacts when a test fails. This is invaluable for debugging template generation or `aspire run` failures — you can inspect the exact generated files including the SDK output in `.modules/aspire.js`.

To add workspace capture to a new test:
```csharp
[Fact]
[CaptureWorkspaceOnFailure]
public async Task MyTemplateTest()
{
var workspace = TemporaryWorkspace.Create(output);
// ... test code — workspace is automatically registered for capture ...
}
```
```
### One-Liner: Download Latest Recording
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@
<EmbeddedResource Include="Templating\Templates\ts-starter\**\*" LogicalName="ts-starter.%(RecursiveDir)%(Filename)%(Extension)">
<WithCulture>false</WithCulture>
</EmbeddedResource>
<EmbeddedResource Include="Templating\Templates\py-starter\**\*" LogicalName="py-starter.%(RecursiveDir)%(Filename)%(Extension)">
<WithCulture>false</WithCulture>
</EmbeddedResource>
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// 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.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
using Spectre.Console;

namespace Aspire.Cli.Templating;

internal sealed partial class CliTemplateFactory
{
private async Task<TemplateResult> ApplyPythonStarterTemplateAsync(CallbackTemplate _, TemplateInputs inputs, System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken)
{
var projectName = inputs.Name;
if (string.IsNullOrWhiteSpace(projectName))
{
var defaultName = _executionContext.WorkingDirectory.Name;
projectName = await _prompter.PromptForProjectNameAsync(defaultName, cancellationToken);
}

if (string.IsNullOrWhiteSpace(inputs.Version))
{
_interactionService.DisplayError("Unable to determine Aspire version for the Python starter template.");
return new TemplateResult(ExitCodeConstants.InvalidCommand);
}

var aspireVersion = inputs.Version;
var outputPath = inputs.Output;
if (string.IsNullOrWhiteSpace(outputPath))
{
var defaultOutputPath = $"./{projectName}";
outputPath = await _prompter.PromptForOutputPath(defaultOutputPath, cancellationToken);
}
outputPath = Path.GetFullPath(outputPath, _executionContext.WorkingDirectory.FullName);

_logger.LogDebug("Applying Python starter template. ProjectName: {ProjectName}, OutputPath: {OutputPath}, AspireVersion: {AspireVersion}.", projectName, outputPath, aspireVersion);

var useLocalhostTld = await ResolveUseLocalhostTldAsync(parseResult, cancellationToken);
var useRedisCache = await ResolveUseRedisCacheAsync(parseResult, cancellationToken);

TemplateResult templateResult;
try
{
if (!Directory.Exists(outputPath))
{
Directory.CreateDirectory(outputPath);
}

templateResult = await _interactionService.ShowStatusAsync(
TemplatingStrings.CreatingNewProject,
async () =>
{
var projectNameLower = projectName.ToLowerInvariant();

var ports = GenerateRandomPorts();
var hostName = useLocalhostTld ? $"{projectNameLower}.dev.localhost" : "localhost";
var conditions = new Dictionary<string, bool>
{
["redis"] = useRedisCache,
["no-redis"] = !useRedisCache,
};
string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process(
ApplyTokens(content, projectName, projectNameLower, aspireVersion, ports, hostName),
conditions);
_logger.LogDebug("Copying embedded Python starter template files to '{OutputPath}'.", outputPath);
await CopyTemplateTreeToDiskAsync("py-starter", outputPath, ApplyAllTokens, cancellationToken);

if (useRedisCache)
{
AddRedisPackageToConfig(outputPath, aspireVersion);
}

// Write channel to settings.json before restore so package resolution uses the selected channel.
if (!string.IsNullOrEmpty(inputs.Channel))
{
var config = AspireJsonConfiguration.Load(outputPath);
if (config is not null)
{
config.Channel = inputs.Channel;
config.Save(outputPath);
}
}

var appHostProject = _projectFactory.TryGetProject(new FileInfo(Path.Combine(outputPath, "apphost.ts")));
if (appHostProject is not IGuestAppHostSdkGenerator guestProject)
{
_interactionService.DisplayError("Automatic 'aspire restore' is unavailable for the new Python starter project because no TypeScript AppHost SDK generator was found.");
return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath);
}

_logger.LogDebug("Generating SDK code for Python starter in '{OutputPath}'.", outputPath);
var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken);
if (!restoreSucceeded)
{
_interactionService.DisplayError("Automatic 'aspire restore' failed for the new Python starter project. Run 'aspire restore' in the project directory for more details.");
return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath);
}

return new TemplateResult(ExitCodeConstants.Success, outputPath);
}, emoji: KnownEmojis.Rocket);

if (templateResult.ExitCode != ExitCodeConstants.Success)
{
return templateResult;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_interactionService.DisplayError($"Failed to create project files: {ex.Message}");
return new TemplateResult(ExitCodeConstants.FailedToCreateNewProject);
}

_interactionService.DisplaySuccess($"Created Python starter project at {outputPath.EscapeMarkup()}");
DisplayPostCreationInstructions(outputPath);

return templateResult;
}

private async Task<bool> ResolveUseRedisCacheAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken)
{
var redisCacheOptionSpecified = parseResult.Tokens.Any(token =>
string.Equals(token.Value, "--use-redis-cache", StringComparisons.CliInputOrOutput));
var useRedisCache = parseResult.GetValue(_useRedisCacheOption);
if (!redisCacheOptionSpecified)
{
if (!_hostEnvironment.SupportsInteractiveInput)
{
return false;
}

useRedisCache = await _interactionService.PromptForSelectionAsync(
TemplatingStrings.UseRedisCache_Prompt,
[TemplatingStrings.Yes, TemplatingStrings.No],
choice => choice,
cancellationToken) switch
{
var choice when string.Equals(choice, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput) => true,
var choice when string.Equals(choice, TemplatingStrings.No, StringComparisons.CliInputOrOutput) => false,
_ => throw new InvalidOperationException(TemplatingStrings.UseRedisCache_UnexpectedChoice)
};
}

if (useRedisCache ?? false)
{
_interactionService.DisplayMessage(KnownEmojis.CheckMark, TemplatingStrings.UseRedisCache_UsingRedisCache);
}

return useRedisCache ?? false;
}

private static void AddRedisPackageToConfig(string outputPath, string aspireVersion)
{
var config = AspireConfigFile.LoadOrCreate(outputPath);
config.AddOrUpdatePackage("Aspire.Hosting.Redis", aspireVersion);
config.Save(outputPath);
}
}
20 changes: 19 additions & 1 deletion src/Aspire.Cli/Templating/CliTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ internal sealed partial class CliTemplateFactory : ITemplateFactory
Description = TemplatingStrings.UseLocalhostTld_Description
};

private readonly Option<bool?> _useRedisCacheOption = new("--use-redis-cache")
{
Description = TemplatingStrings.UseRedisCache_Description
};

private readonly ILanguageDiscovery _languageDiscovery;
private readonly IAppHostProjectFactory _projectFactory;
private readonly IScaffoldingService _scaffoldingService;
Expand Down Expand Up @@ -130,7 +135,20 @@ private IEnumerable<ITemplate> GetTemplateDefinitions()
ApplyEmptyAppHostTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.Java,
isEmpty: true)
isEmpty: true),

new CallbackTemplate(
KnownTemplateId.PythonStarter,
"Starter App (FastAPI/React)",
projectName => $"./{projectName}",
cmd =>
{
AddOptionIfMissing(cmd, _localhostTldOption);
AddOptionIfMissing(cmd, _useRedisCacheOption);
},
ApplyPythonStarterTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.TypeScript)
];

return templates.Where(IsTemplateAvailable);
Expand Down
105 changes: 105 additions & 0 deletions src/Aspire.Cli/Templating/ConditionalBlockProcessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Text.RegularExpressions;

namespace Aspire.Cli.Templating;

/// <summary>
/// Processes conditional blocks in template content. Blocks are delimited by
/// marker lines of the form <c>{{#name}}</c> / <c>{{/name}}</c>. When a block
/// is included, the marker lines are stripped and the inner content is kept;
/// when excluded, the marker lines and their content are removed entirely.
/// Marker lines may contain leading comment characters (e.g. <c>// {{#name}}</c>
/// or <c># {{#name}}</c>) — the entire line is always removed.
/// </summary>
/// <remarks>
/// Blocks must not overlap or nest across different condition names. Each condition
/// is processed independently in enumeration order. Overlapping blocks produce
/// undefined behavior.
/// </remarks>
internal static partial class ConditionalBlockProcessor
{
/// <summary>
/// Processes all conditional blocks for the given set of conditions. Each entry
/// in <paramref name="conditions"/> maps a block name to whether it should be included.
/// </summary>
/// <param name="content">The template content to process.</param>
/// <param name="conditions">A set of block-name to include/exclude mappings.</param>
/// <returns>The processed content with conditional blocks resolved.</returns>
internal static string Process(string content, IReadOnlyDictionary<string, bool> conditions)
{
foreach (var (blockName, include) in conditions)
{
content = ProcessBlock(content, blockName, include);
}

Debug.Assert(
!LeftoverMarkerPattern().IsMatch(content),
$"Template content contains unprocessed conditional markers. Ensure all block names are included in the conditions dictionary.");

return content;
}

[GeneratedRegex(@"\{\{[#/][a-zA-Z][\w-]*\}\}")]
private static partial Regex LeftoverMarkerPattern();

/// <summary>
/// Processes all occurrences of a single conditional block in the content.
/// </summary>
/// <param name="content">The template content to process.</param>
/// <param name="blockName">The name of the conditional block (e.g. <c>redis</c>).</param>
/// <param name="include">
/// When <see langword="true"/>, the block content is kept and only the marker lines
/// are removed. When <see langword="false"/>, the entire block (markers and content) is removed.
/// </param>
/// <returns>The processed content.</returns>
internal static string ProcessBlock(string content, string blockName, bool include)
{
var startPattern = $"{{{{#{blockName}}}}}";
var endPattern = $"{{{{/{blockName}}}}}";

while (true)
{
var startIdx = content.IndexOf(startPattern, StringComparison.Ordinal);
if (startIdx < 0)
{
break;
}

var endIdx = content.IndexOf(endPattern, startIdx, StringComparison.Ordinal);
if (endIdx < 0)
{
throw new InvalidOperationException(
$"Template contains opening marker '{{{{#{blockName}}}}}' without a matching closing marker '{{{{/{blockName}}}}}'.");
}

// Find the full start marker line (including leading whitespace/comments and trailing newline).
var startLineBegin = content.LastIndexOf('\n', startIdx);
startLineBegin = startLineBegin < 0 ? 0 : startLineBegin + 1;
var startLineEnd = content.IndexOf('\n', startIdx);
startLineEnd = startLineEnd < 0 ? content.Length : startLineEnd + 1;

// Find the full end marker line.
var endLineBegin = content.LastIndexOf('\n', endIdx);
endLineBegin = endLineBegin < 0 ? 0 : endLineBegin + 1;
var endLineEnd = content.IndexOf('\n', endIdx);
endLineEnd = endLineEnd < 0 ? content.Length : endLineEnd + 1;

if (include)
{
// Keep the block content but remove the marker lines.
var blockContent = content[startLineEnd..endLineBegin];
content = string.Concat(content.AsSpan(0, startLineBegin), blockContent, content.AsSpan(endLineEnd));
}
else
{
// Remove everything from start marker line to end marker line (inclusive).
content = string.Concat(content.AsSpan(0, startLineBegin), content.AsSpan(endLineEnd));
}
}

return content;
}
}
Loading
Loading