Roslyn CodeFix - MSBuild Properties/metadata and Unit testing - roslyn

I'm building a roslyn analyzer/code fix but I wan't to access the MSBuild Properties and metadata (both from Directory.build.props and the .csproj) in order to know how to apply the code fix. I only found documentation to do it in source generators but not for analyzers.
To be more specific I want to know if the project is configured to use the new ImplicitUsings, but it would be usefull to also have access to everything.
Also do we have any way to get all the project global usings?
And using the new Microsoft.CodeAnalysis.Testing how can I add the MSBuild property so I can actually test it?
Regards.

Accessing MSBuild properties and metadata in DiagnosticAnalyzers is actually quite similar to how they're read and tested in ISourceGenerators/IIncrementalGenerators, since Source Generators are technically .NET analyzers as well.
I assume that the documentation you've mentioned is the Source Generators Cookbook.
First, we need to make the MSBuild property available to the global analyzer config options of the analyzer:
<Project>
<ItemGroup>
<CompilerVisibleProperty Include="MyAnalyzer_MyProperty" />
</ItemGroup>
</Project>
Then, we may read the value of that property from the AnalyzerConfigOptionsProvider's GlobalOptions. You'll find it within the parameter of the AnalysisContext.Register* method of your choice that you use within your overridden DiagnosticAnalyzer.Initialize(AnalysisContext) method.
For example RegisterCompilationAction:
bool isEnabled = false;
if (compilationAnalysisContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.MyAnalyzer_MyProperty", out string? value))
{
isEnabled = value.Equals("true", StringComparison.OrdinalIgnoreCase) || value.Equals("enable", StringComparison.OrdinalIgnoreCase);
}
ImmutableDictionary<string, string?>.Builder properties = ImmutableDictionary.CreateBuilder<string, string?>();
if (isEnabled)
{
properties.Add("IsEnabled", value);
}
var diagnostic = Diagnostic.Create(Rule, location, properties.ToImmutable());
compilationAnalysisContext.ReportDiagnostic(diagnostic);
The CodeFixProvider's CodeFixContext does not have a dedicated AnalyzerOptions Options property, but you may pass the value via the Diagnostic.Properties:
foreach (Diagnostic diagnostic in context.Diagnostics)
{
if (diagnostic.Properties.TryGetValue("IsEnabled", out string? value))
{
var action = CodeAction.Create(Title, cancellationToken => OnCreateChangedDocument(context.Document, cancellationToken), diagnostic.Id);
context.RegisterCodeFix(action, diagnostic);
}
}
... or, what I just discovered while composing this answer, access the AnalyzerConfigOptionsProvider through CodeFixContext.Document.Project.AnalyzerOptions. This works wherever you have a Document (or Project) available:
public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
bool hasValue = context.Document.Project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.MyAnalyzer_MyProperty", out string? value);
}
Additionally, it works with CodeRefactoringProvider:
public override Task ComputeRefactoringsAsync(CodeRefactoringContext context)
{
bool hasValue = context.Document.Project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.MyAnalyzer_MyProperty", out string? value);
return Task.CompletedTask;
}
... and with CompletionProvider:
public override Task ProvideCompletionsAsync(CompletionContext context)
{
bool hasValue = context.Document.Project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.MyAnalyzer_MyProperty", out string? value);
return Task.CompletedTask;
}
... and also with DiagnosticSuppressor:
public override void ReportSuppressions(SuppressionAnalysisContext context)
{
bool hasValue = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.MyAnalyzer_MyProperty", out string? value);
}
And for testing via Microsoft.CodeAnalysis.Testing, you can add the global analyzer config option via ProjectState.AnalyzerConfigFiles of the AnalyzerTest<TVerifier>'s SolutionState TestState property:
string config = $"is_global = true{Environment.NewLine}build_property.MyAnalyzer_MyProperty = {true}";
analyzerTest.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", config));
Above I described the usage with the custom MSBuild property MyAnalyzer_MyProperty, but of course it works with the well-known ImplicitUsings property too.

Related

How to Unit Test a Method in Sitecore MVC having tightly coupled Dependency on two distinct Sitecore Contexts?

