Skip to content

Commit 8ed2256

Browse files
Expand integration acceptance coverage
1 parent 95802a2 commit 8ed2256

91 files changed

Lines changed: 6759 additions & 4972 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/integration-emulators.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ jobs:
4242
- name: Install Firebase CLI and XHarness
4343
run: scripts/install-integration-test-tools.sh
4444

45+
- name: Check integration coverage metadata
46+
run: scripts/check-integration-coverage.rb
47+
4548
- name: Build Cloud Functions
4649
run: |
4750
npm ci --prefix tests/cloud-functions/functions --legacy-peer-deps
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env ruby
2+
3+
require "pathname"
4+
5+
repo_root = Pathname.new(__dir__).join("..").realpath
6+
tests_root = repo_root.join("tests", "Plugin.Firebase.IntegrationTests")
7+
coverage_file = tests_root.join("ACCEPTANCE_COVERAGE.md")
8+
9+
PACKAGE_LABELS = {
10+
"Analytics" => "Analytics",
11+
"AppCheck" => "App Check",
12+
"Auth" => "Auth",
13+
"Bundled" => "Bundled initializer",
14+
"CloudMessaging" => "Cloud Messaging",
15+
"Crashlytics" => "Crashlytics",
16+
"Firestore" => "Firestore",
17+
"Functions" => "Functions",
18+
"Installations" => "Installations",
19+
"PerformanceMonitoring" => "Performance Monitoring",
20+
"RemoteConfig" => "Remote Config",
21+
"Storage" => "Storage"
22+
}.freeze
23+
24+
def coverage_packages(coverage_file)
25+
packages = {}
26+
File.readlines(coverage_file).each do |line|
27+
next unless line.start_with?("|")
28+
29+
cells = line.split("|").map(&:strip)
30+
package = cells[1]
31+
next if package.nil? || package.empty? || package == "Package" || package.start_with?("---")
32+
33+
packages[package] = true
34+
end
35+
packages
36+
end
37+
38+
def class_declarations(content)
39+
declarations = []
40+
class_pattern = /^\s*(?:public|internal|private)?\s*(?:sealed\s+)?(?:partial\s+)?class\s+(\w+Fixture)\b/
41+
content.to_enum(:scan, class_pattern).each do
42+
match = Regexp.last_match
43+
prefix = content[0...match.begin(0)].lines.last(10).join
44+
declarations << {
45+
name: match[1],
46+
package: prefix[/IntegrationTestFixture\(IntegrationTestPackage\.(\w+)\)/, 1],
47+
ignored: prefix.include?("IntegrationTestCoverageIgnore(")
48+
}
49+
end
50+
declarations
51+
end
52+
53+
def test_case_count(content)
54+
content.scan(/^\s*\[(?:\w*(?:Fact|Theory)|Fact|Theory)(?:\(|\])/).size
55+
end
56+
57+
coverage = coverage_packages(coverage_file)
58+
errors = []
59+
fixtures = {}
60+
test_cases = 0
61+
62+
Dir.glob(tests_root.join("**", "*.cs")).sort.each do |file|
63+
next if file.include?("/bin/") || file.include?("/obj/")
64+
65+
content = File.read(file)
66+
test_cases += test_case_count(content)
67+
class_declarations(content).each do |declaration|
68+
fixture = fixtures[declaration[:name]] ||= {
69+
files: [],
70+
packages: [],
71+
ignored: false
72+
}
73+
fixture[:files] << Pathname.new(file).relative_path_from(repo_root).to_s
74+
fixture[:packages] << declaration[:package] if declaration[:package]
75+
fixture[:ignored] ||= declaration[:ignored]
76+
end
77+
end
78+
79+
fixtures.each do |name, fixture|
80+
next if fixture[:ignored]
81+
82+
packages = fixture[:packages].uniq
83+
if packages.empty?
84+
errors << "#{name} is missing IntegrationTestFixture metadata."
85+
next
86+
end
87+
88+
packages.each do |package|
89+
label = PACKAGE_LABELS[package]
90+
if label.nil?
91+
errors << "#{name} references unknown IntegrationTestPackage.#{package}."
92+
elsif !coverage.key?(label)
93+
errors << "#{name} maps to #{label}, but #{coverage_file.relative_path_from(repo_root)} does not list that package."
94+
end
95+
end
96+
end
97+
98+
if coverage.key?("Dynamic Links")
99+
errors << "Dynamic Links is intentionally excluded and must not be listed in #{coverage_file.relative_path_from(repo_root)}."
100+
end
101+
102+
if errors.any?
103+
warn "Integration coverage metadata audit failed:"
104+
errors.each { |error| warn " - #{error}" }
105+
exit 1
106+
end
107+
108+
tracked_packages = fixtures
109+
.values
110+
.flat_map { |fixture| fixture[:packages] }
111+
.compact
112+
.uniq
113+
.sort
114+
.map { |package| PACKAGE_LABELS.fetch(package, package) }
115+
116+
ignored_fixtures = fixtures.count { |_, fixture| fixture[:ignored] }
117+
118+
puts "Integration coverage metadata audit passed."
119+
puts "Fixtures: #{fixtures.length} (#{ignored_fixtures} ignored harness fixture)"
120+
puts "Packages: #{tracked_packages.join(", ")}"
121+
puts "Test cases discovered: #{test_cases}"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Integration Acceptance Coverage
2+
3+
`Plugin.Firebase.IntegrationTests` is the acceptance suite for active packages. Dynamic Links is intentionally excluded.
4+
5+
Use this file as the checklist when adding or changing public mobile APIs: every active public surface should be covered automatically, explicitly gated behind a real-backend or opt-in attribute, or documented as not locally assertable.
6+
7+
Run `scripts/check-integration-coverage.rb` after adding or renaming fixtures. The script verifies that every fixture is annotated with `IntegrationTestFixture` metadata and maps to a package listed in the table below. Harness-only fixtures must opt out with `IntegrationTestCoverageIgnore` and a reason.
8+
9+
| Package | Default emulator gate | Real backend | Opt-in/manual | Notes |
10+
|---|---:|---:|---:|---|
11+
| Analytics | No | Yes | No | Verifies SDK acceptance and observable local state; Firebase Console ingestion is not asserted. |
12+
| App Check | Partial | Optional | `PLUGIN_FIREBASE_RUN_APPCHECK_TOKEN_TESTS` | Disabled/debug/provider behavior is automatic; token enforcement requires a real project. |
13+
| Auth | Yes | Partial | `PLUGIN_FIREBASE_RUN_PHONE_AUTH_TESTS` | Email/password, anonymous, custom tokens, email links, metadata, and claims are covered. Phone auth requires external credentials. |
14+
| Bundled initializer | Yes | Yes | No | Verifies singleton access and dispose/reacquire behavior without reconfiguring initialized native SDKs. |
15+
| Cloud Messaging | Partial | Optional | `PLUGIN_FIREBASE_RUN_FCM_TOKEN_TESTS`, `PLUGIN_FIREBASE_RUN_FCM_DELIVERY_TESTS` | Synthetic events are automatic. Token and push delivery require a real project; iOS delivery requires a physical device with APNs. |
16+
| Crashlytics | Partial | Partial | `PLUGIN_FIREBASE_FORCE_CRASHLYTICS_CRASH`, `PLUGIN_FIREBASE_EXPECT_PREVIOUS_CRASH` | Non-crash APIs are automatic. Previous-crash detection needs a destructive two-run flow. |
17+
| Firestore | Yes | Yes | No | Emulator-backed PR gate covers document, query, conversion, nullability, listener, lifecycle, and offline behavior. |
18+
| Functions | Yes | Yes | No | Real backend requires deploying `tests/cloud-functions/functions`. |
19+
| Installations | Partial | Yes | `PLUGIN_FIREBASE_RUN_INSTALLATIONS_DELETE_TESTS` | Delete is opt-in because it resets the shared installation identity. |
20+
| Performance Monitoring | Partial | Yes | No | Verifies SDK acceptance and local state; Firebase Console ingestion is not asserted. |
21+
| Remote Config | No | Yes | No | Requires published real-project parameters. |
22+
| Storage | Yes | Yes | No | Real backend requires bucket seed files and permissive test rules. |
23+
24+
## Expected Fixture Metadata
25+
26+
| Metadata package | Acceptance coverage row |
27+
|---|---|
28+
| `IntegrationTestPackage.Analytics` | Analytics |
29+
| `IntegrationTestPackage.AppCheck` | App Check |
30+
| `IntegrationTestPackage.Auth` | Auth |
31+
| `IntegrationTestPackage.Bundled` | Bundled initializer |
32+
| `IntegrationTestPackage.CloudMessaging` | Cloud Messaging |
33+
| `IntegrationTestPackage.Crashlytics` | Crashlytics |
34+
| `IntegrationTestPackage.Firestore` | Firestore |
35+
| `IntegrationTestPackage.Functions` | Functions |
36+
| `IntegrationTestPackage.Installations` | Installations |
37+
| `IntegrationTestPackage.PerformanceMonitoring` | Performance Monitoring |
38+
| `IntegrationTestPackage.RemoteConfig` | Remote Config |
39+
| `IntegrationTestPackage.Storage` | Storage |
40+
41+
## Harness Rules
42+
43+
- Prefer `Fact`, `EmulatorBackendFact`, `RealFirebaseFact`, `OptInFact`, or `RealFirebaseOptInFact` over runtime skips. Runtime skips can be reported as failures by device runners.
44+
- Put `IntegrationTestFixture(IntegrationTestPackage.X)` on each package fixture root. Use the backend/platform/opt-in fact attributes for method-level metadata, and add `IntegrationTestCase` only when a plain xUnit attribute needs explicit metadata.
45+
- Use `IntegrationTestData` for unique resource names and opt-in configuration values.
46+
- Use `WaitForTestAsync` or `EventuallyAsync` for asynchronous device callbacks so timeout failures include the operation being awaited.
47+
- Keep destructive, paid, external-delivery, or console-ingestion checks behind opt-in attributes.

tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsFixture.cs

Lines changed: 48 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,48 +4,10 @@ namespace Plugin.Firebase.IntegrationTests.Analytics
44
{
55
[Collection("Sequential")]
66
[TestLogging]
7+
[IntegrationTestFixture(IntegrationTestPackage.Analytics)]
78
[Preserve(AllMembers = true)]
89
public sealed class AnalyticsFixture
910
{
10-
#if ANDROID
11-
[Fact]
12-
public void throws_actionable_exception_when_android_analytics_is_not_initialized()
13-
{
14-
var firebaseAnalyticsField = typeof(FirebaseAnalyticsImplementation).GetField(
15-
"_firebaseAnalytics",
16-
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static
17-
);
18-
Assert.NotNull(firebaseAnalyticsField);
19-
20-
var originalFirebaseAnalytics = firebaseAnalyticsField.GetValue(null);
21-
try {
22-
firebaseAnalyticsField.SetValue(null, null);
23-
24-
var logEventException = Assert.Throws<InvalidOperationException>(
25-
() => CrossFirebaseAnalytics.Current.LogEvent("test_uninitialized_analytics_guard")
26-
);
27-
AssertAnalyticsNotInitializedException(logEventException);
28-
29-
var setDefaultEventParametersException = Assert.Throws<InvalidOperationException>(
30-
() => CrossFirebaseAnalytics.Current.SetDefaultEventParameters(new Dictionary<string, object> {
31-
{ "default_string", "some_value" }
32-
})
33-
);
34-
AssertAnalyticsNotInitializedException(setDefaultEventParametersException);
35-
}
36-
finally {
37-
firebaseAnalyticsField.SetValue(null, originalFirebaseAnalytics);
38-
}
39-
}
40-
41-
private static void AssertAnalyticsNotInitializedException(InvalidOperationException exception)
42-
{
43-
Assert.Contains("Firebase Analytics has not been initialized on Android", exception.Message);
44-
Assert.Contains("FirebaseAnalyticsImplementation.Initialize(activity)", exception.Message);
45-
Assert.Contains("isAnalyticsEnabled: true", exception.Message);
46-
}
47-
#endif
48-
4911
[RealFirebaseFact]
5012
public void does_not_throw_any_exception_when_logging_events()
5113
{
@@ -92,7 +54,7 @@ public void does_not_throw_any_exception_when_setting_default_event_parameters_v
9254
sut.LogEvent("test_with_default_dictionary_parameters");
9355
}
9456
finally {
95-
sut.SetDefaultEventParameters((IDictionary<string, object>) null);
57+
sut.SetDefaultEventParameters((IDictionary<string, object>?) null);
9658
}
9759
}
9860

@@ -110,14 +72,14 @@ public void does_not_throw_any_exception_when_setting_default_event_parameters_v
11072
sut.LogEvent("test_with_default_tuple_parameters");
11173
}
11274
finally {
113-
sut.SetDefaultEventParameters((IDictionary<string, object>) null);
75+
sut.SetDefaultEventParameters((IDictionary<string, object>?) null);
11476
}
11577
}
11678

