From 4982e751d542da6d07a379285f1d2fb07ee4926a Mon Sep 17 00:00:00 2001 From: Mohanned Binmiskeen Date: Sun, 6 Sep 2026 18:18:56 +0200 Subject: [PATCH 1/3] fix(verify): disclose skipped cross-checks and report URL schemes per platform verify silently degraded to local-only checks when it could not fetch the dashboard config (unauthenticated, or a project selected without credentials) and still printed a bare green PASS, so a passing run could coexist with deep linking being unverified. It now emits an explicit skip notice, surfaces it in the default report, and qualifies the result as PARTIAL unless every check ran. Also fixes the iOS/Android URL scheme checks printing the pooled scheme union (iOS + Android combined for Flutter) under each platform header; they now read the per-platform lists, so an iOS-only scheme is no longer reported under Android and vice versa. - add skippedCount/hasSkipped to VerificationReport - emit a skip result whenever the dashboard cross-check does not run, including the project-selected-but-no-credentials case that previously added none - default report: show skipped count, a NOT VERIFIED section, and PARTIAL status - ios/android validators read iosUrlSchemes/androidUrlSchemes, not urlSchemes - tests: guards against cross-platform scheme pooling and a silent green PASS --- lib/commands/verify_command.dart | 22 +++++++-- lib/models/verification_result.dart | 3 ++ lib/reporters/report_generator.dart | 40 ++++++++++++++- lib/validators/android_validator.dart | 12 +++-- lib/validators/ios_validator.dart | 11 +++-- .../unit/reporters/report_generator_test.dart | 49 +++++++++++++++++++ .../validators/android_validator_test.dart | 30 ++++++++++++ test/unit/validators/ios_validator_test.dart | 29 +++++++++++ 8 files changed, 182 insertions(+), 14 deletions(-) diff --git a/lib/commands/verify_command.dart b/lib/commands/verify_command.dart index 843ea45..a8ecddb 100644 --- a/lib/commands/verify_command.dart +++ b/lib/commands/verify_command.dart @@ -420,14 +420,28 @@ class VerifyCommand { ), ); } - } else if (effectiveProjectId == null) { + } else { + // No dashboard config was fetched (missing project ID and/or credentials). + // Only local files were inspected — the local config was NOT compared + // against the ULink dashboard, and the hosted AASA / assetlinks.json files + // were never fetched. Make that explicit so a green run is not mistaken for + // a full verification. results.add( VerificationResult( - checkName: 'ULink API Connection', + checkName: 'Dashboard cross-check (bundle id, team id, package, fingerprints, AASA & assetlinks.json)', status: VerificationStatus.skipped, - message: 'Project ID and credentials not provided', + message: effectiveProjectId == null + ? 'Not authenticated — local files were checked, but they were NOT ' + 'compared against your ULink dashboard config, and the hosted ' + 'well-known files were not fetched.' + : 'No credentials — a project is selected, but local files were NOT ' + 'compared against the ULink dashboard config, and the hosted ' + 'well-known files were not fetched.', fixSuggestion: - 'Run "ulink login" to authenticate, or provide --project-id and --api-key', + 'Run "ulink login" to authenticate (or pass --api-key) so verify can ' + 'compare local config against the dashboard and fetch the domain\'s ' + 'AASA / assetlinks.json. Without this, verify only confirms local ' + 'files exist — not that deep linking actually resolves.', ), ); } diff --git a/lib/models/verification_result.dart b/lib/models/verification_result.dart index 8c23b14..d4a54ea 100644 --- a/lib/models/verification_result.dart +++ b/lib/models/verification_result.dart @@ -40,7 +40,10 @@ class VerificationReport { results.where((r) => r.status == VerificationStatus.warning).length; int get errorCount => results.where((r) => r.status == VerificationStatus.error).length; + int get skippedCount => + results.where((r) => r.status == VerificationStatus.skipped).length; bool get hasErrors => errorCount > 0; bool get hasWarnings => warningCount > 0; + bool get hasSkipped => skippedCount > 0; } diff --git a/lib/reporters/report_generator.dart b/lib/reporters/report_generator.dart index 8e0e482..bf548af 100644 --- a/lib/reporters/report_generator.dart +++ b/lib/reporters/report_generator.dart @@ -35,6 +35,9 @@ class ReportGenerator { if (report.errorCount > 0) { parts.add(ConsoleStyle.error('✗ ${report.errorCount} error${report.errorCount > 1 ? 's' : ''}')); } + if (report.skippedCount > 0) { + parts.add(ConsoleStyle.dim('⊘ ${report.skippedCount} skipped')); + } buffer.writeln('${report.projectType.name} | ${parts.join(' ')}'); buffer.writeln(''); @@ -45,6 +48,9 @@ class ReportGenerator { final warnings = report.results .where((r) => r.status == VerificationStatus.warning) .toList(); + final skipped = report.results + .where((r) => r.status == VerificationStatus.skipped) + .toList(); // Errors first (most important) if (errors.isNotEmpty) { @@ -76,17 +82,43 @@ class ReportGenerator { } } + // Skipped checks — surface them in the default report too. A skipped check + // means something was NOT verified (e.g. local config was never compared + // against the dashboard, or the hosted AASA / assetlinks.json were not + // fetched), so a run with skips is not a full verification. + if (skipped.isNotEmpty) { + buffer.writeln(ConsoleStyle.dim('⊘ NOT VERIFIED:')); + for (final result in skipped) { + buffer.writeln(ConsoleStyle.dim(' ${result.checkName}')); + if (result.message != null) { + buffer.writeln(ConsoleStyle.dim(' ${result.message}')); + } + if (result.fixSuggestion != null) { + buffer.writeln(ConsoleStyle.info(' → ${result.fixSuggestion}')); + } + buffer.writeln(''); + } + } + // If no errors or warnings, show success message - if (errors.isEmpty && warnings.isEmpty) { + if (errors.isEmpty && warnings.isEmpty && skipped.isEmpty) { buffer.writeln(ConsoleStyle.success('All checks passed successfully!')); buffer.writeln(''); } buffer.writeln(ConsoleStyle.dim('─' * 50)); - // Overall status + // Overall status. A clean "✓ PASSED" is reserved for a full run with no + // skips — otherwise the result is qualified so a partial (local-only) run is + // never mistaken for a verified one. if (report.hasErrors) { buffer.writeln(ConsoleStyle.errorBold('✗ FAILED - Fix ${report.errorCount} error${report.errorCount > 1 ? 's' : ''} above')); + } else if (report.hasSkipped) { + final warnSuffix = report.hasWarnings + ? ' and ${report.warningCount} warning${report.warningCount > 1 ? 's' : ''}' + : ''; + buffer.writeln(ConsoleStyle.warningBold( + '⚠ PARTIAL - local checks passed, but ${report.skippedCount} check${report.skippedCount > 1 ? 's were' : ' was'} skipped$warnSuffix (see above). This is NOT a full verification.')); } else if (report.hasWarnings) { buffer.writeln(ConsoleStyle.warningBold('⚠ PASSED with ${report.warningCount} warning${report.warningCount > 1 ? 's' : ''}')); } else { @@ -195,6 +227,9 @@ class ReportGenerator { // Overall status if (report.hasErrors) { buffer.writeln(ConsoleStyle.errorBold('❌ Verification FAILED - Please fix the errors above')); + } else if (report.hasSkipped) { + buffer.writeln(ConsoleStyle.warningBold( + '⚠️ Verification PARTIAL - ${report.skippedCount} check${report.skippedCount > 1 ? 's' : ''} skipped (see above). This is NOT a full verification.')); } else if (report.hasWarnings) { buffer.writeln(ConsoleStyle.warningBold('⚠️ Verification completed with WARNINGS')); } else { @@ -213,6 +248,7 @@ class ReportGenerator { 'success': report.successCount, 'warnings': report.warningCount, 'errors': report.errorCount, + 'skipped': report.skippedCount, }, 'results': report.results .map( diff --git a/lib/validators/android_validator.dart b/lib/validators/android_validator.dart index db27602..1502f60 100644 --- a/lib/validators/android_validator.dart +++ b/lib/validators/android_validator.dart @@ -61,8 +61,13 @@ class AndroidValidator { ); } - // Check URL schemes (custom schemes) - if (platformConfig.urlSchemes.isEmpty) { + // Check URL schemes (custom schemes). + // Use the Android-specific scheme list, not the combined `urlSchemes` + // (which for Flutter pools iOS + Android schemes together). Reporting the + // pooled list here made the "Android URL Schemes" line show iOS-only + // schemes. + final androidSchemes = platformConfig.androidUrlSchemes; + if (androidSchemes.isEmpty) { results.add( VerificationResult( checkName: 'Android URL Schemes', @@ -77,8 +82,7 @@ class AndroidValidator { VerificationResult( checkName: 'Android URL Schemes', status: VerificationStatus.success, - message: - 'URL schemes found: ${platformConfig.urlSchemes.join(", ")}', + message: 'URL schemes found: ${androidSchemes.join(", ")}', ), ); } diff --git a/lib/validators/ios_validator.dart b/lib/validators/ios_validator.dart index c4e7d1c..1390f26 100644 --- a/lib/validators/ios_validator.dart +++ b/lib/validators/ios_validator.dart @@ -39,9 +39,13 @@ class IosValidator { ), ); - // Check CFBundleURLTypes + // Check CFBundleURLTypes. + // Use the iOS-specific scheme list, not the combined `urlSchemes` (which + // for Flutter pools iOS + Android schemes together). Reporting the pooled + // list here made the "iOS URL Schemes" line show Android-only schemes. if (platformConfig != null) { - if (platformConfig.urlSchemes.isEmpty) { + final iosSchemes = platformConfig.iosUrlSchemes; + if (iosSchemes.isEmpty) { results.add( VerificationResult( checkName: 'iOS URL Schemes', @@ -56,8 +60,7 @@ class IosValidator { VerificationResult( checkName: 'iOS URL Schemes', status: VerificationStatus.success, - message: - 'URL schemes found: ${platformConfig.urlSchemes.join(", ")}', + message: 'URL schemes found: ${iosSchemes.join(", ")}', ), ); } diff --git a/test/unit/reporters/report_generator_test.dart b/test/unit/reporters/report_generator_test.dart index ddfaa81..5008179 100644 --- a/test/unit/reporters/report_generator_test.dart +++ b/test/unit/reporters/report_generator_test.dart @@ -25,6 +25,55 @@ void main() { expect(result, contains('passed')); }); + test( + 'a skipped check is disclosed as PARTIAL, never a bare green PASS', + () { + final report = VerificationReport( + projectType: ProjectType.flutter, + results: [ + VerificationResult( + checkName: 'iOS URL Schemes', + status: VerificationStatus.success, + message: 'URL schemes found: myapp', + ), + VerificationResult( + checkName: 'Dashboard cross-check', + status: VerificationStatus.skipped, + message: 'Not authenticated — local files were not compared ' + 'against the dashboard.', + fixSuggestion: 'Run "ulink login".', + ), + ], + ); + + final result = ReportGenerator.generateReport(report); + + // The skip must be visible and the run must not read as fully verified. + expect(result, contains('skipped')); + expect(result, contains('NOT VERIFIED')); + expect(result, contains('PARTIAL')); + expect(result, isNot(contains('All checks passed successfully!'))); + }); + + test('a clean run with no skips still reports a green PASS', () { + final report = VerificationReport( + projectType: ProjectType.flutter, + results: [ + VerificationResult( + checkName: 'iOS URL Schemes', + status: VerificationStatus.success, + message: 'URL schemes found: myapp', + ), + ], + ); + + final result = ReportGenerator.generateReport(report); + + expect(result, contains('All checks passed successfully!')); + expect(result, contains('PASSED')); + expect(result, isNot(contains('PARTIAL'))); + }); + test('should generate verbose report when requested', () { final report = VerificationReport( projectType: ProjectType.flutter, diff --git a/test/unit/validators/android_validator_test.dart b/test/unit/validators/android_validator_test.dart index e1b037f..549cbb9 100644 --- a/test/unit/validators/android_validator_test.dart +++ b/test/unit/validators/android_validator_test.dart @@ -112,6 +112,7 @@ void main() { projectType: ProjectType.flutter, packageName: 'com.example.app', urlSchemes: ['myapp', 'myapp-dev'], + androidUrlSchemes: ['myapp', 'myapp-dev'], ); final results = AndroidValidator.validate(tempDir.path, config); @@ -123,6 +124,34 @@ void main() { expect(schemeResult.message, contains('myapp')); }); + test( + 'reports only Android schemes, not iOS-only schemes pooled from a ' + 'Flutter project', () async { + await TestHelpers.createAndroidProjectStructure( + tempDir, + urlSchemes: ['androidonly'], + ); + + // Flutter parser pools iOS + Android schemes into `urlSchemes` but keeps + // per-platform lists. The Android check must read androidUrlSchemes only. + final config = PlatformConfig( + projectType: ProjectType.flutter, + packageName: 'com.example.app', + urlSchemes: ['iosonly', 'androidonly'], + iosUrlSchemes: ['iosonly'], + androidUrlSchemes: ['androidonly'], + ); + + final results = AndroidValidator.validate(tempDir.path, config); + final schemeResult = results.firstWhere( + (r) => r.checkName == 'Android URL Schemes', + ); + + expect(schemeResult.status, VerificationStatus.success); + expect(schemeResult.message, contains('androidonly')); + expect(schemeResult.message, isNot(contains('iosonly'))); + }); + test('should warn when no App Links found', () async { await TestHelpers.createAndroidProjectStructure( tempDir, @@ -226,6 +255,7 @@ void main() { projectType: ProjectType.flutter, packageName: 'com.example.app', urlSchemes: ['myapp'], + androidUrlSchemes: ['myapp'], appLinkHosts: ['example.com'], ); diff --git a/test/unit/validators/ios_validator_test.dart b/test/unit/validators/ios_validator_test.dart index ed4e95b..54de504 100644 --- a/test/unit/validators/ios_validator_test.dart +++ b/test/unit/validators/ios_validator_test.dart @@ -73,6 +73,7 @@ void main() { projectType: ProjectType.flutter, bundleIdentifier: 'com.example.app', urlSchemes: ['myapp', 'myapp-dev'], + iosUrlSchemes: ['myapp', 'myapp-dev'], ); final results = IosValidator.validate(tempDir.path, config); @@ -84,6 +85,34 @@ void main() { expect(schemeResult.message, contains('myapp')); }); + test( + 'reports only iOS schemes, not Android-only schemes pooled from a ' + 'Flutter project', () async { + await TestHelpers.createIosProjectStructure( + tempDir, + urlSchemes: ['iosonly'], + ); + + // Flutter parser pools iOS + Android schemes into `urlSchemes` but keeps + // per-platform lists. The iOS check must read iosUrlSchemes only. + final config = PlatformConfig( + projectType: ProjectType.flutter, + bundleIdentifier: 'com.example.app', + urlSchemes: ['iosonly', 'androidonly'], + iosUrlSchemes: ['iosonly'], + androidUrlSchemes: ['androidonly'], + ); + + final results = IosValidator.validate(tempDir.path, config); + final schemeResult = results.firstWhere( + (r) => r.checkName == 'iOS URL Schemes', + ); + + expect(schemeResult.status, VerificationStatus.success); + expect(schemeResult.message, contains('iosonly')); + expect(schemeResult.message, isNot(contains('androidonly'))); + }); + test('should return error when bundle identifier not found', () async { await TestHelpers.createIosProjectStructure(tempDir); From ae3ebd16d258f1c4b7562450e641e7de40f00a00 Mon Sep 17 00:00:00 2001 From: Mohanned Binmiskeen Date: Sun, 6 Sep 2026 21:27:02 +0200 Subject: [PATCH 2/3] fix(verify): only downgrade to PARTIAL for skips that skip verification The PR treated every skipped check as 'NOT VERIFIED', so a fully authenticated, correctly configured project read as PARTIAL whenever an optional probe could not run - no booted simulator, no adb, or a managed Expo project with no native dirs. That is the common CI case, and crying wolf on every run erodes the PARTIAL signal the disclosure fix introduced. Add VerificationResult.blocksFullVerification and mark only the checks that actually verify deep linking and were not performed - the dashboard cross-check, and an aborted project selection. The report now: - lists blocking skips under 'NOT VERIFIED' and optional ones under 'SKIPPED (optional - did not affect the result)'; - downgrades to PARTIAL only when incompleteCount > 0 (report.isPartial), noting optional skips on an otherwise green PASS; - adds summary.incomplete and a top-level partial flag to the JSON. Optional runtime/managed-Expo skips keep the default (non-blocking). --- lib/commands/verify_command.dart | 2 + lib/models/verification_result.dart | 27 +++++++++ lib/reporters/report_generator.dart | 56 ++++++++++++++----- .../unit/reporters/report_generator_test.dart | 37 ++++++++++++ 4 files changed, 107 insertions(+), 15 deletions(-) diff --git a/lib/commands/verify_command.dart b/lib/commands/verify_command.dart index a8ecddb..95cf43a 100644 --- a/lib/commands/verify_command.dart +++ b/lib/commands/verify_command.dart @@ -348,6 +348,7 @@ class VerifyCommand { VerificationResult( checkName: 'ULink API Connection', status: VerificationStatus.skipped, + blocksFullVerification: true, message: 'Invalid project selection', fixSuggestion: 'Run the command again and select a valid project', @@ -430,6 +431,7 @@ class VerifyCommand { VerificationResult( checkName: 'Dashboard cross-check (bundle id, team id, package, fingerprints, AASA & assetlinks.json)', status: VerificationStatus.skipped, + blocksFullVerification: true, message: effectiveProjectId == null ? 'Not authenticated — local files were checked, but they were NOT ' 'compared against your ULink dashboard config, and the hosted ' diff --git a/lib/models/verification_result.dart b/lib/models/verification_result.dart index d4a54ea..5af3d6c 100644 --- a/lib/models/verification_result.dart +++ b/lib/models/verification_result.dart @@ -10,12 +10,22 @@ class VerificationResult { final String? fixSuggestion; final Map? details; + /// For a [VerificationStatus.skipped] result: whether this skip means the run + /// is not a full verification. `true` only for checks that would actually + /// verify something and were not performed — chiefly the dashboard + /// cross-check (comparing local config against the ULink project and fetching + /// the hosted AASA / assetlinks.json). Optional environment probes that simply + /// could not run (no booted simulator, no `adb`, a managed-Expo project with + /// no native dirs) leave this `false`: they never downgrade the verdict. + final bool blocksFullVerification; + VerificationResult({ required this.checkName, required this.status, this.message, this.fixSuggestion, this.details, + this.blocksFullVerification = false, }); } @@ -43,7 +53,24 @@ class VerificationReport { int get skippedCount => results.where((r) => r.status == VerificationStatus.skipped).length; + /// Skips that mean the run is not a full verification (e.g. the dashboard + /// cross-check was not performed) — as opposed to optional probes that merely + /// could not run. + int get incompleteCount => results + .where((r) => + r.status == VerificationStatus.skipped && r.blocksFullVerification) + .length; + + /// Skips that do not affect the verdict (no simulator, no `adb`, managed-Expo + /// with no native dirs). + int get optionalSkippedCount => skippedCount - incompleteCount; + bool get hasErrors => errorCount > 0; bool get hasWarnings => warningCount > 0; bool get hasSkipped => skippedCount > 0; + + /// True when at least one skipped check would actually verify something and + /// was not performed — i.e. the run is only a partial verification. Optional + /// probe skips alone do not make a run partial. + bool get isPartial => incompleteCount > 0; } diff --git a/lib/reporters/report_generator.dart b/lib/reporters/report_generator.dart index bf548af..c1d61d7 100644 --- a/lib/reporters/report_generator.dart +++ b/lib/reporters/report_generator.dart @@ -82,13 +82,20 @@ class ReportGenerator { } } - // Skipped checks — surface them in the default report too. A skipped check - // means something was NOT verified (e.g. local config was never compared - // against the dashboard, or the hosted AASA / assetlinks.json were not - // fetched), so a run with skips is not a full verification. - if (skipped.isNotEmpty) { + // Blocking skips: a check that would actually verify deep linking (chiefly + // the dashboard cross-check — comparing local config against the ULink + // project and fetching the hosted AASA / assetlinks.json) was NOT performed, + // so the run is only a partial verification. + final notVerified = skipped.where((r) => r.blocksFullVerification).toList(); + // Optional probes that simply could not run in this environment (no booted + // simulator, no `adb`, managed-Expo with no native dirs). These do NOT + // downgrade the verdict. + final optionalSkipped = + skipped.where((r) => !r.blocksFullVerification).toList(); + + if (notVerified.isNotEmpty) { buffer.writeln(ConsoleStyle.dim('⊘ NOT VERIFIED:')); - for (final result in skipped) { + for (final result in notVerified) { buffer.writeln(ConsoleStyle.dim(' ${result.checkName}')); if (result.message != null) { buffer.writeln(ConsoleStyle.dim(' ${result.message}')); @@ -100,6 +107,18 @@ class ReportGenerator { } } + if (optionalSkipped.isNotEmpty) { + buffer.writeln( + ConsoleStyle.dim('⊘ SKIPPED (optional — did not affect the result):')); + for (final result in optionalSkipped) { + buffer.writeln(ConsoleStyle.dim(' ${result.checkName}')); + if (result.message != null) { + buffer.writeln(ConsoleStyle.dim(' ${result.message}')); + } + buffer.writeln(''); + } + } + // If no errors or warnings, show success message if (errors.isEmpty && warnings.isEmpty && skipped.isEmpty) { buffer.writeln(ConsoleStyle.success('All checks passed successfully!')); @@ -108,21 +127,25 @@ class ReportGenerator { buffer.writeln(ConsoleStyle.dim('─' * 50)); - // Overall status. A clean "✓ PASSED" is reserved for a full run with no - // skips — otherwise the result is qualified so a partial (local-only) run is - // never mistaken for a verified one. + // Overall status. A clean "✓ PASSED" is reserved for a full run — one where + // no check that would actually verify deep linking was skipped. A skipped + // dashboard cross-check downgrades to PARTIAL; optional probes that could + // not run (no simulator/adb) are noted but never change the verdict. + final optionalNote = report.optionalSkippedCount > 0 + ? ' (${report.optionalSkippedCount} optional check${report.optionalSkippedCount > 1 ? 's' : ''} skipped)' + : ''; if (report.hasErrors) { buffer.writeln(ConsoleStyle.errorBold('✗ FAILED - Fix ${report.errorCount} error${report.errorCount > 1 ? 's' : ''} above')); - } else if (report.hasSkipped) { + } else if (report.isPartial) { final warnSuffix = report.hasWarnings ? ' and ${report.warningCount} warning${report.warningCount > 1 ? 's' : ''}' : ''; buffer.writeln(ConsoleStyle.warningBold( - '⚠ PARTIAL - local checks passed, but ${report.skippedCount} check${report.skippedCount > 1 ? 's were' : ' was'} skipped$warnSuffix (see above). This is NOT a full verification.')); + '⚠ PARTIAL - local checks passed, but ${report.incompleteCount} check${report.incompleteCount > 1 ? 's were' : ' was'} not verified$warnSuffix (see above). This is NOT a full verification.')); } else if (report.hasWarnings) { - buffer.writeln(ConsoleStyle.warningBold('⚠ PASSED with ${report.warningCount} warning${report.warningCount > 1 ? 's' : ''}')); + buffer.writeln(ConsoleStyle.warningBold('⚠ PASSED with ${report.warningCount} warning${report.warningCount > 1 ? 's' : ''}$optionalNote')); } else { - buffer.writeln(ConsoleStyle.successBold('✓ PASSED')); + buffer.writeln(ConsoleStyle.successBold('✓ PASSED$optionalNote')); } return buffer.toString(); @@ -227,9 +250,9 @@ class ReportGenerator { // Overall status if (report.hasErrors) { buffer.writeln(ConsoleStyle.errorBold('❌ Verification FAILED - Please fix the errors above')); - } else if (report.hasSkipped) { + } else if (report.isPartial) { buffer.writeln(ConsoleStyle.warningBold( - '⚠️ Verification PARTIAL - ${report.skippedCount} check${report.skippedCount > 1 ? 's' : ''} skipped (see above). This is NOT a full verification.')); + '⚠️ Verification PARTIAL - ${report.incompleteCount} check${report.incompleteCount > 1 ? 's were' : ' was'} not verified (see above). This is NOT a full verification.')); } else if (report.hasWarnings) { buffer.writeln(ConsoleStyle.warningBold('⚠️ Verification completed with WARNINGS')); } else { @@ -249,7 +272,10 @@ class ReportGenerator { 'warnings': report.warningCount, 'errors': report.errorCount, 'skipped': report.skippedCount, + // Skips that make the run a partial verification (a subset of skipped). + 'incomplete': report.incompleteCount, }, + 'partial': report.isPartial, 'results': report.results .map( (r) => { diff --git a/test/unit/reporters/report_generator_test.dart b/test/unit/reporters/report_generator_test.dart index 5008179..7464568 100644 --- a/test/unit/reporters/report_generator_test.dart +++ b/test/unit/reporters/report_generator_test.dart @@ -39,6 +39,7 @@ void main() { VerificationResult( checkName: 'Dashboard cross-check', status: VerificationStatus.skipped, + blocksFullVerification: true, message: 'Not authenticated — local files were not compared ' 'against the dashboard.', fixSuggestion: 'Run "ulink login".', @@ -55,6 +56,42 @@ void main() { expect(result, isNot(contains('All checks passed successfully!'))); }); + test( + 'an optional probe skip (no simulator) does not downgrade to PARTIAL', + () { + final report = VerificationReport( + projectType: ProjectType.flutter, + results: [ + VerificationResult( + checkName: 'iOS URL Schemes', + status: VerificationStatus.success, + message: 'URL schemes found: myapp', + ), + VerificationResult( + checkName: 'Dashboard cross-check', + status: VerificationStatus.success, + message: 'Local config matches the dashboard.', + ), + // Optional environment probe — no booted simulator. Not a blocking + // skip, so it must not make the run read as unverified. + VerificationResult( + checkName: 'iOS Runtime Test', + status: VerificationStatus.skipped, + message: 'No booted iOS simulator available.', + ), + ], + ); + + final result = ReportGenerator.generateReport(report); + + // Verdict stays green; the optional skip is disclosed but not alarming. + expect(result, contains('PASSED')); + expect(result, isNot(contains('PARTIAL'))); + expect(result, isNot(contains('NOT VERIFIED'))); + expect(result, contains('optional')); + expect(result, contains('1 optional check skipped')); + }); + test('a clean run with no skips still reports a green PASS', () { final report = VerificationReport( projectType: ProjectType.flutter, From a486de1578558fd7b5082923359320e22ad5a6eb Mon Sep 17 00:00:00 2001 From: Mohanned Binmiskeen Date: Sun, 6 Sep 2026 21:40:33 +0200 Subject: [PATCH 3/3] fix(verify): correct auth wording and passed flag for partial runs Two accuracy fixes in the dashboard cross-check skip path: - The skip message said 'Not authenticated' whenever effectiveProjectId was null, but that also happens for a signed-in user when no project exists, the selection was cancelled/invalid, or the project fetch failed. Gate the wording on hasCredentials and add a distinct 'signed in, but no project resolved' case with the right remediation. - The dashboard upload set passed = !hasErrors, so a partial run (a check that would actually verify deep linking was skipped) uploaded passed:true while the console said NOT a full verification. Set passed = !hasErrors && !isPartial so it matches the verdict; the separate partial flag still distinguishes the two. --- lib/commands/verify_command.dart | 53 +++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/lib/commands/verify_command.dart b/lib/commands/verify_command.dart index 95cf43a..314a71c 100644 --- a/lib/commands/verify_command.dart +++ b/lib/commands/verify_command.dart @@ -427,23 +427,44 @@ class VerifyCommand { // against the ULink dashboard, and the hosted AASA / assetlinks.json files // were never fetched. Make that explicit so a green run is not mistaken for // a full verification. + // Distinguish the reasons this branch is reached. effectiveProjectId can + // be null even for an authenticated user (no projects, cancelled/invalid + // selection, or a failed project fetch), so gate the "not authenticated" + // wording on credentials, not on the project id. + final String crossCheckMessage; + final String crossCheckFix; + if (!hasCredentials) { + crossCheckMessage = effectiveProjectId == null + ? 'Not authenticated — local files were checked, but they were NOT ' + 'compared against your ULink dashboard config, and the hosted ' + 'well-known files were not fetched.' + : 'No credentials — a project is selected, but local files were NOT ' + 'compared against the ULink dashboard config, and the hosted ' + 'well-known files were not fetched.'; + crossCheckFix = + 'Run "ulink login" to authenticate (or pass --api-key) so verify can ' + 'compare local config against the dashboard and fetch the domain\'s ' + 'AASA / assetlinks.json. Without this, verify only confirms local ' + 'files exist — not that deep linking actually resolves.'; + } else { + // Signed in, but no project id resolved. + crossCheckMessage = + 'Signed in, but no ULink project was resolved — local files were NOT ' + 'compared against a dashboard project, and the hosted well-known ' + 'files were not fetched.'; + crossCheckFix = + 'Select a project with "ulink project set" (or create one at ' + 'https://ulink.ly), then re-run verify so it can compare local ' + 'config against the dashboard and fetch the domain\'s AASA / ' + 'assetlinks.json.'; + } results.add( VerificationResult( checkName: 'Dashboard cross-check (bundle id, team id, package, fingerprints, AASA & assetlinks.json)', status: VerificationStatus.skipped, blocksFullVerification: true, - message: effectiveProjectId == null - ? 'Not authenticated — local files were checked, but they were NOT ' - 'compared against your ULink dashboard config, and the hosted ' - 'well-known files were not fetched.' - : 'No credentials — a project is selected, but local files were NOT ' - 'compared against the ULink dashboard config, and the hosted ' - 'well-known files were not fetched.', - fixSuggestion: - 'Run "ulink login" to authenticate (or pass --api-key) so verify can ' - 'compare local config against the dashboard and fetch the domain\'s ' - 'AASA / assetlinks.json. Without this, verify only confirms local ' - 'files exist — not that deep linking actually resolves.', + message: crossCheckMessage, + fixSuggestion: crossCheckFix, ), ); } @@ -590,9 +611,13 @@ class VerifyCommand { apiKey: effectiveApiKey, ); - // Generate JSON report with passed status + // Generate JSON report with passed status. "passed" means a full + // verification: no errors AND not partial (a check that would actually + // verify deep linking wasn't skipped). The report also carries a + // separate `partial` flag, but keep `passed` consistent with the + // console verdict rather than reporting a partial run as passed. final jsonReport = ReportGenerator.generateJsonReport(report); - jsonReport['passed'] = !report.hasErrors; + jsonReport['passed'] = !report.hasErrors && !report.isPartial; await apiClient.postVerificationResults(effectiveProjectId, jsonReport); uploadSpinner.success('Results synced to dashboard');