I have to unfortunately write Unit Tests for a legacy Sitecore MVC code base where two distinct Sitecore Contexts are called. I understand this comes under Integration Testing but i don't have the option of educating my project Leads on that front. So i have chosen to use FakeDb for emulating Sitecore Instance and NSubstitute for substituting injected Dependencies (can't use any Profilier API Frameworks like MS Fakes, TypeMock etc because of Budget constraints). I am providing the code below:
Method to be UnitTested
public bool DubiousMethod()
{
// This HttpContext call is pain area 1. This gets resolved when i call it using ItemContextSwitcher in Unit Tests.
string currentUrl = HttpContext.Current.Request.RawUrl;
// This Sitecore Context call to Site Name is pain area 2. This gets resolved when Unit Tests are run under SiteContextSwitcher.
string siteName = Sitecore.Context.Site.Name;
return true/False;
}
Unit Test Method
[Fact]
public void DubiousMethodUT()
{
// create a fake site context
var fakeSite = new Sitecore.FakeDb.Sites.FakeSiteContext(
new Sitecore.Collections.StringDictionary
{
{ "name", "website" }, { "database", "web" }, { "rootPath", "/sitecore/content/home" },
{ "contentStartItem", "home"}, {"hostName","https://www.myorignalsiteurl.com"}
});
using (new Sitecore.Sites.SiteContextSwitcher(fakeSite))
{
//DubiousClassObject.DubiousMethod(home) // When Debugging after uncommenting this line i get correct value in **Sitecore.Context.Site.Name**
using (Sitecore.FakeDb.Db db = new Sitecore.FakeDb.Db
{
new Sitecore.FakeDb.DbItem("home") { { "Title", "Welcome!" } ,
new Sitecore.FakeDb.DbItem("blogs") }
})
{
Sitecore.Data.Items.Item home = db.GetItem("/sitecore/content/home");
//bool abc = confBlogUT.IsBlogItem(home);
using (new ContextItemSwitcher(home))
{
string siteName = Sitecore.Context.Site.Name;
var urlOptions = new Sitecore.Links.UrlOptions();
urlOptions.AlwaysIncludeServerUrl = true;
var pageUrl = Sitecore.Links.LinkManager.GetItemUrl(Sitecore.Context.Item, urlOptions);
HttpContext.Current = new HttpContext(new HttpRequest("", pageUrl.Substring(3), ""), new HttpResponse(new StringWriter()));
Assert.False(DubiousClassObject.DubiousMethod(home); //When Debugging after commenting above DubiousMethodCall i get correct value for **HttpContext.Current.Request.RawUrl**
}
}
}
}
As you can observe that when i try to call the method from FakSiteContext then i am getting the correct value for Sitecore.Context.Site.Name however my code breaks when HttpContext.Current.Request.RawUrl is invoked in the method. Opposite happens when i invoke the method from ContextItemSwitcher(FakeItem) context. So far i have not been able to find a way to merge both the Contexts (which i believe is impossible in Sitecore). Can anyone suggest if i run my Unit Tests in an overarching context where i am able to contrl fakeSite Variables as well as FakeItem context variables as well and by extensions any other Sitecore Context calls?
Any help would be appreciated.
I'd recommend to take a look at Unit testing in Sitecore article as it seem to be what you need.
In short - you'll need to do a few adjustments in your code to make it testable:
1) Replace static HttpContext with abstract HttpContextBase (impl. HttpContextWrapper) so that everything can be arranged - DubiousMethod gets an overload that accepts DubiousMethod(HttpContextBase httpContext).
2) As for Sitecore Context data - it has Sitecore.Caching.ItemsContext-bound semantics (as mentioned in the article), so you could cleanup the collection before/after each test to get a sort of isolation between tests.
Alternatively you could bake a similar wrapper for Sitecore.Context as ASP.NET team had done for HttpContext -> HttpContextBase & impl HttpContextWrapper.

How to attach Sitecore context for controller action mappled to route robots.txt?

