Welcome to MSDN Blogs Sign in | Join | Help

Hey Man Nice Shot

As I mentioned last time, deferred custom actions are generally no impersonate. In this entry, I hope to expand on the postbuild steps to change enable all deferred custom actions in an msi into no impersonate.

Doing this via jscript isn't overly difficult; Aaron outlines how to do it on his blog. It would be easy to simply convert this code into C# to work in the MSBuild system that's been built up so far. But why would I take the easy route?

Instead , I will try to be consistent with the pattern of how I see MSBuild working: use a task to get a bunch of data, use a task to filter or modify them in some fashion, and then use a task to do something with this data.

This is more difficult than making a specific textbox in a specific dialog use password text. In the case of the custom action table, it will be necessary to first determine which if any entries are deferred, than update those specific entries to add the no impersonate bit. That's right: a chance to work with bitwise ands and ors!

Let's break this down into 4 steps:

  1. Grab all entries in the Custom Action table
  2. Decide which of these entries are deferred
  3. Update the deferred entries to include the no impersonate bit
  4. Put the entries back into the table
Put another, more generic way, this breaks down to:
  1. Pull information out of an MSI into our object model
  2. Select specific items from this batch of information
  3. Modify all of these items in some capacity
  4. Convert items in our object model and put them back into the MSI
Let's get started by adding a modifying a setup project, SetupMakeNoImpersonate, with the usual postbuild command line and project structure.

Grab all entries in the Custom Action table

It would be nice to do the first 2 steps as a single statement. Presumably, with a fuller set of SQL operators available to us, this wouldn't be that difficult (full disclosure: I'm not a database guy; it's been 7 years since my last (and first) database class). But Windows Installer doesn't have full SQL capabilities, so this will have to be a two step process.

Fortunately, this first step is now easy: I recently wrote a task to perform exactly this. The full project file would look something like this:

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="PostBuild">
    <Import Project="$(MSBuildExtensionsPath)\SetupProjects\SetupProjects.Targets" />

    <Target Name="PostBuild">
        <Select MsiFileName="$(BuiltOutputPath)"
                TableName="CustomAction">
            <Output TaskParameter="Records" ItemName="CustomActionRecords" />
        </Select>
    
        <Message Text="%(CustomActionRecords.Action) %(CustomActionRecords.Type)" />
    </Target>
</Project>
I threw an extra Message task in there so that we can confirm as we go what we are dealing with. Building this yields:
Project "E:\MWadeBlog\src\Examples\SetupNoImpersonate\PostBuild.proj" (default targets):


Target PostBuild:
    _CA05F9CD_3117_47F8_AD98_5A53210BCD93.uninstall 1025
    _CA05F9CD_3117_47F8_AD98_5A53210BCD93.uninstall.SetProperty 51
    _F8702B9C_568F_49D7_A77F_6FF50945BBB7.install 1025
    _F8702B9C_568F_49D7_A77F_6FF50945BBB7.install.SetProperty 51
    _51C6890F_5CB0_4085_8587_0CBACD4B4431.rollback 1281
    _51C6890F_5CB0_4085_8587_0CBACD4B4431.rollback.SetProperty 51
    _51D9F96A_3196_4E83_811C_0FFD4127D3BE.commit 1537
    _51D9F96A_3196_4E83_811C_0FFD4127D3BE.commit.SetProperty 51
    DIRCA_TARGETDIR 307
    DIRCA_CheckFX 1
    VSDCA_VsdLaunchConditions 1
    ERRCA_CANCELNEWERVERSION 19
    ERRCA_UIANDADVERTISED 19
    VSDCA_FolderForm_AllUsers 51

Build succeeded.

Did I mention that I added a managed custom action to my setup project? Well, I did.

Decide which of these entries are deferred

A deferred custom action will have the msidbCustomActionTypeInScript attribute of its Type column set. Of course, other bits may also be set, depending on the custom action type (dll, exe, jscript, etc.) as well as its sequence (install, uninstall, commit, rollback). So, a task must be written to filter out the custom action with a specific bit set.

There is an example of a task which does something similar: The FindExternalCabs task looked at a specific column to determine if a string started with '#'. It would be easy enough to copy this exact sort of concept, but like I said before: why do something the easy way?

Instead, let's think about this filtering as a process. A whole bunch of data is passed through the filter. Some items match and are kept, and others don't and should be discarded. FindExternalCabs had a filter (it may not be obvious, but its there), and we'll want a filter for this process as well.

What we'd really like to happen is to let the task define what the filter is and how it is initialized. The task will create the filter, pass all the input items to it, and select and return the matches. Let's stub out the task and try to come up with a set of tests for it.

