Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ The pack ships two styles of WinUI app. **XAML** templates define the UI in mark

> **Reactor templates are experimental.** They reference the prerelease `Microsoft.UI.Reactor` packages, whose APIs can change or be removed in a future release. `winapp new` marks them **(Experimental)** in `--list` and in the interactive picker, sets `"Experimental": true` in `--json`, and prints a warning after scaffolding one. They are never chosen as the default template. Reactor also requires the **.NET 10 SDK or newer**; on an older SDK `winapp new` fails up front with the version it needs rather than scaffolding a project you can't build.

Each template's canonical short name is the first alias `dotnet new` lists for it; any listed alias (e.g. `winui3`, `wasdk-single`, `winui-reactor`) is also accepted. When run inside an existing WinUI project, `dotnet new` also surfaces **item** templates (e.g. a blank page), which `winapp new` adds into the current project rather than creating a new one.
Each template's canonical short name is the first alias `dotnet new` lists for it; any listed alias (e.g. `winui3`, `wasdk-single`, `winui-reactor`) is also accepted. If another installed template pack registers the same short name (for example, `reactor` from `Microsoft.UI.Reactor.Templates`), `winapp new` still creates the WinUI pack's template by using one of its other aliases. If another pack registers every alias the template has, `winapp new` stops and names that pack. Remove it with `dotnet new uninstall <package>` and re-run. When run inside an existing WinUI project, `dotnet new` also surfaces **item** templates (e.g. a blank page), which `winapp new` adds into the current project rather than creating a new one.

**Template pack versioning:**

Expand Down
85 changes: 85 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,91 @@ public async Task Handler_List_ExcludesTemplatesNotOwnedByTheMicrosoftPack()
"A template from another installed pack must be filtered out of the catalog.");
}

/// <summary>
/// Scripts the WinUI pack plus the standalone Reactor pack, whose template registers
/// <paramref name="foreignAliases"/>. Only the WinUI pack's templates match <c>dotnet new list winui</c>.
/// </summary>
private void ScriptWithStandaloneReactorPack(string foreignAliases)
{
(string Name, string Short, string Lang, string Type, string Author, string Tags)[] templates =
[
("WinUI Blank App", "winui,winui3,wasdk-single", "[C#]", "project", "Microsoft", "Windows/WinUI/Desktop/XAML"),
("Reactor Blank App (Experimental)", "reactor,reactor-blank,winui-reactor", "[C#]", "project", "Microsoft", "Windows/WinUI/Desktop/Reactor/Experimental"),
];
var list = BuildListTable(templates);
var uninstall = "Currently installed items:\n"
+ " Microsoft.UI.Reactor.Templates\n"
+ " Version: 0.1.0\n"
+ " Templates:\n"
+ $" Microsoft.UI.Reactor App ({foreignAliases}) C#\n"
+ " Uninstall Command:\n"
+ " dotnet new uninstall Microsoft.UI.Reactor.Templates\n"
+ BuildUninstallOutput("0.0.6-alpha", templates).Replace("Currently installed items:\n", string.Empty, StringComparison.Ordinal);
_dotnet.RunDotnetArgumentListHandler = args =>
{
if (args.Count >= 1 && args[0] == "--version")
{
return (0, "10.0.100\n", string.Empty);
}
if (args.Count >= 2 && args[0] == "new" && args[1] == "uninstall")
{
return (0, uninstall, string.Empty);
}
if (args.Count >= 2 && args[0] == "new" && args[1] == "list")
{
return (0, list, string.Empty);
}
if (args.Count >= 2 && args[0] == "new" && args[1] == "update")
{
return (0, "All template packages are up-to-date.", string.Empty);
}
if (args.Count >= 2 && args[0] == "new" && args[1] == "install")
{
return (0, "ok", string.Empty);
}
return (0, "The template was created successfully.", string.Empty);
};
}

