Skip to content
Draft
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
8 changes: 8 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@
This is an early preview package, keep 1.0.0-alpha or similar suffix even in official builds.
-->
<MicrosoftTestingExtensionsVideoRecorderVersionPrefix>1.0.0</MicrosoftTestingExtensionsVideoRecorderVersionPrefix>

<MicrosoftTestingPlatformBrowserPreReleaseVersionLabel>alpha</MicrosoftTestingPlatformBrowserPreReleaseVersionLabel>

<!--
The browser launcher is independently versioned so its browser-host integration can service
Chromium cadence without coupling that cadence to Microsoft.Testing.Platform.
-->
<MicrosoftTestingPlatformBrowserVersionPrefix>0.1.0</MicrosoftTestingPlatformBrowserVersionPrefix>
</PropertyGroup>

<!-- Pack config -->
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
<PackageVersion Include="Azure.AI.OpenAI" Version="2.1.0" />
<PackageVersion Include="Azure.Core" Version="1.62.0" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="$(MicrosoftTestingExtensionsCodeCoverageVersion)" />
<PackageVersion Include="Microsoft.Playwright" Version="$(MicrosoftPlaywrightVersion)" />
<PackageVersion Include="Microsoft.TestPlatform.ObjectModel" Version="$(MicrosoftNETTestSdkVersion)" />
<PackageVersion Include="Microsoft.TestPlatform.TranslationLayer" Version="$(MicrosoftNETTestSdkVersion)" />
<PackageVersion Include="Microsoft.TestPlatform.Filter.Source" Version="$(MicrosoftTestPlatformFilterSourceVersion)" />
Expand Down
1 change: 1 addition & 0 deletions Microsoft.Testing.Platform.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"src\\Platform\\Microsoft.Testing.Extensions.VSTestBridge\\Microsoft.Testing.Extensions.VSTestBridge.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.VideoRecorder\\Microsoft.Testing.Extensions.VideoRecorder.csproj",
"src\\Platform\\Microsoft.Testing.Platform.AI\\Microsoft.Testing.Platform.AI.csproj",
"src\\Platform\\Microsoft.Testing.Platform.Browser\\Microsoft.Testing.Platform.Browser.csproj",
"src\\Platform\\Microsoft.Testing.Platform.MSBuild\\Microsoft.Testing.Platform.MSBuild.csproj",
"src\\Platform\\Microsoft.Testing.Platform.ServerMode.Client.Sources\\Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj",
"src\\Platform\\Microsoft.Testing.Platform\\Microsoft.Testing.Platform.csproj",
Expand Down
1 change: 1 addition & 0 deletions MutationTesting.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<Project Path="src/Platform/Microsoft.Testing.Extensions.VSTestBridge/Microsoft.Testing.Extensions.VSTestBridge.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Extensions.VideoRecorder/Microsoft.Testing.Extensions.VideoRecorder.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform.AI/Microsoft.Testing.Platform.AI.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform.MSBuild/Microsoft.Testing.Platform.MSBuild.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj" />
<!-- Microsoft.Testing.Platform stays Microsoft-signed because generated test applications bind to that identity. -->
Expand Down
1 change: 1 addition & 0 deletions NonWindowsTests.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"src\\Platform\\Microsoft.Testing.Extensions.VSTestBridge\\Microsoft.Testing.Extensions.VSTestBridge.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.VideoRecorder\\Microsoft.Testing.Extensions.VideoRecorder.csproj",
"src\\Platform\\Microsoft.Testing.Platform.AI\\Microsoft.Testing.Platform.AI.csproj",
"src\\Platform\\Microsoft.Testing.Platform.Browser\\Microsoft.Testing.Platform.Browser.csproj",
"src\\Platform\\Microsoft.Testing.Platform.MSBuild\\Microsoft.Testing.Platform.MSBuild.csproj",
"src\\Platform\\Microsoft.Testing.Platform.ServerMode.Client.Sources\\Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj",
"src\\Platform\\Microsoft.Testing.Platform\\Microsoft.Testing.Platform.csproj",
Expand Down
1 change: 1 addition & 0 deletions TestFx.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
<Project Path="src/Platform/Microsoft.Testing.Extensions.VSTestBridge/Microsoft.Testing.Extensions.VSTestBridge.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Extensions.VideoRecorder/Microsoft.Testing.Extensions.VideoRecorder.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform.AI/Microsoft.Testing.Platform.AI.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform.MSBuild/Microsoft.Testing.Platform.MSBuild.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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.Platform.Browser;

internal static class BoundedResourceCleanup
{
public static async Task<T?> ObserveCreationAsync<T>(
Task<T> creationTask,
TimeSpan timeout,
Action<T> dispose,
Action<string> report)
where T : class
{
try
{
return await creationTask.WaitAsync(timeout).ConfigureAwait(false);
}
catch (TimeoutException)
{
report("Resource creation did not finish during bounded cleanup; disposal will continue in the background.");
_ = DisposeWhenCreatedAsync(creationTask, timeout, dispose, report);
return null;
}
catch (Exception ex)
{
report($"Resource creation failed during cleanup: {ex.Message}");
return null;
}
}

public static async Task DisposeAsync(
Action dispose,
TimeSpan timeout,
Action<string> report)
{
try
{
await Task.Run(dispose).WaitAsync(timeout).ConfigureAwait(false);
}
catch (Exception ex)
{
report($"Resource disposal did not complete cleanly: {ex.Message}");
}
}

private static async Task DisposeWhenCreatedAsync<T>(
Task<T> creationTask,
TimeSpan timeout,
Action<T> dispose,
Action<string> report)
{
try
{
T resource = await creationTask.ConfigureAwait(false);
await DisposeAsync(() => dispose(resource), timeout, report).ConfigureAwait(false);
}
catch (Exception ex)
{
report($"Late resource creation could not be cleaned up: {ex.Message}");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// 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.Platform.Browser;

internal sealed record BrowserLauncherOptions(
string HostCommand,
string HostArguments,
string HostWorkingDirectory,
string BrowserExecutable,
TimeSpan StartupTimeout,
TimeSpan CompletionTimeout,
IReadOnlyList<string> TestApplicationArguments,
DotnetTestHttpBootstrap Bootstrap)
{
public static BrowserLauncherOptions Parse(string[] args)
{
int separatorIndex = Array.IndexOf(args, "--");
if (separatorIndex < 0)
{
throw new BrowserLauncherException(
"The launcher command line must contain '--' before the Microsoft Testing Platform arguments.");
}

var options = new Dictionary<string, string>(StringComparer.Ordinal);
for (int i = 0; i < separatorIndex; i += 2)
{
if (i + 1 >= separatorIndex || !args[i].StartsWith("--", StringComparison.Ordinal))
{
throw new BrowserLauncherException($"Invalid launcher option at position {i + 1}.");
}

if (!options.TryAdd(args[i], args[i + 1]))
{
throw new BrowserLauncherException($"Launcher option '{args[i]}' was provided more than once.");
}
}

string hostCommand = ReadEncodedOption(options, "--host-command-uri", required: true);
string hostArguments = ReadEncodedOption(options, "--host-arguments-uri", required: false);
string hostWorkingDirectory = ReadEncodedOption(options, "--host-working-directory-uri", required: true);
string browserExecutable = Path.GetFullPath(
ReadEncodedOption(options, "--browser-executable-uri", required: true));
if (!File.Exists(browserExecutable))
{
throw new BrowserLauncherException(
$"The configured browser executable does not exist: '{browserExecutable}'.");
}

TimeSpan startupTimeout = ParseTimeout(options, "--startup-timeout-seconds");
TimeSpan completionTimeout = ParseTimeout(options, "--completion-timeout-seconds");
if (options.Count != 0)
{
throw new BrowserLauncherException(
$"Unknown launcher option '{options.Keys.First()}'.");
}

string[] expandedArguments = SdkResponseFileExpander.Expand(args[(separatorIndex + 1)..]);
var bootstrap = DotnetTestHttpBootstrap.Parse(expandedArguments);

return new BrowserLauncherOptions(
hostCommand,
hostArguments,
Path.GetFullPath(hostWorkingDirectory),
browserExecutable,
startupTimeout,
completionTimeout,
expandedArguments,
bootstrap);
}

private static string ReadEncodedOption(
Dictionary<string, string> options,
string name,
bool required)
{
if (!options.Remove(name, out string? encodedValue))
{
return required
? throw new BrowserLauncherException($"Required launcher option '{name}' is missing.")
: string.Empty;
}

string value = Uri.UnescapeDataString(encodedValue);
return required && string.IsNullOrWhiteSpace(value)
? throw new BrowserLauncherException($"Required launcher option '{name}' is empty.")
: value;
}

private static TimeSpan ParseTimeout(Dictionary<string, string> options, string name)
=> options.Remove(name, out string? value)
&& int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int seconds)
&& seconds > 0
? TimeSpan.FromSeconds(seconds)
: throw new BrowserLauncherException(
$"Launcher option '{name}' must be a positive integer.");
}

internal sealed record DotnetTestHttpBootstrap(Uri Endpoint, string Token)
{
public static DotnetTestHttpBootstrap Parse(IReadOnlyList<string> arguments)
{
string? server = null;
string? transport = null;
string? endpoint = null;
string? token = null;

for (int i = 0; i < arguments.Count; i++)
{
string argument = arguments[i];
if (argument is "--server" or "--dotnet-test-transport" or "--dotnet-test-http-endpoint" or "--dotnet-test-http-token")
{
if (++i >= arguments.Count)
{
throw new BrowserLauncherException(
$"Microsoft Testing Platform option '{argument}' has no value.");
}

string value = arguments[i];
switch (argument)
{
case "--server":
server = value;
break;
case "--dotnet-test-transport":
transport = value;
break;
case "--dotnet-test-http-endpoint":
endpoint = value;
break;
case "--dotnet-test-http-token":
token = value;
break;
}
}
}

Uri endpointUri = Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? parsedEndpoint)
? parsedEndpoint
: throw InvalidBootstrap();

return string.Equals(server, "dotnettestcli", StringComparison.OrdinalIgnoreCase)
&& string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)
&& endpointUri.Scheme is "http" or "https"
&& endpointUri.IsLoopback
&& !string.IsNullOrWhiteSpace(token)
&& !token.Any(char.IsWhiteSpace)
&& !token.Any(char.IsControl)
? new DotnetTestHttpBootstrap(endpointUri, token)
: throw InvalidBootstrap();

static BrowserLauncherException InvalidBootstrap()
=> new(
"The SDK did not provide a valid loopback authenticated HTTP dotnettestcli bootstrap.");
}
}

internal static class SdkResponseFileExpander
{
public static string[] Expand(IReadOnlyList<string> arguments)
{
if (arguments.Count == 0
|| !arguments[^1].StartsWith('@')
|| arguments[^1].Length == 1)
{
throw new BrowserLauncherException(
"The browser experiment requires the SDK HTTP bootstrap response file as the final test application argument.");
}

for (int i = 0; i < arguments.Count - 1; i++)
{
if (arguments[i].StartsWith('@'))
{
throw new BrowserLauncherException(
"User response files are not supported by the browser experiment. Pass those arguments directly.");
}
}

var expanded = new List<string>(arguments.Count + 8);
expanded.AddRange(arguments.Take(arguments.Count - 1));
string path = Path.GetFullPath(arguments[^1][1..]);
try
{
ValidateResponseFilePermissions(path);
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.None);
using var reader = new StreamReader(
stream,
new UTF8Encoding(
encoderShouldEmitUTF8Identifier: false,
throwOnInvalidBytes: true));

while (reader.ReadLine() is { } line)
{
string trimmed = line.Trim();
if (trimmed.Length == 0 || trimmed[0] == '#')
{
continue;
}

int separator = trimmed.IndexOfAny([' ', '\t']);
if (separator < 0)
{
expanded.Add(trimmed);
continue;
}

expanded.Add(trimmed[..separator]);
string value = trimmed[(separator + 1)..].TrimStart();
if (value.Length != 0)
{
expanded.Add(value);
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException)
{
throw new BrowserLauncherException(
"Unable to read the SDK response file.",
ex);
}

return [.. expanded];
}

private static void ValidateResponseFilePermissions(string path)
{
if (OperatingSystem.IsWindows())
{
return;
}

UnixFileMode mode = File.GetUnixFileMode(path);
const UnixFileMode disallowed =
UnixFileMode.GroupRead
| UnixFileMode.GroupWrite
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherWrite
| UnixFileMode.OtherExecute;
if ((mode & disallowed) != 0)
{
throw new BrowserLauncherException(
"The SDK response file is accessible by users other than its owner.");
}
}
}

internal sealed class BrowserLauncherException : Exception
{
public BrowserLauncherException(string message)
: base(message)
{
}

public BrowserLauncherException(string message, Exception innerException)
: base(message, innerException)
{
}
}
Loading
Loading