In Sitecore I'm trying to set up a way for our client to modify the robots.txt file from the content tree. I am attempting to set up a MVC controller action that is mappled to route "robots.txt" and will return the file contents. My controller looks like this:
public class SeoController : BaseController
{
private readonly IContentService _contentService;
private readonly IPageContext _pageContext;
private readonly IRenderingContext _renderingContext;
public SeoController(IContentService contentService, IPageContext pageContext, IRenderingContext renderingContext, ISitecoreContext glassContext)
: base(glassContext)
{
_contentService = contentService;
_pageContext = pageContext;
_renderingContext = renderingContext;
}
public FileContentResult Robots()
{
string content = string.Empty;
var contentResponse = _contentService.GetRobotsTxtContent();
if (contentResponse.Success && contentResponse.ContentItem != null)
{
content = contentResponse.ContentItem.RobotsText;
}
return File(Encoding.UTF8.GetBytes(content), "text/plain");
}
}
And the route config:
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
RouteTable.Routes.MapRoute("Robots.txt", "robots.txt", new { controller = "Seo", action = "Robots" });
}
}
This all works great if I use a route without the ".txt" extension. However after adding the extension I get a null reference exception in the domain layer due to the context database being null. Here's where the error happens:
public Item GetItem(string contentGuid)
{
return Sitecore.Context.Database.GetItem(contentGuid);
}
I'm assuming that there is a setting in sitecore that ignores the .txt extension. I've tried adding it as an allowed extension in the Sitecore.Pipelines.PreprocessRequest.FilterUrlExtensions setting of the config. Is there anything else I could be missing?
Ok, I found the issue. I was correct in assuming that txt needed to be added to the allowed extensions for the Sitecore.Pipelines.PreprocessRequest.FilterUrlExtensions setting. However robots.txt was listed under the IgnoreUrlPrefixes setting in the config file. That was causing sitecore to ignore that request. I removed it from that list and it's working great now.
This is a pure guess, but you might also have to add it to the allowed extensions of Sitecore.Pipelines.HttpRequest.FilterUrlExtensions in httpRequestBegin as well.

Autoroute Bulk operations in Orchard

If you customize an autoroute part you have the option to recreate the url on each save.
The help text under this option says:
"Automatically regenerate when editing content
This option will cause the Url to automatically be regenerated when you edit existing content and publish it again, otherwise it will always keep the old route, or you have to perform bulk update in the Autoroute admin."
I have digged all around but I cannot find anywhere an "Autoroute admin".
Is it really there?
It was a proposed feature never implemented?
Any idea to do a bulk update even without an Admin page?
Thanks
EDIT after #joshb suggestion...
I have tried to implement a bulk operation in my controller.
var MyContents = _contentManager.Query<MyContentPart, MyContentPartRecord>().List().ToList();
foreach (var MyContent in MyContents) {
var autoroutePart = recipe.ContentItem.As<AutoroutePart>();
autoroutePart.UseCustomPattern = false;
autoroutePart.DisplayAlias = _autorouteService.GenerateAlias(autoroutePart);
_contentManager.Publish(autoroutePart.ContentItem);
}
In this way it recreates all aliases for the types that contain the given part MyContentPart.
With some more work this code can be encapsulated in a command or in a new tab in Alias UI.
After finished the current project I'm doing I will try that...
You could create a module and implement a command that does a bulk update. Shouldn't be too much work if you're comfortable creating modules. You'll need to implement DefaultOrchardCommandHandler and inject IContentManager to get all the parts you're interested in.
Enable Alias UI in the modules section will give you the admin section for managing routes, however I'm not sure what kind of bulk updates it offers
Publishing the ContentItem will do nothing if it is already Published (as it was in my case).
Instead, one could call the PublishAlias method on the AutorouteService. I ended up with a Controller, something like this:
using Orchard;
using Orchard.Autoroute.Models;
using Orchard.Autoroute.Services;
using Orchard.ContentManagement;
using Orchard.Localization;
using Orchard.Security;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
namespace MyNamespace.MyModule.Controllers {
public class AutorouteBulkUpdateController : Controller {
private readonly IOrchardServices _orchardServices;
private readonly IAutorouteService _autorouteService;
private Localizer T { get; set; }
public AutorouteBulkUpdateController(IOrchardServices orchardServices, IAutorouteService autorouteService) {
_orchardServices = orchardServices;
_autorouteService = autorouteService;
T = NullLocalizer.Instance;
}
public ActionResult Index() {
if (!_orchardServices.Authorizer.Authorize(StandardPermissions.SiteOwner, T("Not authorized to manage settings"))) {
return new HttpUnauthorizedResult();
}
int count = 0;
IEnumerable<AutoroutePart> contents;
do {
//contents = _orchardServices.ContentManager.Query<AutoroutePart>(VersionOptions.Latest, new string[] { "Page" }).Slice(count * 100, 100).ToList();
contents = _orchardServices.ContentManager.Query<AutoroutePart>(VersionOptions.Latest).Slice(count * 100, 100).ToList();
foreach (var autoroutePart in contents) {
var alias = _autorouteService.GenerateAlias(autoroutePart);
if (autoroutePart.DisplayAlias != alias) {
autoroutePart.UseCustomPattern = false;
autoroutePart.DisplayAlias = alias;
_autorouteService.PublishAlias(autoroutePart);
}
}
_orchardServices.TransactionManager.RequireNew();
_orchardServices.ContentManager.Clear();
count += 1;
} while (contents.Any());
return null;
}
}
}