using System;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace SetupProjects.Tasks
{
    public class FilterItems : Task
    {
        [Required]
        public string Name
        {
            get { return null; }
            set { }
        }

        public ITaskItem FilterInfo
        {
            get { return null; }
            set { }
        }

        [Required]
        public ITaskItem[] Items
        {
            get { return null; }
            set { }
        }

        [Output]
        public ITaskItem[] MatchedItems
        {
            get { return null; }
            set { }
        }

        [Output]
        public ITaskItem[] NotMatchedItems
        {
            get { return null; }
            set { }
        }

        public override bool Execute()
        {
            throw new Exception("The method or operation is not implemented.");
        }
    }
}

There are a total of 5 task parameters, 2 of which are required, and 2 of which are output:

  1. Name: The class name of the filter. This class will be instantiated and will perform the filtering. This was inspired by the whole "UsingTask" or "CallTargets" node in MSBuild; I'll probably eventually add in the whole AssemblyName construct to make this more pluggable, but let's wait until we need it.
  2. FilterInfo: Information that will be used by the filter to initialize itself. This allows filter to know how to filter and what to filter for.
  3. Items: The items to be filtered
  4. MatchedItems: The items which matched the specified filter
  5. NotMatchedItems: The items which did not match the filter. MatchedItems and NotMatchedItems should be mutually exclusive and combined would be the full set of input Items

Now that we have this definition, lets come up with a series of tests to write for this task.

  • Filtering an empty set of items should match no items
  • Filtering a set of 5 items with a simple filter (all items match) should match all 5 items
  • Filtering a set of 5 items with the opposite simple filter (no item matches) should not match any items
  • Filtering a set of 5 items with a more complex filter should find appropriate matches and not matches

The last test there is kind of lame, but I'm hoping its slightly less lame than "filtering a set of input data should work".

Let's write the first test. Here is the new class contents I added to FilterTest.cs in SetupProjects.Tasks.UnitTests:

[TestClass()]
public class FilterTest
{
    [TestMethod()]
    public void EmptyTest()
    {
        FilterItems target = new FilterItems();

        List empty = new List();
        target.Items = empty.ToArray();

        target.ApplyFilter(new SimpleFilter());
        Assert.AreEqual(0, target.MatchedItems.Length);
        Assert.AreEqual(0, target.NotMatchedItems.Length);
    }
}

I think that pretty much does what I want it to do: create an empty input set, filter it, and make sure both output sets are empty. It seemed natural to have an ApplyFilter function that would do the work, and have the filter be the input to the method. I expect to use the Name parameter in one of my tests down the road, but I'm trying to limit the scope of this test as much as possible.

Unfortunately, this doesn't compile: I need to define the ApplyFilter method and come up with the SimpleFilter class. Here's my take at the ApplyFilter method:

private void ApplyFiler(IFilter filter)
{
}

Just about as easy as they get, right? Well, of course I still have to define the IFilter interface, which I'll add to a new file, IFilter.cs:

public interface IFilter
{
}

That gets our test closer to compiling: at least the assembly getting tested is building. Now all that's needed is a way for the test to call ApplyFilter, a methd which should probably have its access reduced. While it would be possible to use friend assemblies, let's have the VSTS take care of this for us with its code generator. Right-clicking on the unit test project and selecting Add -> Unit test... and then drilling down to the SetupProjects.Tasks.Filter.ApplyFilter creates a new test for ApplyFilter. Most of the test is pretty worthless:

[TestMethod()]
public void ApplyFilterTest()
{
    FilterItems target = new FilterItems();

    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);

    IFilter filter = null; // TODO: Initialize to an appropriate value

    accessor.ApplyFilter(filter);

    Assert.Inconclusive("A method that does not return a value cannot be verified.");
}

but it shows how to use the FilterItemsAccessor, a class defined in the newly added VsCodeGenAccessors.cs. So let's move this accessor usage to the EmptyTest:

[TestMethod()]
public void EmptyTest()
{
    FilterItems target = new FilterItems();

    List empty = new List();
    target.Items = empty.ToArray();

    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);
    accessor.ApplyFilter(new SimpleFilter());

    Assert.AreEqual(0, target.MatchedItems.Length);
    Assert.AreEqual(0, target.NotMatchedItems.Length);
}

Now the only compiler errors revolve around the missing SimpleFilter definition. Adding a private class to the FilterTest class should do the trick:

class SimpleFilter : IFilter
{
}

At this point, the structure for the first test and much of the FilterItems class is laid out. Now does the test pass? Of course not:


Failed EmptyTest Test method SetupProjects.Tasks.UnitTests.FilterTest.EmptyTest threw exception:  System.NullReferenceException: Object reference not set to an instance of an object..	

This makes sense: the MatchedItems and NotMatchedItems both return null. Let's fix up both of these in the FilterItems class:

private List matchedItems = new List();
[Output]
public ITaskItem[] MatchedItems
{
    get { return matchedItems.ToArray(); }
    set { }
}