11779
[RealFirebaseFact]
11880
public void does_not_throw_any_exception_when_clearing_default_event_parameters()
11981
{
120-
CrossFirebaseAnalytics.Current.SetDefaultEventParameters((IDictionary<string, object>) null);
82+
CrossFirebaseAnalytics.Current.SetDefaultEventParameters((IDictionary<string, object>?) null);
12183
}
12284

12385
[RealFirebaseFact]
@@ -128,13 +90,57 @@ public void does_not_throw_any_exception_when_setting_user_properties()
12890
sut.SetUserProperty("some_name", "some_value");
12991
}
13092

93+
[RealFirebaseFact]
94+
public void does_not_throw_any_exception_when_clearing_user_properties()
95+
{
96+
var sut = CrossFirebaseAnalytics.Current;
97+
sut.SetUserId(null);
98+
sut.SetUserProperty("some_name", null);
99+
}
100+
131101
[RealFirebaseFact]
132102
public async Task does_not_throw_any_exception_when_getting_app_instance_id()
133103
{
134104
var sut = CrossFirebaseAnalytics.Current;
135105
Assert.NotNull(await sut.GetAppInstanceIdAsync());
136106
}
137107

108+
[RealFirebaseFact]
109+
public async Task reset_analytics_data_keeps_instance_id_api_usable()
110+
{
111+
var sut = CrossFirebaseAnalytics.Current;
112+
113+
sut.ResetAnalyticsData();
114+
115+
Assert.NotNull(await sut.GetAppInstanceIdAsync());
116+
}
117+
118+
[RealFirebaseFact]
119+
public void accepts_events_while_collection_is_disabled()
120+
{
121+
var sut = CrossFirebaseAnalytics.Current;
122+
123+
try {
124+
sut.IsAnalyticsCollectionEnabled = false;
125+
sut.LogEvent("test_collection_disabled", ("some_parameter", "some_value"));
126+
}
127+
finally {
128+
sut.IsAnalyticsCollectionEnabled = true;
129+
}
130+
}
131+
132+
[RealFirebaseFact]
133+
public void accepts_boundary_sized_parameters_and_user_properties()
134+
{
135+
var sut = CrossFirebaseAnalytics.Current;
136+
var parameterName = new string('p', 40);
137+
var userPropertyName = new string('u', 24);
138+
var userPropertyValue = new string('v', 36);
139+
140+
sut.LogEvent("test_boundary_parameters", (parameterName, "value"));
141+
sut.SetUserProperty(userPropertyName, userPropertyValue);
142+
}
143+
138144
[RealFirebaseFact]
139145
public void does_not_throw_any_exception_at_other_methods()
140146
{

0 commit comments

Comments
 (0)