How do I explicitly specify where a Nancy view is located?

First of all I know what the problem is, I just don't know Nancy well enough to know how to fix it.
I have a unit test failing when as part of the appharbor build process. The same test also fails when NCrunch executes it. But, when executed by VS2012 it works fine.
The test looks like this:
[Test]
public void Get_Root_Should_Return_Status_OK()
{
// Given
var browser = new Browser(new Bootstrapper());
// When
var result = browser.Get("/");
// Then
Assert.AreEqual(HttpStatusCode.OK, result.StatusCode);
}
HomeModule part handling the "/" route looks like this:
Get["/"] = _ => View["home.sshtml"];
home.sshtml is in the Views folder.
If I replace the above with:
Get["/"] = _ => "Hello World!;
Then the test goes green.
So plainly the problem is that when running the test in NCrunch and appharbor the home.sshtml file cannot be found.
How do I explicitly tell Nancy where the file is?
PS The view file is being copied to the output directory.
PPS I have also tried explicitly telling Nancy where the Views are like and that doesn't work either.
protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines)
{
var directoryInfo = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory;
if (directoryInfo != null)
Environment.CurrentDirectory = directoryInfo.FullName;
Conventions.ViewLocationConventions.Add((viewName, model, viewLocationContext) => String.Concat("Views/", viewName));
}
The problem is due to the fact that NCrunch doesn't copy the views to the output directory when it compiles and copies the bin directory for running the tests.
What you need to do is set the views to Copy Always, and then in your unit testing project add a IRootPathProvider implementation:
public class StaticPathProvider : IRootPathProvider
{
public static string Path { get; set; }
public string GetRootPath()
{
return Path;
}
}
(Not entirely sure on the path, I can't remember, think it's just where the executing assembly is)
And register that in your bootstrapper for unit tests.
var browserParser = new Browser(with =>
{
...
with.RootPathProvider<StaticPathProvider>();
...
});
Downside is when deploying you need to delete the view directory from your /bin directory.
The alternative is to do what you've already done, embed your views.

Change Template of Uploaded File in Media Library in Sitecore