private List notMatchedItems = new List();
[Output]
public ITaskItem[] NotMatchedItems
{
    get { return notMatchedItems.ToArray(); }
    set { }
}

This simple addition gets the first test passing. No time to revel in our fantasticness, however: there are more tests to write. But correcting the Matches property made me realize I forgot at least one important test:

  • Filtering an empty set of items should match no items
  • Filtering a set of 5 items with a simple filter (all items match) should match all 5 items
  • Filtering a set of 5 items with the opposite simple filter (no item matches) should not match any items
  • Filtering a set of 5 items with a more complex filter should find appropriate matches and not matches
  • Filters are initialized according to the FilterInfo property

Let's try filtering 5 items, with a very basic filter. Here is a pair of functions to test this out:

private List testItems;

[TestInitialize()]
public void TestInitialize()
{
    testItems = new List();

    TaskItem item = new TaskItem("A");
    item.SetMetadata("Number", "1");
    testItems.Add(item);

    item = new TaskItem("B");
    item.SetMetadata("Number", "2");
    testItems.Add(item);
    
    item = new TaskItem("C");
    item.SetMetadata("Number", "3");
    testItems.Add(item);
    
    item = new TaskItem("D");
    item.SetMetadata("Number", "4");
    testItems.Add(item);
    
    item = new TaskItem("E");
    item.SetMetadata("Number", "5");
    testItems.Add(item);
}

[TestMethod()]
public void Match5ItemsTest()
{
    FilterItems target = new FilterItems();
    target.Items = testItems.ToArray();

    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);
    accessor.ApplyFilter(new SimpleFilter());

    Assert.AreEqual(testItems.Count, target.MatchedItems.Length);
    Assert.AreEqual(0, target.NotMatchedItems.Length);

    for (int i = 0; i < target.MatchedItems.Length; i++)
    {
        Assert.AreEqual(testItems[i].ItemSpec, target.MatchedItems[i].ItemSpec);
        Assert.IsNotNull(target.MatchedItems[i].GetMetadata("Number"));
        Assert.AreEqual(testItems[i].GetMetadata("Number"), target.MatchedItems[i].GetMetadata("Number"));
    }
}

This test initializes an array of 5 items, each item with a simple piece of metadata. The test makes sure that all 5 items match, with ItemSpec and Metadata still intact. This test intially fails: there are no items that match. Lets see how this can be fixed:

private void ApplyFilter(IFilter filter)
{
    foreach (ITaskItem item in Items)
    {
        matchedItems.Add(item);
    }
}

Running the tests, however, shows this doesn't work: Items is returning null. So let's fix that up, too:

ITaskItem[] items;
[Required]
public ITaskItem[] Items
{
    get { return items; }
    set { items = value; }
}

Both tests now pass. Next!

  • Filtering an empty set of items should match no items
  • Filtering a set of 5 items with a simple filter (all items match) should match all 5 items
  • Filtering a set of 5 items with the opposite simple filter (no item matches) should not match any items
  • Filtering a set of 5 items with a more complex filter should find appropriate matches and not matches
  • Filters are initialized according to the FilterInfo property

It's pretty tempting to go to the next item on the list. However, that means we'll actually have to have our filter start doing something: the simple filter has to be able to say that something does not match. In order to do that, I'm going to need to initialize the filter. So, let's work on that step next.

The test that I've come up with seems a little strange to me, but here it is:

[TestMethod()]
public void FilterInfoTest()
{
    FilterItems target = new FilterItems();
    target.FilterInfo = testItems[0];

    FilterInfoTestFilter filter = new FilterInfoTestFilter();
    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);
    accessor.ApplyFilter(filter);

    Assert.AreEqual(filter.PrimaryData, "A");
    Assert.AreEqual(filter.Metadata["Number"], "1");
}

It seems strange only because there isn't actually an filtering going on here: no Items are passed in, and no verification is done on the output items. It would be very easy to roll this test into one of the other tests, but that uber-test would not isolate any functionality, possibly making it harder to tell why it ever failed.

Obviously, this test doesn't compile because there is no FilterInfoTestFilter defined anywhere. So let's add that to the test class:

class FilterInfoTestFilter : IFilter
{
    private string primaryData;
    private Dictionary metadata = new Dictionary();

    public string PrimaryData
    {
        get { return primaryData; }
    }

    public string GetMetadata(string key)
    {
        return metadata[key];
    }
}

Pretty basic: a couple of members that will keep track of the information passed to it from the task. Of course, that begs the question: how is info passed to the filter? There is no code to do this. Let's change that by updating the ApplyFilter method:

private void ApplyFilter(IFilter filter)
{
    filter.Initialize(FilterInfo);
    foreach (ITaskItem item in Items)
    {
        matchedItems.Add(item);
    }
}

which of course means Initialize needs to be added to the IFilter specification:

public interface IFilter
{
    void Initialize(ITaskItem filterInfo);
}

