diff --git a/lib/commands/verify_command.dart b/lib/commands/verify_command.dart index 843ea45..314a71c 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', @@ -420,14 +421,50 @@ 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. + // 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: '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', - fixSuggestion: - 'Run "ulink login" to authenticate, or provide --project-id and --api-key', + blocksFullVerification: true, + message: crossCheckMessage, + fixSuggestion: crossCheckFix, ), ); } @@ -574,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'); diff --git a/lib/models/verification_result.dart b/lib/models/verification_result.dart index 8c23b14..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, }); } @@ -40,7 +50,27 @@ 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; + + /// 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 8e0e482..c1d61d7 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,21 +82,70 @@ class ReportGenerator { } } + // 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 notVerified) { + 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 (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) { + 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 — 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.isPartial) { + final warnSuffix = report.hasWarnings + ? ' and ${report.warningCount} warning${report.warningCount > 1 ? 's' : ''}' + : ''; + buffer.writeln(ConsoleStyle.warningBold( + '⚠ 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(); @@ -195,6 +250,9 @@ class ReportGenerator { // Overall status if (report.hasErrors) { buffer.writeln(ConsoleStyle.errorBold('❌ Verification FAILED - Please fix the errors above')); + } else if (report.isPartial) { + buffer.writeln(ConsoleStyle.warningBold( + '⚠️ 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 { @@ -213,7 +271,11 @@ class ReportGenerator { 'success': report.successCount, '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/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..7464568 100644 --- a/test/unit/reporters/report_generator_test.dart +++ b/test/unit/reporters/report_generator_test.dart @@ -25,6 +25,92 @@ 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, + blocksFullVerification: true, + 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( + '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, + 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);