[TestMethod]
public async Task Handler_ShortNameAlsoRegisteredByAnotherPack_ScaffoldsWithUniqueAlias()
{
// `dotnet new reactor` is ambiguous when Microsoft.UI.Reactor.Templates is also installed, so
// winapp must invoke the WinUI pack's template by an alias only that pack owns (#948).
ScriptWithStandaloneReactorPack("reactor");
var command = GetRequiredService<NewCommand>();

var exitCode = await ParseAndInvokeWithCaptureAsync(
command, ["--use-defaults", "--json", "--template", "reactor", "--name", "MyApp"]);

Assert.AreEqual(NewCommand.ExitSuccess, exitCode, TestAnsiConsole.Output);
var json = ParseJson(TestAnsiConsole.Output);
Assert.AreEqual("reactor", json.GetProperty("Template").GetString(),
"The reported template is the one the user picked, not the alias used to invoke it.");

var scaffold = ScaffoldInvocation();
Assert.IsNotNull(scaffold);
Assert.AreEqual("reactor-blank", scaffold[1], "dotnet new must receive an alias no other installed pack registers.");
}

[TestMethod]
public async Task Handler_EveryAliasRegisteredByAnotherPack_FailsNamingThePack()
{
ScriptWithStandaloneReactorPack("reactor,reactor-blank,winui-reactor");
var command = GetRequiredService<NewCommand>();

var exitCode = await ParseAndInvokeWithCaptureAsync(
command, ["--use-defaults", "--json", "--template", "reactor", "--name", "MyApp"]);

Assert.AreEqual(NewCommand.ExitTemplatePackFailed, exitCode);
var json = ParseJson(TestAnsiConsole.Output);
Assert.IsFalse(json.GetProperty("Created").GetBoolean());
var error = json.GetProperty("Error").GetString() ?? string.Empty;
StringAssert.Contains(error, "Microsoft.UI.Reactor.Templates");
StringAssert.Contains(error, "dotnet new uninstall Microsoft.UI.Reactor.Templates");
Assert.IsNull(ScaffoldInvocation(), "No scaffold may run when dotnet new can't select the template.");
}


[TestMethod]
public async Task Handler_AppTemplate_PrintsWinappRunNextStep()
Expand Down
100 changes: 100 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/WinUiTemplateCatalogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,106 @@ public void RestrictToPack_NoPackRows_KeepsNothing()
Assert.AreEqual(0, WinUiTemplateCatalog.RestrictToPack(listed, []).Count);
}

private const string ReactorPackageId = "Microsoft.UI.Reactor.Templates";

private const string ConflictingUninstallOutput =
"Currently installed items:\n" +
" Microsoft.UI.Reactor.Templates\n" +
" Version: 0.1.0\n" +
" Details:\n" +
" Author: Microsoft\n" +
" Templates:\n" +
" Microsoft.UI.Reactor App (reactor) C#\n" +
" Uninstall Command:\n" +
" dotnet new uninstall Microsoft.UI.Reactor.Templates\n" +
" Microsoft.WindowsAppSDK.WinUI.CSharp.Templates\n" +
" Version: 0.0.6-alpha\n" +
" Details:\n" +
" Author: Microsoft\n" +
" Templates:\n" +
" WinUI Blank App (winui,winui3,wasdk-single) C#\n" +
" Reactor Blank App (Experimental) (reactor,reactor-blank,winui-reactor) C#\n" +
" Uninstall Command:\n" +
" dotnet new uninstall Microsoft.WindowsAppSDK.WinUI.CSharp.Templates\n" +
" Contoso.NoTemplates\n" +
" Version: 1.0.0\n" +
" Uninstall Command:\n" +
" dotnet new uninstall Contoso.NoTemplates\n";

private static readonly string[] ExpectedInstalledPackIds = [ReactorPackageId, PackageId];
private static readonly string[] ExpectedReactorBlankAliases = ["reactor", "reactor-blank", "winui-reactor"];
private static readonly string[] ExpectedSharedReactorAlias = ["reactor"];
private static readonly string[] ExpectedConflictingPacks = [ReactorPackageId];