That gets our task compiling, but not the tests:

class SimpleFilter : IFilter
{
    public void Initialize(ITaskItem filterInfo)
    {
    }
}

class FilterInfoTestFilter : IFilter
{
    private string primaryData;
    private Dictionary metadata = new Dictionary();

    public string PrimaryData
    {
        get { return primaryData; }
    }

    public string GetMetadata(string key)
    {
        return metadata[key];
    }

    public void Initialize(ITaskItem filterInfo)
    {
    }

Stubs will have to do it for now. Both of the original tests are still passing, but the latest is not. Time to do more than just stub:

public void Initialize(ITaskItem filterInfo)
{
    primaryData = filterInfo.ItemSpec;

    foreach (string key in filterInfo.MetadataNames)
    {
        metadata[key] = filterInfo.GetMetadata(key);
    }
}

Just what one would expect this to do: save the ItemSpec as the member and all of the metadata into the member dictionary. But this still isn't enough to get the test passing: FilterInfo on the task is still returning null. Fix that up, too:

private ITaskItem filterInfo;
public ITaskItem FilterInfo
{
    get { return filterInfo; }
    set { filterInfo = value; }
}

Running the tests still show a failure on my end. It turns out that the decision to not pass any items is causing a problem: ApplyFilter makes no check to make sure that items are actually passed in. This leads to a philosophical discussion: whose responsibility is it to make sure the class getting tested is properly initialized: the tester or the testee? If this task were being used through MSBuild, passing in no Items would have caused a build error because Items is marked as required. But shouldn't the class make a simple assurance that the items are actually there? And if they aren't what should it do: treat this as an empty list, or throw an exception? Decisions, decisions.

Let's take the simplest approach: let the method assume that its input is valid (it is private, after all): whoever calls the method should make sure the input is valid. In other words, let's make a test change:

[TestMethod()]
public void FilterInfoTest()
{
    FilterItems target = new FilterItems();
    target.Items = testItems.ToArray();
    target.FilterInfo = testItems[0];

    FilterInfoTestFilter filter = new FilterInfoTestFilter();
    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);
    accessor.ApplyFilter(filter);

    Assert.AreEqual(filter.PrimaryData, "A");
    Assert.AreEqual(filter.GetMetadata("Number"), "1");
}

That gets the test passing.

  • Filtering an empty set of items should match no items
  • Filtering a set of 5 items with a simple filter (all items match) should match all 5 items
  • Filtering a set of 5 items with the opposite simple filter (no item matches) should not match any items
  • Filtering a set of 5 items with a more complex filter should find appropriate matches and not matches
  • Filters are initialized according to the FilterInfo property

Let's try the test with the opposite simple filter. This test starts off as a virtual clone of Match5Items:

[TestMethod()]
public void NoMatch5ItemsTest()
{
    FilterItems target = new FilterItems();
    target.Items = testItems.ToArray();

    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);
    accessor.ApplyFilter(new SimpleFilter());

    Assert.AreEqual(testItems.Count, target.NotMatchedItems.Length);
    Assert.AreEqual(0, target.MatchedItems.Length);

    for (int i = 0; i < target.NotMatchedItems.Length; i++)
    {
        Assert.AreEqual(testItems[i].ItemSpec, target.NotMatchedItems[i].ItemSpec);
        Assert.IsNotNull(target.NotMatchedItems[i].GetMetadata("Number"));
        Assert.AreEqual(testItems[i].GetMetadata("Number"), target.NotMatchedItems[i].GetMetadata("Number"));
    }
}

The only difference is that MatchedItems was change to NotMatchedItems (and vice-versa). Of course, this doesn't pass, though: the filter isn't doing anything different. Essentially, the SimpleFilter needs to know whether it should be reporting matches when it is run. The options are to initialize this as a parameter to the SimpleFilter constructor, or to use the FilterInfo property on the task itself, just like last time around. Let's opt for the second option:

class SimpleFilter : IFilter
{
    private bool match = true;

    public void Initialize(ITaskItem filterInfo)
    {
        match = bool.Parse(filterInfo.ItemSpec);
    }
}

SimpleFilter has been updated with a member variable, match, which is the value used to indicate whether or not an item matches (whatever that means). This value is initialized via the Initiallize entry, which parses the ItemSpec of the ITaskItem. Now, the test needs to make sure this value is passed in by setting the FilterInfo parameter:

[TestMethod()]
public void NoMatch5ItemsTest()
{
    FilterItems target = new FilterItems();
    target.Items = testItems.ToArray();
    target.FilterInfo = new TaskItem(false.ToString());

    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);
    accessor.ApplyFilter(new SimpleFilter());

    Assert.AreEqual(testItems.Count, target.NotMatchedItems.Length);
    Assert.AreEqual(0, target.MatchedItems.Length);

    for (int i = 0; i < target.NotMatchedItems.Length; i++)
    {
        Assert.AreEqual(testItems[i].ItemSpec, target.NotMatchedItems[i].ItemSpec);
        Assert.IsNotNull(target.NotMatchedItems[i].GetMetadata("Number"));
        Assert.AreEqual(testItems[i].GetMetadata("Number"), target.NotMatchedItems[i].GetMetadata("Number"));
    }
}