I've done some research into this but not sure I understand all the pieces that need to go into the following problem.
My client needs a special template to be used instead of the auto detected media templates in the Media Library if they upload to a certain Folder. The template has special fields. The template can also house different types of files (PDFs, vendor specific formats, executables).
For development purposes we are currently uploading the file and then doing a template switch afterwards but what really needs to happen is that file be uploaded to that template type in the first place. I was wondering if there was a way to hook into the upload process to make sure the special template is used when underneath a certain path in the Media Library? If so, where should I start?
We recently had to do something similar. Along the same line as techphoria414, I'd tap into the upload save pipeline. Then, to make it a bit more generic and reusable, use the power of Sitecore's configuration parsing to hook everything up to your handler. Here's what we ended up with.
Main class with required "Process" method:
public class ChangeTemplate
{
public string Name { get; set; }
public string Path { get; set; }
public List<ChangedMediaTemplate> Templates { get; set; }
public ChangeTemplate()
{
Templates = new List<ChangedMediaTemplate>();
}
public void Process(UploadArgs args)
{
var db = Sitecore.Context.ContentDatabase;
var uploadPath = db.GetItem(args.Folder).Paths.ContentPath;
if (!uploadPath.StartsWith(Path))
{
// Not uploading to designated folder
return;
}
foreach (var item in args.UploadedItems)
{
// Need to change template for this item?
var changedTemplate = Templates.Where(t => t.Old.Equals(item.Template.FullName)).FirstOrDefault();
if (changedTemplate != null)
{
var newTemplate = db.Templates[changedTemplate.New];
try
{
item.ChangeTemplate(newTemplate);
}
catch (Exception e)
{
Log.Error("Unable to change {0} template on upload of {1} to {2}.".FormatWith(Name, item.Name, uploadPath), e, this);
}
}
}
}
}
Minor supporting class:
public class ChangedMediaTemplate
{
public string Old { get; set; }
public string New { get; set; }
}
And then the config:
<processors>
<uiUpload>
<processor patch:after="*[#type='Sitecore.Pipelines.Upload.Save, Sitecore.Kernel']" mode="on" type="Foo.Project.SitecoreX.Pipelines.Upload.ChangeTemplate, Foo.Project.Classes">
<Name>Product Images</Name>
<Path>/sitecore/media library/Images/Foo/products</Path>
<Templates hint="list">
<Template type="Foo.Project.SitecoreX.Pipelines.Upload.ChangedMediaTemplate, Foo.Project.Classes">
<Old>System/Media/Unversioned/Image</Old>
<New>User Defined/Foo/Product/Image/Unversioned/Product Image</New>
</Template>
<Template type="Foo.Project.SitecoreX.Pipelines.Upload.ChangedMediaTemplate, Foo.Project.Classes">
<Old>System/Media/Unversioned/Jpeg</Old>
<New>User Defined/Foo/Product/Image/Unversioned/Product Jpeg</New>
</Template>
<Template type="Foo.Project.SitecoreX.Pipelines.Upload.ChangedMediaTemplate, Foo.Project.Classes">
<Old>System/Media/Versioned/Image</Old>
<New>User Defined/Foo/Product/Image/Versioned/Product Image</New>
</Template>
<Template type="Foo.Project.SitecoreX.Pipelines.Upload.ChangedMediaTemplate, Foo.Project.Classes">
<Old>System/Media/Versioned/Jpeg</Old>
<New>User Defined/Foo/Product/Image/Versioned/Product Jpeg</New>
</Template>
</Templates>
</processor>
</uiUpload>
</processors>
Modifying or adding new template rules becomes as simple as editing the config as needed.
Hope this helps!
Unfortunately, to my knowledge, the Sitecore.Resources.Media.MediaCreator(handels mediaitem creation) can not be overridden. So the only (easy) way is to change the templates for the entire media library.
Otherwise I think you need to make your own changes to the sheerUI - but i wouldn't recommend it. Anyhow.. the mediaitems Sitecore creates, are defined in web.config under
<mediaLibrary>
<mediaTypes>
<mediaType name="Any" extensions="*">...</mediaType>
<mediaType name="Windows Bitmap image" extensions="bmp">...</mediaType>
....
</mediaTypes>
</mediaLibrary>
There is a version/unversion template for each mediaitem you can change.
If you want to look into SheerUI I recommend you start here:
http://learnsitecore.cmsuniverse.net/en/Developers/Articles/2009/10/My-First-Sitecore-XAML-Application.aspx
I would use an item:saving handler. If the item is a Media Item, and within a configured folder, then you can change its template. As always with item:saving, insert some checks very early in the method and exit quickly if you determine the item is not of concern.
I want to add something to ambrauer's answer above. It is a good solution but the code should be tweaked before use in production.
The following line:
var uploadPath = db.GetItem(args.Folder).Paths.ContentPath;
should be changed to:
if (args.Folder == null) return;
var uploadFolderItem = db.GetItem(args.Folder);
if (uploadFolderItem == null) return;
var uploadPath = uploadFolderItem.Paths.ContentPath;
The reason is that without the null check on args.Folder, the package upload tool in the Sitecore installation wizard breaks.
This is not critical to developers like us but some administrators rely on this tool as part of their workflow if they do not have full access to the site.