[TestMethod]
public void ParseInstalledPacks_ReturnsEveryPackWithItsTemplates()
{
var packs = WinUiTemplateCatalog.ParseInstalledPacks(ConflictingUninstallOutput);

CollectionAssert.AreEqual(ExpectedInstalledPackIds, packs.Select(p => p.PackageId).ToArray(),
"A pack without a Templates block contributes no aliases and is omitted.");
CollectionAssert.AreEqual(ExpectedReactorBlankAliases, packs[1].Templates[1].Aliases.ToArray());
Assert.AreEqual("Microsoft.UI.Reactor App", packs[0].Templates[0].DisplayName);
}

[TestMethod]
public void ParsePackTemplates_OtherPacksBeforeIt_StillReturnsOnlyItsTemplates()
{
var rows = WinUiTemplateCatalog.ParsePackTemplates(ConflictingUninstallOutput, PackageId);

Assert.AreEqual(2, rows.Count);
Assert.IsFalse(rows.Any(r => r.DisplayName == "Microsoft.UI.Reactor App"));
}

[TestMethod]
public void MarkSharedAliases_AliasAlsoInAnotherPack_ScaffoldsWithFirstUniqueAlias()
{
// `dotnet new reactor` is ambiguous when the standalone Reactor pack is installed too, so the
// WinUI pack's template must be scaffolded by an alias only it owns.
var entries = new List<WinUiTemplateEntry>
{
Entry("WinUI Blank App", "winui,winui3,wasdk-single"),
Entry("Reactor Blank App (Experimental)", "reactor,reactor-blank,winui-reactor"),
};

var marked = WinUiTemplateCatalog.MarkSharedAliases(
entries, WinUiTemplateCatalog.ParseInstalledPacks(ConflictingUninstallOutput), PackageId);

Assert.AreEqual("winui", marked[0].ScaffoldShortName);
Assert.AreEqual(0, marked[0].ConflictingPackages.Count);

Assert.AreEqual("reactor", marked[1].ShortName, "The canonical short name shown to the user is unchanged.");
Assert.AreEqual("reactor-blank", marked[1].ScaffoldShortName);
CollectionAssert.AreEqual(ExpectedSharedReactorAlias, marked[1].SharedAliases.ToArray());
CollectionAssert.AreEqual(ExpectedConflictingPacks, marked[1].ConflictingPackages.ToArray());
}

[TestMethod]
public void MarkSharedAliases_EveryAliasShared_HasNoScaffoldShortName()
{
var output = ConflictingUninstallOutput.Replace(
"Microsoft.UI.Reactor App (reactor) C#",
"Microsoft.UI.Reactor App (reactor,reactor-blank,winui-reactor) C#",
StringComparison.Ordinal);
var entries = new List<WinUiTemplateEntry> { Entry("Reactor Blank App (Experimental)", "reactor,reactor-blank,winui-reactor") };

var marked = WinUiTemplateCatalog.MarkSharedAliases(entries, WinUiTemplateCatalog.ParseInstalledPacks(output), PackageId);

Assert.IsNull(marked[0].ScaffoldShortName);
CollectionAssert.AreEqual(ExpectedConflictingPacks, marked[0].ConflictingPackages.ToArray());
}

[TestMethod]
public void MarkSharedAliases_NoOtherPacks_LeavesEntriesUnchanged()
{
var entries = new List<WinUiTemplateEntry> { Entry("WinUI Blank App", "winui,winui3,wasdk-single") };

var marked = WinUiTemplateCatalog.MarkSharedAliases(entries, WinUiTemplateCatalog.ParseInstalledPacks(UninstallOutput), PackageId);

Assert.AreEqual("winui", marked[0].ScaffoldShortName);
Assert.AreEqual(0, marked[0].SharedAliases.Count);
}