But the test still fails because the ApplyFilter is always copying the item into the Matched list. Instead, it should be copying the item to the appropriate list based on whether or not the item matches the filter. Sounds like an interface change:

public interface IFilter
{
    void Initialize(ITaskItem filterInfo);
    bool IsMatch(ITaskItem item);
}

and a corresponding change to ApplyFilter:

private void ApplyFilter(IFilter filter)
{
    filter.Initialize(FilterInfo);
    foreach (ITaskItem item in Items)
    {
        if (filter.IsMatch(item))
        {
            matchedItems.Add(item);
        }
        else
        {
            notMatchedItems.Add(item);
        }
    }
}

This causes the tests to stop compiling because the 2 filters that have been defined do not fully implement the interface. That is simple enough: its already been determined what the SimpleFilter should do, and since FilterInfoTests shouldn't actually test the results of the filter, it doesn't matter what it does:

class SimpleFilter : IFilter
{
    private bool results = true;

    public void Initialize(ITaskItem filterInfo)
    {
        results = bool.Parse(filterInfo.ItemSpec);
    }

    public bool IsMatch(ITaskItem item)
    {
        return results;
    }
}

class FilterInfoTestFilter : IFilter
{
    private string primaryData;
    private Dictionary metadata = new Dictionary();

    public string PrimaryData
    {
        get { return primaryData; }
    }

    public string GetMetadata(string key)
    {
        return metadata[key];
    }

    public void Initialize(ITaskItem filterInfo)
    {
        primaryData = filterInfo.ItemSpec;

        foreach (string key in filterInfo.MetadataNames)
        {
            metadata[key] = filterInfo.GetMetadata(key);
        }
    }

    public bool IsMatch(ITaskItem item)
    {
        return false;
    }
}

This is sufficient to get the NoMatch5ItemsTest passing. But what about all the other tests? Looks like those have been broken: now that an attempt is made to initialize the SimpleFilter via the Initialize function, the other tests are crashing because the FilterInfo property is not getting set. This time, though, the fix will not be to update the tests, it will be to update ApplyFilter. Remember: FilterInfo is an optional parameter, and it is very possible for this value to be null.

private void ApplyFilter(IFilter filter)
{
    if (filterInfo != null)
    {
        filter.Initialize(FilterInfo);
    }

    foreach (ITaskItem item in Items)
    {
        if (filter.IsMatch(item))
        {
            matchedItems.Add(item);
        }
        else
        {
            notMatchedItems.Add(item);
        }
    }
}

Now all 4 tests are passing.

  • Filtering an empty set of items should match no items
  • Filtering a set of 5 items with a simple filter (all items match) should match all 5 items
  • Filtering a set of 5 items with the opposite simple filter (no item matches) should not match any items
  • Filtering a set of 5 items with a more complex filter should find appropriate matches and not matches
  • Filters are initialized according to the FilterInfo property

The last test to implement (at least the last test written down; I know there are more out there) is rather ill-defined. Let's define the "more complex filter" as "if the Number metadata is even, the item matches". Such a filter could be defined as:

class EvenFilter : IFilter
{
    public void Initialize(ITaskItem filterInfo)
    {
    }

    public bool IsMatch(ITaskItem item)
    {
        return int.Parse(item.GetMetadata("Number")) % 2 == 0;
    }
}

that is, parse the "Number" metadata, mod it by 2 and see if it is 0. In this case, there is nothing to initialize, so Initialize does nothing.

Let's get this test written. One interesting thing about this test (what am I talking about? none of this stuff is actually interesting) is how to keep track of what is expected to match and what is not. One possibility is to simply re-define the testItems created so that "even" items actually aligned with even indices, but I'm not convinced it would have been better. So here is the test that I wrote (along with a new helper function that I should have re-factored a couple of steps ago):

[TestMethod()]
public void ComplexFilterTest()
{
    FilterItems target = new FilterItems();
    target.Items = testItems.ToArray();

    EvenFilter filter = new EvenFilter();
    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);
    accessor.ApplyFilter(filter);

    List expectedEvenItems = new List();
    expectedEvenItems.Add(testItems[1]);
    expectedEvenItems.Add(testItems[3]);

    List expectedOddItems = new List();
    expectedOddItems.Add(testItems[0]);
    expectedOddItems.Add(testItems[2]);
    expectedOddItems.Add(testItems[4]);

    Assert.AreEqual(expectedEvenItems.Count, target.MatchedItems.Length);
    Assert.AreEqual(expectedOddItems.Count, target.NotMatchedItems.Length);

    CompareItems(expectedEvenItems, target.MatchedItems);
    CompareItems(expectedOddItems, target.NotMatchedItems);
}

private void CompareItems(List expected, ITaskItem[] actual)
{
    for (int i = 0; i < actual.Length; i++)
    {
        Assert.AreEqual(expected[i].ItemSpec, actual[i].ItemSpec);
        Assert.IsNotNull(actual[i].GetMetadata("Number"));
        Assert.AreEqual(expected[i].GetMetadata("Number"), actual[i].GetMetadata("Number"));
    }
}

This test passes out of the gate, which should make one question whether or not it is a good test. I think its a good test: it seems more real-worldy to me. Maybe its one of the others that is bad. But I don't think its necessary to remove any of the tests.

Looking at the sources for FilterItem, I think there are 2 things left to do:

  1. The Name parameter is not getting used
  2. The Execute method is still blank
Let's work on dealing with the Name task parameter first. Name is supposed to be the name of a filter. It would be possible to do some cool reflection-based name matching, but lets keep it simple for now: the input name will be compared against a list of known filters, and the correct one will be selected. The filter required for the NoImpersonate objective is an and-based filter. Let's first stub that out before writing the tests for it.
using System;
using Microsoft.Build.Framework;

namespace SetupProjects.Tasks.Filters
{
    public class BitwiseAnd : IFilter
    {
        public void Initialize(ITaskItem filterInfo)
        {
        }

        public bool IsMatch(ITaskItem item)
        {
            return false;
        }
    }
}
Now that there is a filter to instantiate, lets start verifying that an BitwiseAnd is returned when the name "BitwiseAnd" is passed into the task. Let's start by adding a stub into the FilterItems task:
private IFilter SelectFilter()
{
    return null;
}
The Unit Test wizard gets a reasonable start for the test, but its up to the programmer to complete it. Here is one crack at it:
[DeploymentItem("SetupProjects.Tasks.dll")]
[TestMethod()]
public void SelectFilterTest()
{
    FilterItems target = new FilterItems();

    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);

    target.Name = "BitwiseAnd";
    IFilter actual;

    actual = accessor.SelectFilter();
    Assert.IsNotNull(actual);
    Assert.IsTrue(actual is SetupProjects.Tasks.Filters.BitwiseAnd, string.Format("Got type: {0}, expected {1}", "BitwiseAnd", actual.GetType().ToString()));
}
The test first makes sure the object is not null, and then verifies the type of the returned filter. This test is currently failing:
Failed	SelectFilterTest Assert.IsNotNull failed.
That's not unexpected: the function is currently setup to return null. Time to fix that up:
private IFilter SelectFilter()
{
    if (Name.Equals("BitwiseAnd"))
    {
        return new Filters.BitwiseAnd();
    }
    throw new ArgumentException("Unable to find filter with name {0}", Name);
}
This function no longer returns null: it creates a new BitwiseAnd if thats the specified parameter, and throws an ArgumentException otherwise. Oops, shouldn't there be a test for that?
[DeploymentItem("SetupProjects.Tasks.dll")]
[TestMethod()]
[ExpectedException(typeof(ArgumentException))]
public void SelectBogusFilterTest()
{
    FilterItems target = new FilterItems();

    SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor accessor = new SetupProjects.Tasks.UnitTests.SetupProjects_Tasks_FilterItemsAccessor(target);

    target.Name = "FooFilter";
    accessor.SelectFilter();
}

Both of these tests pass. Now all that is left for the task is actually do some executing. Is unit testing important here? Probably another philosophical question. Realistically, this comes down to how much you trust that MSBuild is ensuring that parameters are initialized appropriately, requirements are enforced, and that the functions are called in the correct order in Execute. I happen to feel that all of these things will work out:

public override bool Execute()
{
    ApplyFilter(SelectFilter());
    return true;
}

I think its about time to declare the FilterItems task complete. Let's get cracking on the filter by thinking about what the filter should do. It will be initialized by passing in 2 values via the ITaskItem, the name of the column to look at (as the ItemSpec of the item), and Value, the value to test against. Unlike with the task, there will be no security blanket to make sure that all values are passed into the filter, so there will have to be tests ensuring the filter is initialized correctly.

  • No Value metadata item passed to the filter results in an ArgumentException
  • Filtering an item which doesn't have the specified Name attribute results in an ArgumentException
  • Filtering items 3, 4, 5, 8, 11, 12, and 13 against 4 should match appropriate items

This first test is very easy:

[TestMethod()]
[ExpectedException(typeof(ArgumentException))]
public void InitializeTest()
{
    BitwiseAnd target = new BitwiseAnd();
    ITaskItem filterInfo = new TaskItem("Test");
    target.Initialize(filterInfo);
}

and it fails when first run because the exception is not thrown:

Failed	InitializeTest Test method SetupProjects.Tasks.UnitTests.BitwiseAndTest.InitializeTest did not throw expected exception

Here is some code I would expect to get this test passing:

public class BitwiseAnd : IFilter
{
    private string itemName;
    private int targetValue;

        protected string ItemName
        {
            get { return itemName; }
        }

        protected int TargetValue
        {
            get { return targetValue; }
        }

    public void Initialize(ITaskItem filterInfo)
    {
        itemName = filterInfo.ItemSpec;
        string valueData = filterInfo.GetMetadata("Value");
        if (valueData == null)
        {
            throw new ArgumentException("Failed to specify Value metadata");
        }
        targetValue = int.Parse(valueData);
    }

    public bool IsMatch(ITaskItem item)
    {
        return false;
    }
}

There is no method on ITaskItem that directly tests that the item contains a specified piece of metadata. I have assumed that that if the item does not contain the metadata, GetMetadata would return null. Let's see what the test says:

Failed        InitializeTest        Test method SetupProjects.Tasks.UnitTests.BitwiseAndTest.InitializeTest threw exception System.FormatException, but exception System.ArgumentException was expected. Exception message:  System.FormatException: Input string was not in a correct format.

Unfortunately (at least in this case its unfortunate), GetMetadata does not return null when the item can't be found, it instead returns an empty string. It would be possible to go through all of the MetadataNames looking for the "Value" name, but that seems excessive. So, instead the code will test if the value is null or empty:

if (string.IsNullOrEmpty(valueData))

And that test is now passing.

  • No Value metadata item passed to the filter results in an ArgumentException
  • Filtering an item which doesn't have the specified Name attribute results in an ArgumentException
  • Filtering items 3, 4, 5, 8, 11, 12, and 13 against 4 should match appropriate items

Since writing that list of tests, it ihas crossed my mind that perhaps there should be a test to verify that the Value really is an integer, but the failing test showed that an exception is thrown correctly anyway, so I think its safe to skip an explicit test. So let's move to the next test:

[TestMethod()]
[ExpectedException(typeof(ArgumentException))]
public void MissingMetadataTest()
{
    BitwiseAnd target = new BitwiseAnd();
    ITaskItem filterInfo = new TaskItem("Test");
    filterInfo.SetMetadata("Value", "1");
    target.Initialize(filterInfo);
    target.IsMatch(new TaskItem("NoValue"));
}

This test creates a new legitimate filter, and checks if a task item without the "Test" metadata matches. This test fails, because no exception is thrown yet:

Failed	MissingMetadataTest	Test method	SetupProjects.Tasks.UnitTests.BitwiseAndTest.MissingMetadataTest did not throw expected exception .	

Let's make sure that exception starts getting thrown:

public bool IsMatch(ITaskItem item)
{
    string valueData = item.GetMetadata(ItemName);
    if (string.IsNullOrEmpty(valueData))
    {
        throw new ArgumentException(string.Format("Failed to specify {0} metadata", ItemName));
    }
    return false;
}

The test now passes, and it should now be possible to move on to the next test. Or is it? The test went from red to green, but I forgot the refactor step. Both the Initialize and IsMatch methods read of piece of metadata and throw an exception if that metadata is not found. This should be refactored into a helper function. But I'm going to wait until the next test is written because right now the helper function would return a string, when realistically I would want it to return an int. Moving on.

  • No Value metadata item passed to the filter results in an ArgumentException
  • Filtering an item which doesn't have the specified Name attribute results in an ArgumentException
  • Filtering items 3, 4, 5, 8, 11, 12, and 13 against 4 should match appropriate items
For this last test, I wanted to strike a balance between a sufficiently difficult dataset, but not TOO difficult. Kind of like the line I was trying to walk with the Roman Numeral converter. I think this one seems reasonable. Here is the test:
[TestMethod()]
public void IsMatchTest()
{
    BitwiseAnd target = new BitwiseAnd();
    ITaskItem filterInfo = new TaskItem("Number");
    int[] numbers =  { 3,     4,    5,    7,    8,     11,    12,   13 };
    bool[] matches = { false, true, true, true, false, false, true, true };
    filterInfo.SetMetadata("Value", "4");
    target.Initialize(filterInfo);

    for (int i = 0; i < numbers.Length; i++)
    {
        TaskItem item = new TaskItem("Test");
        item.SetMetadata("Number", numbers[i].ToString());
        Assert.AreEqual(matches[i], target.IsMatch(item), string.Format("{0} & 4 should be {1}", numbers[i], matches[i].ToString()));
    }
}
And the initial run shows failure:
Failed	IsMatchTest	Assert.AreEqual failed. Expected:
, Actual:. 4 & 4 should be True
Updating the code to:
public bool IsMatch(ITaskItem item)
{
    string valueData = item.GetMetadata(ItemName);
    if (string.IsNullOrEmpty(valueData))
    {
        throw new ArgumentException(string.Format("Failed to specify {0} metadata", ItemName));
    }
    int value = int.Parse(valueData);
    return (value & TargetValue) == TargetValue;
}

