@@ -1664,6 +1664,285 @@ public async Task NewCommand_WhenTypeScriptTemplateApplyFails_ReturnsNonZeroExit
16641664 Assert . NotEqual ( 0 , exitCode ) ;
16651665 Assert . NotNull ( testInteractionService ) ;
16661666 }
1667+
1668+ [ Fact ]
1669+ public async Task NewCommandInExtensionModeAppendsProjectNameToOutputPath ( )
1670+ {
1671+ using var workspace = TemporaryWorkspace . Create ( outputHelper ) ;
1672+ string ? capturedOutputPath = null ;
1673+
1674+ var services = CliTestHelper . CreateServiceCollection ( workspace , outputHelper , options =>
1675+ {
1676+ options . InteractionServiceFactory = sp => new TestExtensionInteractionService ( sp ) ;
1677+ options . ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
1678+ {
1679+ HasCapabilityAsyncCallback = ( c , _ ) => Task . FromResult ( c is "baseline.v1" ) ,
1680+ } ;
1681+
1682+ options . NewCommandPrompterFactory = ( sp ) =>
1683+ {
1684+ var interactionService = sp . GetRequiredService < IInteractionService > ( ) ;
1685+ var prompter = new TestNewCommandPrompter ( interactionService ) ;
1686+
1687+ prompter . PromptForProjectNameCallback = ( _ ) => "MyFirstApp" ;
1688+
1689+ // Simulate the user picking a parent folder (not named after the project)
1690+ prompter . PromptForOutputPathCallback = ( _ ) =>
1691+ Path . Combine ( workspace . WorkspaceRoot . FullName , "source" ) ;
1692+
1693+ return prompter ;
1694+ } ;
1695+
1696+ options . DotNetCliRunnerFactory = ( sp ) =>
1697+ {
1698+ var runner = new TestDotNetCliRunner ( ) ;
1699+ runner . SearchPackagesAsyncCallback = ( dir , query , prerelease , take , skip , nugetSource , useCache , options , cancellationToken ) =>
1700+ {
1701+ var package = new NuGetPackage ( )
1702+ {
1703+ Id = "Aspire.ProjectTemplates" ,
1704+ Source = "nuget" ,
1705+ Version = "9.2.0"
1706+ } ;
1707+ return ( 0 , new NuGetPackage [ ] { package } ) ;
1708+ } ;
1709+ runner . InstallTemplateAsyncCallback = ( packageName , version , nugetSource , force , invocationOptions , ct ) =>
1710+ {
1711+ return ( 0 , version ) ;
1712+ } ;
1713+ runner . NewProjectAsyncCallback = ( templateName , projectName , outputPath , invocationOptions , ct ) =>
1714+ {
1715+ capturedOutputPath = outputPath ;
1716+ return 0 ;
1717+ } ;
1718+ return runner ;
1719+ } ;
1720+ } ) ;
1721+ var provider = services . BuildServiceProvider ( ) ;
1722+
1723+ var command = provider . GetRequiredService < RootCommand > ( ) ;
1724+ var result = command . Parse ( "new aspire-starter --use-redis-cache --test-framework None" ) ;
1725+
1726+ var exitCode = await result . InvokeAsync ( ) . DefaultTimeout ( ) ;
1727+
1728+ Assert . Equal ( 0 , exitCode ) ;
1729+ Assert . NotNull ( capturedOutputPath ) ;
1730+
1731+ // Output path should have the project name appended as a subdirectory
1732+ var expectedPath = Path . Combine ( workspace . WorkspaceRoot . FullName , "source" , "MyFirstApp" ) ;
1733+ Assert . Equal ( expectedPath , capturedOutputPath ) ;
1734+ }
1735+
1736+ [ Fact ]
1737+ public async Task NewCommandInExtensionModeDoesNotDoubleAppendProjectName ( )
1738+ {
1739+ using var workspace = TemporaryWorkspace . Create ( outputHelper ) ;
1740+ string ? capturedOutputPath = null ;
1741+
1742+ var services = CliTestHelper . CreateServiceCollection ( workspace , outputHelper , options =>
1743+ {
1744+ options . InteractionServiceFactory = sp => new TestExtensionInteractionService ( sp ) ;
1745+ options . ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
1746+ {
1747+ HasCapabilityAsyncCallback = ( c , _ ) => Task . FromResult ( c is "baseline.v1" ) ,
1748+ } ;
1749+
1750+ options . NewCommandPrompterFactory = ( sp ) =>
1751+ {
1752+ var interactionService = sp . GetRequiredService < IInteractionService > ( ) ;
1753+ var prompter = new TestNewCommandPrompter ( interactionService ) ;
1754+
1755+ prompter . PromptForProjectNameCallback = ( _ ) => "MyFirstApp" ;
1756+
1757+ // Simulate the user picking a folder already named after the project
1758+ prompter . PromptForOutputPathCallback = ( _ ) =>
1759+ Path . Combine ( workspace . WorkspaceRoot . FullName , "source" , "MyFirstApp" ) ;
1760+
1761+ return prompter ;
1762+ } ;
1763+
1764+ options . DotNetCliRunnerFactory = ( sp ) =>
1765+ {
1766+ var runner = new TestDotNetCliRunner ( ) ;
1767+ runner . SearchPackagesAsyncCallback = ( dir , query , prerelease , take , skip , nugetSource , useCache , options , cancellationToken ) =>
1768+ {
1769+ var package = new NuGetPackage ( )
1770+ {
1771+ Id = "Aspire.ProjectTemplates" ,
1772+ Source = "nuget" ,
1773+ Version = "9.2.0"
1774+ } ;
1775+ return ( 0 , new NuGetPackage [ ] { package } ) ;
1776+ } ;
1777+ runner . InstallTemplateAsyncCallback = ( packageName , version , nugetSource , force , invocationOptions , ct ) =>
1778+ {
1779+ return ( 0 , version ) ;
1780+ } ;
1781+ runner . NewProjectAsyncCallback = ( templateName , projectName , outputPath , invocationOptions , ct ) =>
1782+ {
1783+ capturedOutputPath = outputPath ;
1784+ return 0 ;
1785+ } ;
1786+ return runner ;
1787+ } ;
1788+ } ) ;
1789+ var provider = services . BuildServiceProvider ( ) ;
1790+
1791+ var command = provider . GetRequiredService < RootCommand > ( ) ;
1792+ var result = command . Parse ( "new aspire-starter --use-redis-cache --test-framework None" ) ;
1793+
1794+ var exitCode = await result . InvokeAsync ( ) . DefaultTimeout ( ) ;
1795+
1796+ Assert . Equal ( 0 , exitCode ) ;
1797+ Assert . NotNull ( capturedOutputPath ) ;
1798+
1799+ // Output path should NOT have the project name double-appended
1800+ var expectedPath = Path . Combine ( workspace . WorkspaceRoot . FullName , "source" , "MyFirstApp" ) ;
1801+ Assert . Equal ( expectedPath , capturedOutputPath ) ;
1802+ }
1803+
1804+ [ Fact ]
1805+ public async Task NewCommandInConsoleModeDoesNotAppendProjectName ( )
1806+ {
1807+ using var workspace = TemporaryWorkspace . Create ( outputHelper ) ;
1808+ string ? capturedOutputPath = null ;
1809+
1810+ var services = CliTestHelper . CreateServiceCollection ( workspace , outputHelper , options =>
1811+ {
1812+ // Default InteractionServiceFactory creates ConsoleInteractionService (not extension mode)
1813+
1814+ options . NewCommandPrompterFactory = ( sp ) =>
1815+ {
1816+ var interactionService = sp . GetRequiredService < IInteractionService > ( ) ;
1817+ var prompter = new TestNewCommandPrompter ( interactionService ) ;
1818+
1819+ prompter . PromptForProjectNameCallback = ( _ ) => "MyFirstApp" ;
1820+
1821+ // Simulate user accepting default path or selecting parent folder
1822+ prompter . PromptForOutputPathCallback = ( _ ) =>
1823+ Path . Combine ( workspace . WorkspaceRoot . FullName , "source" ) ;
1824+
1825+ return prompter ;
1826+ } ;
1827+
1828+ options . DotNetCliRunnerFactory = ( sp ) =>
1829+ {
1830+ var runner = new TestDotNetCliRunner ( ) ;
1831+ runner . SearchPackagesAsyncCallback = ( dir , query , prerelease , take , skip , nugetSource , useCache , options , cancellationToken ) =>
1832+ {
1833+ var package = new NuGetPackage ( )
1834+ {
1835+ Id = "Aspire.ProjectTemplates" ,
1836+ Source = "nuget" ,
1837+ Version = "9.2.0"
1838+ } ;
1839+ return ( 0 , new NuGetPackage [ ] { package } ) ;
1840+ } ;
1841+ runner . InstallTemplateAsyncCallback = ( packageName , version , nugetSource , force , invocationOptions , ct ) =>
1842+ {
1843+ return ( 0 , version ) ;
1844+ } ;
1845+ runner . NewProjectAsyncCallback = ( templateName , projectName , outputPath , invocationOptions , ct ) =>
1846+ {
1847+ capturedOutputPath = outputPath ;
1848+ return 0 ;
1849+ } ;
1850+ return runner ;
1851+ } ;
1852+ } ) ;
1853+ var provider = services . BuildServiceProvider ( ) ;
1854+
1855+ var command = provider . GetRequiredService < RootCommand > ( ) ;
1856+ var result = command . Parse ( "new aspire-starter --use-redis-cache --test-framework None" ) ;
1857+
1858+ var exitCode = await result . InvokeAsync ( ) . DefaultTimeout ( ) ;
1859+
1860+ Assert . Equal ( 0 , exitCode ) ;
1861+ Assert . NotNull ( capturedOutputPath ) ;
1862+
1863+ // In console mode, the output path should NOT have project name appended
1864+ var expectedPath = Path . Combine ( workspace . WorkspaceRoot . FullName , "source" ) ;
1865+ Assert . Equal ( expectedPath , capturedOutputPath ) ;
1866+ }
1867+
1868+ [ Fact ]
1869+ public async Task NewCommandInExtensionModeHandlesTrailingDirectorySeparator ( )
1870+ {
1871+ const string projectName = "MyFirstApp" ;
1872+
1873+ async Task AssertOutputPathAsync ( Func < string , string > selectedPathFactory , Func < string , string > expectedPathFactory )
1874+ {
1875+ using var workspace = TemporaryWorkspace . Create ( outputHelper ) ;
1876+ string ? capturedOutputPath = null ;
1877+
1878+ var services = CliTestHelper . CreateServiceCollection ( workspace , outputHelper , options =>
1879+ {
1880+ options . InteractionServiceFactory = sp => new TestExtensionInteractionService ( sp ) ;
1881+ options . ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
1882+ {
1883+ HasCapabilityAsyncCallback = ( c , _ ) => Task . FromResult ( c is "baseline.v1" ) ,
1884+ } ;
1885+
1886+ options . NewCommandPrompterFactory = ( sp ) =>
1887+ {
1888+ var interactionService = sp . GetRequiredService < IInteractionService > ( ) ;
1889+ var prompter = new TestNewCommandPrompter ( interactionService ) ;
1890+
1891+ prompter . PromptForProjectNameCallback = ( _ ) => projectName ;
1892+
1893+ prompter . PromptForOutputPathCallback = ( _ ) =>
1894+ selectedPathFactory ( workspace . WorkspaceRoot . FullName ) ;
1895+
1896+ return prompter ;
1897+ } ;
1898+
1899+ options . DotNetCliRunnerFactory = ( sp ) =>
1900+ {
1901+ var runner = new TestDotNetCliRunner ( ) ;
1902+ runner . SearchPackagesAsyncCallback = ( dir , query , prerelease , take , skip , nugetSource , useCache , options , cancellationToken ) =>
1903+ {
1904+ var package = new NuGetPackage ( )
1905+ {
1906+ Id = "Aspire.ProjectTemplates" ,
1907+ Source = "nuget" ,
1908+ Version = "9.2.0"
1909+ } ;
1910+ return ( 0 , new NuGetPackage [ ] { package } ) ;
1911+ } ;
1912+ runner . InstallTemplateAsyncCallback = ( packageName , version , nugetSource , force , invocationOptions , ct ) =>
1913+ {
1914+ return ( 0 , version ) ;
1915+ } ;
1916+ runner . NewProjectAsyncCallback = ( templateName , pName , outputPath , invocationOptions , ct ) =>
1917+ {
1918+ capturedOutputPath = outputPath ;
1919+ return 0 ;
1920+ } ;
1921+ return runner ;
1922+ } ;
1923+ } ) ;
1924+ var provider = services . BuildServiceProvider ( ) ;
1925+
1926+ var command = provider . GetRequiredService < RootCommand > ( ) ;
1927+ var result = command . Parse ( "new aspire-starter --use-redis-cache --test-framework None" ) ;
1928+
1929+ var exitCode = await result . InvokeAsync ( ) . DefaultTimeout ( ) ;
1930+
1931+ Assert . Equal ( 0 , exitCode ) ;
1932+ Assert . NotNull ( capturedOutputPath ) ;
1933+ Assert . Equal ( expectedPathFactory ( workspace . WorkspaceRoot . FullName ) , capturedOutputPath ) ;
1934+ }
1935+
1936+ // Trailing separator on a parent folder should still append the project name once.
1937+ await AssertOutputPathAsync (
1938+ workspaceRoot => Path . Combine ( workspaceRoot , "source" ) + Path . DirectorySeparatorChar ,
1939+ workspaceRoot => Path . Combine ( workspaceRoot , "source" , projectName ) ) ;
1940+
1941+ // Trailing separator on a folder already named after the project should not double-append.
1942+ await AssertOutputPathAsync (
1943+ workspaceRoot => Path . Combine ( workspaceRoot , projectName ) + Path . DirectorySeparatorChar ,
1944+ workspaceRoot => Path . Combine ( workspaceRoot , projectName ) ) ;
1945+ }
16671946}
16681947
16691948internal sealed class TestNewCommandPrompter ( IInteractionService interactionService ) : NewCommandPrompter ( interactionService )
0 commit comments