[TestMethod]
public void DeriveTfmOption_TemplateNotInThisCache_ReturnsNotFound()
{
Expand Down
40 changes: 36 additions & 4 deletions src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,35 @@ private async Task<int> InvokeCoreAsync(ParseResult parseResult, InvocationTelem
tel.Template = entry.ShortName;
tel.TemplateIsItem = entry.IsItem;

// 1a. Resolve the target-framework pin now, before any name prompt, so a template the
// 1a. dotnet new selects a template by short name and refuses one that matches templates
// from more than one installed pack. When another pack registers every alias this template
// has, there is no name dotnet new can scaffold it by, so stop before prompting for a name.
var scaffoldShortName = entry.ScaffoldShortName;
if (scaffoldShortName is null)
{
var packs = string.Join(", ", entry.ConflictingPackages);
var uninstall = string.Join(" and ", entry.ConflictingPackages.Select(p => $"'dotnet new uninstall {p}'"));
var conflictError = $"Template '{entry.ShortName}' can't be created because every short name it has ({string.Join(", ", entry.ShortNames)}) "
+ $"is also registered by another installed template pack ({packs}), so 'dotnet new' can't tell them apart. "
+ $"Remove the other pack with {uninstall}, then re-run 'winapp new'.";
if (isJson)
{
PrintJson(false, entry.ShortName, name ?? string.Empty, (output ?? currentDir).FullName, conflictError, entry.IsExperimental);
}
else
{
logger.LogError("{Error} {Detail}", UiSymbols.Error, conflictError);
}
return ExitTemplatePackFailed;
}

if (!string.Equals(scaffoldShortName, entry.ShortName, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("'{ShortName}' is also registered by {Packs}; scaffolding with '{Alias}' instead.",
entry.ShortName, string.Join(", ", entry.ConflictingPackages), scaffoldShortName);
}

// 1b. Resolve the target-framework pin now, before any name prompt, so a template the
// installed SDK is too old for (the Reactor templates require .NET 10) fails immediately
// with an actionable message instead of scaffolding a project that cannot be built.
var frameworkArgs = new List<string>();
Expand Down Expand Up @@ -681,9 +709,9 @@ private async Task<int> InvokeCoreAsync(ParseResult parseResult, InvocationTelem

// Pass each token via ArgumentList (injection-safe) so a crafted --name or --output cannot
// inject additional dotnet new options.
var args = new List<string> { "new", entry.ShortName, "-n", name!, "-o", outputDir.FullName };
var args = new List<string> { "new", scaffoldShortName, "-n", name!, "-o", outputDir.FullName };

// Target framework, resolved and validated in step 1a (empty for item templates, which take
// Target framework, resolved and validated in step 1b (empty for item templates, which take
// no framework, and for project templates whose metadata declares nothing to pin).
args.AddRange(frameworkArgs);

Expand Down Expand Up @@ -1119,7 +1147,11 @@ internal static async Task<T> AwaitWithDelayedActionAsync<T>(
return ([], $"Could not verify which templates '{TemplatePackageId}' owns: no template list for it in 'dotnet new uninstall' output. Re-run with --verbose to see that output.");
}

return (WinUiTemplateCatalog.RestrictToPack(parsed, packRows), null);
// Another installed pack (e.g. the standalone Microsoft.UI.Reactor.Templates) may register
// the same aliases; dotnet new refuses a short name that matches templates from more than
// one pack, so record which aliases are shared and scaffold with one that isn't.
var owned = WinUiTemplateCatalog.RestrictToPack(parsed, packRows);
return (WinUiTemplateCatalog.MarkSharedAliases(owned, WinUiTemplateCatalog.ParseInstalledPacks(packOutput), TemplatePackageId), null);
}

private async Task<WinUiTemplateEntry> PromptTemplateAsync(IReadOnlyList<WinUiTemplateEntry> templates, CancellationToken cancellationToken)
Expand Down
Loading
Loading