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
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.Testing.Extensions.PackagedApp;

/// <summary>
/// A single application declared by a packaged Windows app's <c>AppxManifest.xml</c>
/// (<c>Applications/Application</c>). A package can declare more than one, each with its own
/// Application User Model ID, so the launcher resolves the one matching the requested executable
/// rather than assuming a single application.
/// </summary>
internal sealed class AppxApplicationInfo
{
internal AppxApplicationInfo(string id, string? executable, string appUserModelId)
{
Id = id;
Executable = executable;
AppUserModelId = appUserModelId;
}

/// <summary>Gets the application id (the manifest's <c>Application/@Id</c>).</summary>
public string Id { get; }

/// <summary>
/// Gets the application's executable file name (the manifest's <c>Application/@Executable</c>),
/// or <see langword="null"/> when the manifest declares none. Used to disambiguate a package that
/// declares multiple applications by matching the executable the platform asked to launch.
/// </summary>
public string? Executable { get; }

/// <summary>
/// Gets the Application User Model ID (<c>{PackageFamilyName}!{Id}</c>) used to activate this
/// application.
/// </summary>
public string AppUserModelId { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Security.Cryptography;

using Microsoft.Testing.Extensions.PackagedApp.Resources;

namespace Microsoft.Testing.Extensions.PackagedApp;

/// <summary>
/// The identity of a packaged Windows (UWP/WinUI) app, read from its <c>AppxManifest.xml</c>.
/// </summary>
/// <remarks>
/// <para>
/// A packaged app is launched by <em>Application User Model ID</em> (AUMID), never by
/// <c>Process.Start</c>. The AUMID is <c>{PackageFamilyName}!{ApplicationId}</c>, and the package
/// family name is <c>{PackageName}_{publisherId}</c> where <c>publisherId</c> is a 13-character hash
/// of the manifest's <c>Publisher</c>. VSTest reads these values from VS-internal deployment
/// components; this type computes them from the manifest using only public, cross-platform managed
/// APIs so the extension does not depend on the Visual Studio install.
/// </para>
/// </remarks>
internal sealed class AppxManifestInfo
Comment thread
Evangelink marked this conversation as resolved.
{
/// <summary>
/// The canonical file name of a packaged app's manifest inside its (loose or extracted) layout.
/// </summary>
internal const string AppxManifestFileName = "AppxManifest.xml";

// The alphabet Windows uses to encode the publisher hash (Douglas Crockford's base32: the digits
// and lowercase letters with i, l, o and u removed). Must not be reordered.
private const string PublisherHashAlphabet = "0123456789abcdefghjkmnpqrstvwxyz";

private AppxManifestInfo(string packageName, string publisher, IReadOnlyList<AppxApplicationInfo> applications)
{
PackageName = packageName;
Publisher = publisher;
PackageFamilyName = $"{packageName}_{ComputePublisherId(publisher)}";
Applications = applications;
}

/// <summary>Gets the package name (the manifest's <c>Identity/@Name</c>).</summary>
public string PackageName { get; }

/// <summary>Gets the publisher (the manifest's <c>Identity/@Publisher</c>).</summary>
public string Publisher { get; }

/// <summary>Gets the package family name (<c>{PackageName}_{publisherId}</c>).</summary>
public string PackageFamilyName { get; }

/// <summary>
/// Gets the applications declared by the manifest (<c>Applications/Application</c>), in manifest
/// order. A package can declare several applications, so callers must select the one they want
/// (see <see cref="ResolveApplication(string?)"/>) rather than assuming a single entry. The list
/// is empty when the manifest declares no application.
/// </summary>
public IReadOnlyList<AppxApplicationInfo> Applications { get; }

/// <summary>
/// Returns the path to the <c>AppxManifest.xml</c> at the root of <paramref name="layoutDirectory"/>
/// when the directory is a packaged-app layout. This is a cheap, non-throwing probe: it only tests
/// for the file's existence and never parses it. Callers that need the parsed identity pass the
/// returned path to <see cref="ReadFromManifest(string)"/>.
/// </summary>
/// <param name="layoutDirectory">The directory to probe for an <c>AppxManifest.xml</c>.</param>
/// <returns>
/// The full path to the manifest when the directory is a packaged-app layout; otherwise
/// <see langword="null"/>.
/// </returns>
public static string? GetManifestPath(string layoutDirectory)
{
string manifestPath = Path.Combine(layoutDirectory, AppxManifestFileName);
return File.Exists(manifestPath) ? manifestPath : null;
}

/// <summary>
/// Searches <paramref name="startDirectory"/> and each of its ancestors for an
/// <c>AppxManifest.xml</c> and returns the path to the nearest one. A packaged app's manifest
/// lives at the package layout root, but an <c>Application/@Executable</c> can point into a
/// subdirectory (for example <c>bin\host.exe</c>), so the executable's own directory is not
/// necessarily the root. Walking up locates the manifest in that valid layout too, so the launcher
/// does not miss a packaged layout and fall through to <c>Process.Start</c>. Like
/// <see cref="GetManifestPath(string)"/> this is a cheap existence probe that never parses.
/// </summary>
/// <param name="startDirectory">The directory to start searching from (typically the executable's directory).</param>
/// <returns>
/// The full path to the nearest <c>AppxManifest.xml</c> at or above <paramref name="startDirectory"/>;
/// otherwise <see langword="null"/>.
/// </returns>
public static string? FindManifestPath(string startDirectory)
{
for (DirectoryInfo? directory = new(startDirectory); directory is not null; directory = directory.Parent)
{
string? manifestPath = GetManifestPath(directory.FullName);
if (manifestPath is not null)
{
return manifestPath;
}
}

return null;
}

/// <summary>Reads and parses the manifest at <paramref name="manifestPath"/>.</summary>
/// <param name="manifestPath">The path to an <c>AppxManifest.xml</c>.</param>
/// <returns>The parsed manifest info.</returns>
public static AppxManifestInfo ReadFromManifest(string manifestPath)
{
using FileStream stream = File.OpenRead(manifestPath);
return ReadFromManifest(stream);
}

/// <summary>Reads and parses a manifest from <paramref name="manifestStream"/>.</summary>
/// <param name="manifestStream">A stream over an <c>AppxManifest.xml</c>.</param>
/// <returns>The parsed manifest info.</returns>
public static AppxManifestInfo ReadFromManifest(Stream manifestStream)
{
var document = XDocument.Load(manifestStream);
XElement? root = document.Root;

// Match by local name so we are resilient to the manifest schema version (the foundation
// namespace URI changes across Windows 10 SDK revisions).
XElement? identity = root?.Elements().FirstOrDefault(static e => e.Name.LocalName == "Identity");
string? name = identity?.Attribute("Name")?.Value;
string? publisher = identity?.Attribute("Publisher")?.Value;

if (name is null || name.Length == 0 || publisher is null || publisher.Length == 0)
{
throw new InvalidOperationException(ExtensionResources.InvalidAppxManifestMissingIdentity);
}

string packageFamilyName = $"{name}_{ComputePublisherId(publisher)}";

// A package may declare several applications, each with its own id (and AUMID). Capture them
// all so the launcher can resolve the one matching the executable it was asked to launch,
// instead of guessing with the first entry.
List<AppxApplicationInfo> applications = root?.Elements().FirstOrDefault(static e => e.Name.LocalName == "Applications")?
.Elements().Where(static e => e.Name.LocalName == "Application")
.Select(application =>
{
string? applicationId = application.Attribute("Id")?.Value;
string? executable = application.Attribute("Executable")?.Value;
return applicationId is null || applicationId.Length == 0
? null
: new AppxApplicationInfo(applicationId, executable, $"{packageFamilyName}!{applicationId}");
})
.Where(static application => application is not null)
.Select(static application => application!)
.ToList()
?? [];

return new AppxManifestInfo(name, publisher, applications);
}

/// <summary>
/// Resolves the application whose test host the platform asked to launch. Most packages declare a
/// single application, which is returned directly. When a package declares several, the entry is
/// disambiguated by matching <paramref name="executableFileName"/> against each
/// <c>Application/@Executable</c>; an ambiguous request (no match, or several matches) is rejected
/// rather than silently defaulting to the first application, which would identify the wrong app.
/// </summary>
/// <param name="executableFileName">
/// The file name (not full path) of the executable the platform asked to launch, used to pick the
/// matching application when the manifest declares more than one.
/// </param>
/// <returns>
/// The matching application, or <see langword="null"/> when the manifest declares no application.
/// </returns>
/// <exception cref="InvalidOperationException">
/// The manifest declares multiple applications and <paramref name="executableFileName"/> does not
/// match exactly one of them.
/// </exception>
public AppxApplicationInfo? ResolveApplication(string? executableFileName)
{
switch (Applications.Count)
{
case 0:
return null;
case 1:
return Applications[0];
}

AppxApplicationInfo[] matches = [.. Applications.Where(application =>
application.Executable is not null
&& string.Equals(GetExecutableFileName(application.Executable), executableFileName, StringComparison.OrdinalIgnoreCase))];
Comment thread
Evangelink marked this conversation as resolved.
Comment thread
Evangelink marked this conversation as resolved.

return matches.Length == 1
? matches[0]
: throw new InvalidOperationException(
string.Format(
CultureInfo.CurrentCulture,
ExtensionResources.AmbiguousAppxManifestApplication,
executableFileName,
string.Join(", ", Applications.Select(static application => application.AppUserModelId))));
}

// The manifest's Application/@Executable is package-root-relative and may include a subdirectory
// (for example "bin\host.exe"), always with Windows separators. Reduce it to the bare file name so
// it can be matched against the executable file name the platform asked to launch, independently of
// the OS running the parser.
private static string GetExecutableFileName(string executable)
{
int lastSeparator = executable.LastIndexOfAny(['\\', '/']);
return lastSeparator < 0 ? executable : executable[(lastSeparator + 1)..];
}

/// <summary>
/// Computes the 13-character publisher id (the suffix of the package family name) for
/// <paramref name="publisher"/>, using the public Windows algorithm: base32-encode the first 8
/// bytes of the SHA-256 hash of the UTF-16LE publisher string.
/// </summary>
/// <param name="publisher">The manifest's <c>Identity/@Publisher</c> value.</param>
/// <returns>The 13-character publisher id.</returns>
internal static string ComputePublisherId(string publisher)
{
byte[] hash = SHA256.HashData(Encoding.Unicode.GetBytes(publisher));

// Take the first 8 bytes (64 bits) and encode them big-endian as 13 base32 characters. 64 bits
// is not a multiple of 5, so the final character carries the 4 leftover bits padded with a 0.
var builder = new StringBuilder(13);
int buffer = 0;
int bitsInBuffer = 0;
for (int i = 0; i < 8; i++)
{
buffer = (buffer << 8) | hash[i];
bitsInBuffer += 8;
while (bitsInBuffer >= 5)
{
bitsInBuffer -= 5;
builder.Append(PublisherHashAlphabet[(buffer >> bitsInBuffer) & 0x1F]);
buffer &= (1 << bitsInBuffer) - 1;
}
}

if (bitsInBuffer > 0)
{
builder.Append(PublisherHashAlphabet[(buffer << (5 - bitsInBuffer)) & 0x1F]);
}

return builder.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,18 @@
#nullable enable
Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo
Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo.AppUserModelId.get -> string!
Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo.AppxApplicationInfo(string! id, string? executable, string! appUserModelId) -> void
Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo.Executable.get -> string?
Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo.Id.get -> string!
Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo
Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.Applications.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo!>!
Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.PackageFamilyName.get -> string!
Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.PackageName.get -> string!
Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.Publisher.get -> string!
Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.ResolveApplication(string? executableFileName) -> Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo?
const Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.AppxManifestFileName = "AppxManifest.xml" -> string!
static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.ComputePublisherId(string! publisher) -> string!
static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.FindManifestPath(string! startDirectory) -> string?
static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.GetManifestPath(string! layoutDirectory) -> string?
static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.ReadFromManifest(System.IO.Stream! manifestStream) -> Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo!
static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.ReadFromManifest(string! manifestPath) -> Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo!
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<PackageDescription>
<![CDATA[This package provides extensions to Microsoft Testing Platform intended to extend base functionalities.

This package extends Microsoft Testing Platform to deploy and launch a packaged Windows (UWP/WinUI) test host through a custom mechanism instead of starting it in place.]]>
This package extends Microsoft Testing Platform to deploy and launch a non-packaged (loose-layout) Windows test host (for example, unpackaged WinUI) from an isolated directory instead of starting it in place. Launching a genuinely packaged (MSIX) app — UWP or packaged WinUI — by registering its layout with the PackageManager and activating it by Application User Model ID is not implemented yet (see https://github.com/microsoft/testfx/issues/9933).]]>
</PackageDescription>
</PropertyGroup>

Expand Down Expand Up @@ -55,4 +55,8 @@ This package extends Microsoft Testing Platform to deploy and launch a packaged
<ProjectReference Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Microsoft.Testing.Platform.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Testing.Extensions.UnitTests" Key="$(VsPublicKey)" />
</ItemGroup>

</Project>
10 changes: 5 additions & 5 deletions src/Platform/Microsoft.Testing.Extensions.PackagedApp/PACKAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
> [!IMPORTANT]
> This package is experimental and consumes the experimental `ITestHostLauncher` extension point of [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform). The API may change or be removed in a future update.

`Microsoft.Testing.Extensions.PackagedApp` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys a packaged Windows test host (UWP or packaged WinUI) into an isolated directory and launches it from there, instead of starting the test host in place.
`Microsoft.Testing.Extensions.PackagedApp` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys a non-packaged (loose-layout) Windows test host (for example, unpackaged WinUI) into an isolated directory and launches it from there, instead of starting the test host in place.

It is the consumer of the platform's `ITestHostLauncher` extension point for packaged Windows apps. UWP and packaged WinUI ship as MSIX and share the same launch mechanism, which is why VSTest exposes a single `UwpTestHostRuntimeProvider` for both:
It is the consumer of the platform's `ITestHostLauncher` extension point for Windows test hosts. Packaged Windows appsUWP and packaged WinUI — require package identity and ship as MSIX; they share a single launch mechanism, which is why VSTest exposes a single `UwpTestHostRuntimeProvider` for them:

- **Deploy + launch** (implemented): the app's loose layout is deployed to a deployment directory and the produced executable is launched from there.
- **Packaged AUMID activation** (planned): the loose layout is registered with the `PackageManager` and the app is activated by Application User Model ID (AUMID) via `IApplicationActivationManager` — the scenario behind [#2784](https://github.com/microsoft/testfx/issues/2784).
- **Deploy + launch loose layout** (implemented): a non-packaged (loose-layout) app — one without an `AppxManifest.xml`, such as unpackaged WinUI — is deployed to a deployment directory and the produced executable is launched from there.
- **Packaged AUMID activation** (planned): a genuinely packaged (MSIX) layout — UWP or packaged WinUI — is registered with the `PackageManager` and the app is activated by Application User Model ID (AUMID) via `IApplicationActivationManager` — the scenario behind [#9933](https://github.com/microsoft/testfx/issues/9933). Until then, a packaged layout is rejected with an actionable error rather than silently failing at launch.

Microsoft.Testing.Platform is open source. You can find `Microsoft.Testing.Extensions.PackagedApp` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository.

Expand All @@ -22,7 +22,7 @@ dotnet add package Microsoft.Testing.Extensions.PackagedApp

This package extends Microsoft.Testing.Platform with:

- **Deployment + launch**: stages the packaged Windows (UWP/WinUI) test host payload into an isolated directory and launches the deployed copy.
- **Deployment + launch**: stages a non-packaged (loose-layout) Windows test host payload (for example, unpackaged WinUI) into an isolated directory and launches the deployed copy. Genuinely packaged (MSIX) hosts — UWP and packaged WinUI — are not launched yet (see [#9933](https://github.com/microsoft/testfx/issues/9933)).
- **Mechanism-agnostic monitoring**: returns an `ITestHostHandle` that exposes only the lifecycle the platform needs and no local process id.

## Documentation
Expand Down
Loading
Loading