Skip to content
Merged
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
55 changes: 48 additions & 7 deletions lib/commands/verify_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
),
);
}
Expand Down Expand Up @@ -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');
Expand Down
30 changes: 30 additions & 0 deletions lib/models/verification_result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@ class VerificationResult {
final String? fixSuggestion;
final Map<String, dynamic>? 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,
});
}

Expand All @@ -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;
}
70 changes: 66 additions & 4 deletions lib/reporters/report_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('');

Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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) => {
Expand Down
12 changes: 8 additions & 4 deletions lib/validators/android_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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(", ")}',
),
);
}
Expand Down
11 changes: 7 additions & 4 deletions lib/validators/ios_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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(", ")}',
),
);
}
Expand Down
86 changes: 86 additions & 0 deletions test/unit/reporters/report_generator_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading