Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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,149 @@
using Xunit;

namespace FirebaseBindingAudit.Tests;

public sealed class BindingSurfaceCoverageBuilderTests
{
[Fact]
public void Build_RecordsManualMethodParameterTypes()
{
var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}");

try
{
var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth");
Directory.CreateDirectory(sourceDirectory);
File.WriteAllText(
Path.Combine(sourceDirectory, "ApiDefinition.cs"),
"""
namespace Firebase.Auth;

public partial class AuthManualSurface
{
[Wrap("SignInWithEmail")]
public string SignIn(string email, bool createUser) => email;
}
""");

var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build(
repoRoot,
CreateManifest(),
"Auth");

var surface = Assert.Single(
document.Targets.Single().Surfaces,
static surface => surface.MemberName == "SignIn");

Assert.Equal(2, surface.ParameterCount);
Assert.Equal(["string", "bool"], surface.ParameterTypes);
Assert.Equal("string", surface.ReturnType);
Assert.Equal("SignIn(string, bool) -> string", surface.Signature);
}
finally
{
if (Directory.Exists(repoRoot))
{
Directory.Delete(repoRoot, recursive: true);
}
}
}

[Fact]
public void Build_RecordsPublicHelperDelegatesConstructorsAndIndexers()
{
var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}");

try
{
var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth");
Directory.CreateDirectory(sourceDirectory);
File.WriteAllText(
Path.Combine(sourceDirectory, "ApiDefinition.cs"),
"""
namespace Firebase.Auth;

[BaseType(typeof(NSObject), Name = "FIRAuth")]
public interface Auth
{
}
""");
File.WriteAllText(
Path.Combine(sourceDirectory, "Extension.cs"),
"""
namespace Firebase.Auth;

public partial class Auth
{
public delegate string TokenFactory(int index);

public Auth(string name)
{
}

public string this[int index] => "";
}
""");

var document = new BindingSurfaceCoverageBuilder(CreateConfiguration(["Extension.cs"])).Build(
repoRoot,
CreateManifest("Extension.cs"),
"Auth");

var surfaces = document.Targets.Single().Surfaces;
var helperDelegate = Assert.Single(surfaces, static surface => surface.Kind == "manual-delegate");
Assert.Equal("Firebase.Auth.Auth+TokenFactory", helperDelegate.RuntimeTypeName);
Assert.Equal(["int"], helperDelegate.ParameterTypes);
Assert.Equal("string", helperDelegate.ReturnType);

var helperConstructor = Assert.Single(surfaces, static surface => surface.Kind == "manual-constructor");
Assert.Equal("Firebase.Auth.Auth", helperConstructor.RuntimeTypeName);
Assert.Equal(["string"], helperConstructor.ParameterTypes);

var helperIndexer = Assert.Single(surfaces, static surface => surface.Kind == "manual-indexer");
Assert.Equal("Item", helperIndexer.MemberName);
Assert.Equal(["int"], helperIndexer.ParameterTypes);
Assert.Equal("string", helperIndexer.ReturnType);
Assert.True(helperIndexer.HasGetter);
}
finally
{
if (Directory.Exists(repoRoot))
{
Directory.Delete(repoRoot, recursive: true);
}
}
}

private static AuditConfiguration CreateConfiguration(string[]? helperFiles = null) =>
new()
{
ManualAttributes = ["Wrap"],
BindingAttributes = ["Export", "Field", "Notification"],
Targets =
[
new AuditTargetDefinition
{
Id = "Auth",
PackageId = "AdamE.Firebase.iOS.Auth",
BaselineDirectory = Path.Combine("source", "Firebase", "Auth"),
BaselineFiles = ["ApiDefinition.cs"],
HelperFiles = helperFiles ?? []
}
]
};

private static BindingSurfaceCoverageManifest CreateManifest(params string[] helperFiles) =>
new()
{
Targets =
[
new BindingSurfaceCoverageTargetManifest
{
Id = "Auth",
PackageId = "AdamE.Firebase.iOS.Auth",
CoverageCaseMethod = "VerifyAuthBindingSurfaceAsync",
SourceFiles = [Path.Combine("source", "Firebase", "Auth", "ApiDefinition.cs"), .. helperFiles.Select(static file => Path.Combine("source", "Firebase", "Auth", file))]
}
]
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using Xunit;

namespace FirebaseBindingAudit.Tests;

public sealed class BindingSurfaceCoverageValidatorTests
{
[Fact]
public void Validate_DetectsUnclaimedSurfaces()
{
var document = CreateDocument(CreateSurface("Core:type:FIRApp"));

var result = BindingSurfaceCoverageValidator.Validate(document, []);

Assert.Equal(["Core:type:FIRApp"], result.UnclaimedSurfaceIds);
Assert.False(result.IsValid);
}

[Fact]
public void Validate_DetectsStaleWaivers()
{
var document = CreateDocument(
CreateSurface("Core:type:FIRApp"),
waivers: [CreateWaiver("Core:type:Missing")]);

var result = BindingSurfaceCoverageValidator.Validate(document);

Assert.Equal(["Core:type:Missing"], result.StaleWaiverSurfaceIds);
Assert.False(result.IsValid);
}

[Fact]
public void Validate_DetectsStaleExerciserSurfaceIds()
{
var document = CreateDocument(CreateSurface("Core:type:FIRApp"));

var result = BindingSurfaceCoverageValidator.Validate(
document,
[new BindingSurfaceExerciseRecord("Core", "Core:type:Missing")]);

Assert.Equal(["Core:type:Missing"], result.StaleExerciseSurfaceIds);
Assert.False(result.IsValid);
}

[Fact]
public void Validate_AcceptsExactManifestCoverage()
{
var document = CreateDocument(CreateSurface("Core:type:FIRApp"));

var result = BindingSurfaceCoverageValidator.Validate(
document,
[new BindingSurfaceExerciseRecord("Core", "Core:type:FIRApp")]);

Assert.True(result.IsValid);
}

[Fact]
public void Validate_TreatsWaivedSurfacesAsReportedButNotFailed()
{
var document = CreateDocument(
CreateSurface("Core:type:FIRApp"),
waivers: [CreateWaiver("Core:type:FIRApp")]);

var result = BindingSurfaceCoverageValidator.Validate(document, []);

Assert.True(result.IsValid);
}

private static BindingSurfaceCoverageDocument CreateDocument(
BindingSurfaceDescriptor surface,
IReadOnlyList<BindingSurfaceWaiver>? waivers = null) =>
new(
[
new BindingSurfaceCoverageTargetDocument(
"Core",
"AdamE.Firebase.iOS.Core",
"VerifyCoreBindingSurfaceAsync",
["source/Firebase/Core/ApiDefinition.cs"],
[new BindingSurfacePackageReference { Id = "AdamE.Firebase.iOS.Core", Version = "12.6.0" }],
[surface])
],
waivers ?? []);

private static BindingSurfaceDescriptor CreateSurface(string surfaceId) =>
new(
Target: "Core",
SurfaceId: surfaceId,
Kind: "bound-type",
TypeName: "Firebase.Core.App",
RuntimeTypeName: "Firebase.Core.App",
AssemblyName: "Firebase.Core",
ObjectiveCName: "FIRApp",
ContainerKind: "interface",
IsProtocol: false,
IsStatic: false,
MemberName: null,
BindingAttribute: null,
BindingValue: null,
HasGetter: false,
HasSetter: false,
ParameterCount: 0,
ParameterTypes: [],
ReturnType: null,
NativeSelectors: [],
SourceFile: "source/Firebase/Core/ApiDefinition.cs",
Signature: "interface App");

private static BindingSurfaceWaiver CreateWaiver(string surfaceId) =>
new()
{
Target = "Core",
SurfaceId = surfaceId,
Kind = "native-owned-callback-type",
Reason = "unit test",
Evidence = "unit test",
};
}
45 changes: 36 additions & 9 deletions scripts/FirebaseBindingAudit/BindingModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,13 @@ internal sealed record ManualSurfaceItem(
string MatchTypeKey,
string? MatchMemberKey,
string? MemberName,
IReadOnlyList<string> ParameterTypes,
string? ReturnType,
string Signature,
string SourceFile);
string SourceFile)
{
public IReadOnlyList<string> ManualAttributes { get; init; } = [];
}

internal sealed class BindingSyntaxParser
{
Expand Down Expand Up @@ -286,6 +291,8 @@ private static void AddManualTypeItems(
MatchTypeKey: typeMatchKey,
MatchMemberKey: null,
MemberName: null,
ParameterTypes: [],
ReturnType: null,
Signature: $"{containerKind} {typeName}",
SourceFile: filePath));
return;
Expand All @@ -299,6 +306,8 @@ private static void AddManualTypeItems(
MatchTypeKey: typeMatchKey,
MatchMemberKey: member.Key,
MemberName: member.Name,
ParameterTypes: member.Parameters.Select(static parameter => parameter.Type).ToList(),
ReturnType: member.ReturnType,
Signature: member.Signature,
SourceFile: member.SourceFile));
}
Expand All @@ -316,7 +325,8 @@ private void ParseMethod(
{
var bindingAttribute = GetPrimaryBindingAttribute(methodDeclaration.AttributeLists);
var bindingValue = bindingAttribute is null ? null : GetBindingValue(methodDeclaration.AttributeLists, bindingAttribute);
var isManual = isHelperFile || HasAnyAttribute(methodDeclaration.AttributeLists, manualAttributes);
var manualAttributeNames = GetMatchingManualAttributes(methodDeclaration.AttributeLists);
var isManual = isHelperFile || manualAttributeNames.Count > 0;
var signature = BuildMethodSignature(methodDeclaration);

if (isManual)
Expand All @@ -327,8 +337,13 @@ private void ParseMethod(
MatchTypeKey: containingTypeMatchKey,
MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, methodDeclaration.Identifier.Text, methodDeclaration.ParameterList.Parameters.Count),
MemberName: methodDeclaration.Identifier.Text,
ParameterTypes: methodDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(),
ReturnType: NormalizeType(methodDeclaration.ReturnType),
Signature: signature,
SourceFile: filePath));
SourceFile: filePath)
{
ManualAttributes = manualAttributeNames
});
return;
}

Expand Down Expand Up @@ -373,7 +388,8 @@ private void ParseProperty(
{
var bindingAttribute = GetPrimaryBindingAttribute(propertyDeclaration.AttributeLists);
var bindingValue = bindingAttribute is null ? null : GetBindingValue(propertyDeclaration.AttributeLists, bindingAttribute);
var isManual = isHelperFile || HasAnyAttribute(propertyDeclaration.AttributeLists, manualAttributes);
var manualAttributeNames = GetMatchingManualAttributes(propertyDeclaration.AttributeLists);
var isManual = isHelperFile || manualAttributeNames.Count > 0;
var signature = BuildPropertySignature(propertyDeclaration);

if (isManual)
Expand All @@ -384,8 +400,13 @@ private void ParseProperty(
MatchTypeKey: containingTypeMatchKey,
MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, propertyDeclaration.Identifier.Text, 0),
MemberName: propertyDeclaration.Identifier.Text,
ParameterTypes: [],
ReturnType: NormalizeType(propertyDeclaration.Type),
Signature: signature,
SourceFile: filePath));
SourceFile: filePath)
{
ManualAttributes = manualAttributeNames
});
return;
}

Expand Down Expand Up @@ -433,6 +454,8 @@ private void ParseDelegate(
MatchTypeKey: delegateDeclaration.Identifier.Text,
MatchMemberKey: null,
MemberName: null,
ParameterTypes: delegateDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(),
ReturnType: NormalizeType(delegateDeclaration.ReturnType),
Signature: BuildDelegateSignature(delegateDeclaration),
SourceFile: filePath));
return;
Expand Down Expand Up @@ -467,6 +490,8 @@ private void ParseEnum(
MatchTypeKey: enumDeclaration.Identifier.Text,
MatchMemberKey: null,
MemberName: null,
ParameterTypes: [],
ReturnType: null,
Signature: $"enum {enumDeclaration.Identifier.Text}",
SourceFile: filePath));
return;
Expand Down Expand Up @@ -771,20 +796,22 @@ private static string CombineNamespace(string currentNamespace, string childName
return string.IsNullOrWhiteSpace(currentNamespace) ? childNamespace : $"{currentNamespace}.{childNamespace}";
}

private bool HasAnyAttribute(SyntaxList<AttributeListSyntax> attributeLists, HashSet<string> attributeNames)
private IReadOnlyList<string> GetMatchingManualAttributes(SyntaxList<AttributeListSyntax> attributeLists)
{
var matchingAttributes = new List<string>();
foreach (var attributeList in attributeLists)
{
foreach (var attribute in attributeList.Attributes)
{
if (attributeNames.Contains(NormalizeAttributeName(attribute.Name)))
var attributeName = NormalizeAttributeName(attribute.Name);
if (manualAttributes.Contains(attributeName))
{
return true;
matchingAttributes.Add(attributeName);
}
}
}

return false;
return matchingAttributes;
}

private static bool HasAttribute(SyntaxList<AttributeListSyntax> attributeLists, string attributeName)
Expand Down
Loading
Loading