gets the test passing.

Finally, let's move on to the refactoring stage. There are 4 duplicated lines between Initialize and IsMatch: reading some metadata, making sure its not empty, and then converting to an int. Let's refactor that into one method:

public void Initialize(ITaskItem filterInfo)
{
    itemName = filterInfo.ItemSpec;
    targetValue = GetIntegerMetadata(filterInfo, "Value");
}

public bool IsMatch(ITaskItem item)
{
    return (GetIntegerMetadata(item, ItemName) & TargetValue) == TargetValue;
}

private int GetIntegerMetadata(ITaskItem item, string metadataName)
{
    string data = item.GetMetadata(metadataName);
    if (string.IsNullOrEmpty(data))
    {
        throw new ArgumentException("Failed to specify {0} metadata", metadataName);
    }
    return int.Parse(data);
}

and the tests still pass.

Finally, let's see this in action. Let's expand the postbuild steps of SetupNoImpersonate to use the new FilterItems task. First, it is necessary to add the new task to the SetupProjects.targets file:

<UsingTask TaskName="FilterItems" AssemblyFile="$(MSBuildExtensionsPath)\SetupProjects\SetupProjects.Tasks.dll"/&gr;

And here is the expanded project file to make use of the new task:

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="PostBuild">
    <Import Project="$(MSBuildExtensionsPath)\SetupProjects\SetupProjects.Targets" />
    
    <ItemGroup>
        <DeferredCustomActionFilterInfo Include="Type">
            <Value>1024</Value>
        </DeferredCustomActionFilterInfo>
    </ItemGroup>

    <Target Name="PostBuild">
        <Select MsiFileName="$(BuiltOutputPath)"
                TableName="CustomAction">
            <Output TaskParameter="Records" ItemName="CustomActionRecords" />
        </Select>
        <Message Text="%(CustomActionRecords.Action) %(CustomActionRecords.Type)" />
    
        <FilterItems Name="BitwiseAnd" FilterInfo="@(DeferredCustomActionFilterInfo)"  Items="@(CustomActionRecords)">
            <Output TaskParameter="MatchedItems" ItemName="DeferredCustomActionRecords" />
        </FilterItems>
        <Message Text="%(DeferredCustomActionRecords.Action) %(DeferredCustomActionRecords.Type)" />
    </Target>
</Project>

First, it is necessary to describe the filter for the FilterItems task. A deferred custom action has the msidbCustomActionTypeInScript attribute set. To actually do the filtering, call the FilterItems task with the filter information and the results from the initial select. Build output is given below:

Target PostBuild:
    _CA05F9CD_3117_47F8_AD98_5A53210BCD93.uninstall 1025
    _CA05F9CD_3117_47F8_AD98_5A53210BCD93.uninstall.SetProperty 51
    _F8702B9C_568F_49D7_A77F_6FF50945BBB7.install 1025
    _F8702B9C_568F_49D7_A77F_6FF50945BBB7.install.SetProperty 51
    _51C6890F_5CB0_4085_8587_0CBACD4B4431.rollback 1281
    _51C6890F_5CB0_4085_8587_0CBACD4B4431.rollback.SetProperty 51
    _51D9F96A_3196_4E83_811C_0FFD4127D3BE.commit 1537
    _51D9F96A_3196_4E83_811C_0FFD4127D3BE.commit.SetProperty 51
    DIRCA_TARGETDIR 307
    DIRCA_CheckFX 1
    VSDCA_VsdLaunchConditions 1
    ERRCA_CANCELNEWERVERSION 19
    ERRCA_UIANDADVERTISED 19
    VSDCA_FolderForm_AllUsers 51

    _CA05F9CD_3117_47F8_AD98_5A53210BCD93.uninstall 1025
    _F8702B9C_568F_49D7_A77F_6FF50945BBB7.install 1025
    _51C6890F_5CB0_4085_8587_0CBACD4B4431.rollback 1281
    _51D9F96A_3196_4E83_811C_0FFD4127D3BE.commit 1537

Build succeeded.

(To be continued...)

Published Monday, June 25, 2007 9:21 PM by mwade

Attachment(s): SetupProjects.Tasks-1.0.20629.0.msi

Comment Notification

If you would like to receive an email when updates are made to this post, please register here

Subscribe to this post's comments using RSS

Comments

# Hey Man Nice Shot (part 2)

Saturday, June 30, 2007 1:53 AM by Re-inventing The Wheel

( Continued... ) Here is what has been accomplished so far in this grand No Impersonate plan: Grab all

Leave a Comment

(required) 
required 
(required) 

  
Enter Code Here: Required
 
Page view tracker