args_test.dart 14.1 KB
Newer Older
1 2 3 4 5 6 7
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:flutter_tools/executable.dart' as executable;
8
import 'package:flutter_tools/src/cache.dart';
9
import 'package:flutter_tools/src/commands/analyze.dart';
10
import 'package:flutter_tools/src/runner/flutter_command.dart';
11 12 13
import 'package:flutter_tools/src/runner/flutter_command_runner.dart';

import '../src/common.dart';
14
import '../src/context.dart';
15
import '../src/testbed.dart';
16
import 'runner/utils.dart';
17 18

void main() {
19 20 21 22 23 24 25 26
  setUpAll(() {
    Cache.disableLocking();
  });

  tearDownAll(() {
    Cache.enableLocking();
  });

27 28 29 30 31 32 33
  test('Help for command line arguments is consistently styled and complete', () => Testbed().run(() {
    final FlutterCommandRunner runner = FlutterCommandRunner(verboseHelp: true);
    executable.generateCommands(
      verboseHelp: true,
      verbose: true,
    ).forEach(runner.addCommand);
    verifyCommandRunner(runner);
34
    for (final Command<void> command in runner.commands.values) {
35
      if (command.name == 'analyze') {
36
        final AnalyzeCommand analyze = command as AnalyzeCommand;
37
        expect(analyze.allProjectValidators().length, 2);
38 39
      }
    }
40
  }));
41

42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
  testUsingContext('Global arg results are available in FlutterCommands', () async {
    final DummyFlutterCommand command = DummyFlutterCommand(
      commandFunction: () async {
        return const FlutterCommandResult(ExitStatus.success);
      },
    );

    final FlutterCommandRunner runner = FlutterCommandRunner(verboseHelp: true);

    runner.addCommand(command);
    await runner.run(<String>['dummy', '--${FlutterGlobalOptions.kContinuousIntegrationFlag}']);

    expect(command.globalResults, isNotNull);
    expect(command.boolArg(FlutterGlobalOptions.kContinuousIntegrationFlag, global: true), true);
  });

  testUsingContext('Global arg results are available in FlutterCommands sub commands', () async {
    final DummyFlutterCommand command = DummyFlutterCommand(
      commandFunction: () async {
        return const FlutterCommandResult(ExitStatus.success);
      },
    );

    final DummyFlutterCommand subcommand = DummyFlutterCommand(
      name: 'sub',
      commandFunction: () async {
        return const FlutterCommandResult(ExitStatus.success);
      },
    );

    command.addSubcommand(subcommand);

    final FlutterCommandRunner runner = FlutterCommandRunner(verboseHelp: true);

    runner.addCommand(command);
    runner.addCommand(subcommand);
    await runner.run(<String>['dummy', 'sub', '--${FlutterGlobalOptions.kContinuousIntegrationFlag}']);

    expect(subcommand.globalResults, isNotNull);
    expect(subcommand.boolArg(FlutterGlobalOptions.kContinuousIntegrationFlag, global: true), true);
  });

84 85
  testUsingContext('bool? safe argResults', () async {
    final DummyFlutterCommand command = DummyFlutterCommand(
86 87 88
      commandFunction: () async {
        return const FlutterCommandResult(ExitStatus.success);
      },
89 90 91 92
    );
    final FlutterCommandRunner runner = FlutterCommandRunner(verboseHelp: true);
    command.argParser.addFlag('key');
    command.argParser.addFlag('key-false');
93 94 95
    // argResults will be null at this point, if attempt to read them is made,
    // exception `Null check operator used on a null value` would be thrown.
    expect(() => command.boolArg('key'), throwsA(const TypeMatcher<TypeError>()));
96 97 98 99 100

    runner.addCommand(command);
    await runner.run(<String>['dummy', '--key']);

    expect(command.boolArg('key'), true);
101
    expect(() => command.boolArg('non-existent'), throwsArgumentError);
102

103 104
    expect(command.boolArg('key'), true);
    expect(() => command.boolArg('non-existent'), throwsA(const TypeMatcher<ArgumentError>()));
105 106

    expect(command.boolArg('key-false'), false);
107
    expect(command.boolArg('key-false'), false);
108 109
  });

110
  testUsingContext('String? safe argResults', () async {
111
    final DummyFlutterCommand command = DummyFlutterCommand(
112 113 114
      commandFunction: () async {
        return const FlutterCommandResult(ExitStatus.success);
      },
115
    );
116 117
    final FlutterCommandRunner runner = FlutterCommandRunner(verboseHelp: true);
    command.argParser.addOption('key');
118 119 120 121
    // argResults will be null at this point, if attempt to read them is made,
    // exception `Null check operator used on a null value` would be thrown
    expect(() => command.stringArg('key'), throwsA(const TypeMatcher<TypeError>()));

122
    runner.addCommand(command);
123
    await runner.run(<String>['dummy', '--key=value']);
124 125

    expect(command.stringArg('key'), 'value');
126
    expect(() => command.stringArg('non-existent'), throwsArgumentError);
127

128 129
    expect(command.stringArg('key'), 'value');
    expect(() => command.stringArg('non-existent'), throwsA(const TypeMatcher<ArgumentError>()));
130
  });
131 132 133

  testUsingContext('List<String> safe argResults', () async {
    final DummyFlutterCommand command = DummyFlutterCommand(
134 135 136
      commandFunction: () async {
        return const FlutterCommandResult(ExitStatus.success);
      },
137 138 139 140 141 142 143 144 145 146 147 148 149 150
    );
    final FlutterCommandRunner runner = FlutterCommandRunner(verboseHelp: true);
    command.argParser.addMultiOption(
      'key',
      allowed: <String>['a', 'b', 'c'],
    );
    // argResults will be null at this point, if attempt to read them is made,
    // exception `Null check operator used on a null value` would be thrown.
    expect(() => command.stringsArg('key'), throwsA(const TypeMatcher<TypeError>()));

    runner.addCommand(command);
    await runner.run(<String>['dummy', '--key', 'a']);

    // throws error when trying to parse non-existent key.
151
    expect(() => command.stringsArg('non-existent'), throwsA(const TypeMatcher<ArgumentError>()));
152 153 154 155 156 157 158 159 160

    expect(command.stringsArg('key'), <String>['a']);

    await runner.run(<String>['dummy', '--key', 'a', '--key', 'b']);
    expect(command.stringsArg('key'), <String>['a', 'b']);

    await runner.run(<String>['dummy']);
    expect(command.stringsArg('key'), <String>[]);
  });
161 162
}

163
void verifyCommandRunner(CommandRunner<Object?> runner) {
164 165 166
  expect(runner.argParser, isNotNull, reason: '${runner.runtimeType} has no argParser');
  expect(runner.argParser.allowsAnything, isFalse, reason: '${runner.runtimeType} allows anything');
  expect(runner.argParser.allowTrailingOptions, isFalse, reason: '${runner.runtimeType} allows trailing options');
167
  verifyOptions(null, runner.argParser.options.values);
168 169 170
  runner.commands.values.forEach(verifyCommand);
}

171
void verifyCommand(Command<Object?> runner) {
172
  expect(runner.argParser, isNotNull, reason: 'command ${runner.name} has no argParser');
173
  verifyOptions(runner.name, runner.argParser.options.values);
174 175 176 177

  final String firstDescriptionLine = runner.description.split('\n').first;
  expect(firstDescriptionLine, matches(_allowedTrailingPatterns), reason: "command ${runner.name}'s description does not end with the expected single period that a full sentence should end with");

178
  if (!runner.hidden && runner.parent == null) {
179 180 181 182 183 184 185 186 187 188 189
    expect(
      runner.category,
      anyOf(
        FlutterCommandCategory.sdk,
        FlutterCommandCategory.project,
        FlutterCommandCategory.tools,
      ),
      reason: "top-level command ${runner.name} doesn't have a valid category",
    );
  }

190 191 192 193
  runner.subcommands.values.forEach(verifyCommand);
}

// Patterns for arguments names.
194 195 196
final RegExp _allowedArgumentNamePattern = RegExp(r'^([-a-z0-9]+)$');
final RegExp _allowedArgumentNamePatternForPrecache = RegExp(r'^([-a-z0-9_]+)$');
final RegExp _bannedArgumentNamePattern = RegExp(r'-uri$');
197 198 199

// Patterns for help messages.
final RegExp _bannedLeadingPatterns = RegExp(r'^[-a-z]', multiLine: true);
200
final RegExp _allowedTrailingPatterns = RegExp(r'([^ ]([^.^!^:][.!:])\)?|: https?://[^ ]+[^.]|^)$');
201 202 203
final RegExp _bannedQuotePatterns = RegExp(r" '|' |'\.|\('|'\)|`");
final RegExp _bannedArgumentReferencePatterns = RegExp(r'[^"=]--[^ ]');
final RegExp _questionablePatterns = RegExp(r'[a-z]\.[A-Z]');
204
final RegExp _bannedUri = RegExp(r'\b[Uu][Rr][Ii]\b');
205
final RegExp _nonSecureFlutterDartUrl = RegExp(r'http://([a-z0-9-]+\.)*(flutter|dart)\.dev', caseSensitive: false);
206
const String _needHelp = "Every option must have help explaining what it does, even if it's "
207 208 209 210 211 212 213
                         'for testing purposes, because this is the bare minimum of '
                         'documentation we can add just for ourselves. If it is not intended '
                         'for developers, then use "hide: !verboseHelp" to only show the '
                         'help when people run with "--help --verbose".';

const String _header = ' Comment: ';

214
void verifyOptions(String? command, Iterable<Option> options) {
215 216 217 218 219 220 221
  String target;
  if (command == null) {
    target = 'the global argument "';
  } else {
    target = '"flutter $command ';
  }
  assert(target.contains('"'));
222 223
  for (final Option option in options) {
    // If you think you need to add an exception here, please ask Hixie (but he'll say no).
224 225 226 227 228 229
    if (command == 'precache') {
      expect(option.name, matches(_allowedArgumentNamePatternForPrecache), reason: '$_header$target--${option.name}" is not a valid name for a command line argument. (Is it all lowercase?)');
    } else {
      expect(option.name, matches(_allowedArgumentNamePattern), reason: '$_header$target--${option.name}" is not a valid name for a command line argument. (Is it all lowercase? Does it use hyphens rather than underscores?)');
    }
    expect(option.name, isNot(matches(_bannedArgumentNamePattern)), reason: '$_header$target--${option.name}" is not a valid name for a command line argument. (We use "--foo-url", not "--foo-uri", for example.)');
230 231 232 233
    // The flag --sound-null-safety is deprecated
    if (option.name != FlutterOptions.kNullSafety && option.name != FlutterOptions.kNullAssertions) {
      expect(option.hide, isFalse, reason: '${_header}Help for $target--${option.name}" is always hidden. $_needHelp');
    }
234 235 236 237 238 239 240 241 242 243
    expect(option.help, isNotNull, reason: '${_header}Help for $target--${option.name}" has null help. $_needHelp');
    expect(option.help, isNotEmpty, reason: '${_header}Help for $target--${option.name}" has empty help. $_needHelp');
    expect(option.help, isNot(matches(_bannedLeadingPatterns)), reason: '${_header}A line in the help for $target--${option.name}" starts with a lowercase letter. For stylistic consistency, all help messages must start with a capital letter.');
    expect(option.help, isNot(startsWith('(Deprecated')), reason: '${_header}Help for $target--${option.name}" should start with lowercase "(deprecated)" for consistency with other deprecated commands.');
    expect(option.help, isNot(startsWith('(Required')), reason: '${_header}Help for $target--${option.name}" should start with lowercase "(required)" for consistency with other deprecated commands.');
    expect(option.help, isNot(contains('?')), reason: '${_header}Help for $target--${option.name}" has a question mark. Generally we prefer the passive voice for help messages.');
    expect(option.help, isNot(contains('Note:')), reason: '${_header}Help for $target--${option.name}" uses "Note:". See our style guide entry about "empty prose".');
    expect(option.help, isNot(contains('Note that')), reason: '${_header}Help for $target--${option.name}" uses "Note that". See our style guide entry about "empty prose".');
    expect(option.help, isNot(matches(_bannedQuotePatterns)), reason: '${_header}Help for $target--${option.name}" uses single quotes or backticks instead of double quotes in the help message. For consistency we use double quotes throughout.');
    expect(option.help, isNot(matches(_questionablePatterns)), reason: '${_header}Help for $target--${option.name}" may have a typo. (If it does not you may have to update args_test.dart, sorry. Search for "_questionablePatterns")');
244
    if (option.defaultsTo != null) {
245
      expect(option.help, isNot(contains('Default')), reason: '${_header}Help for $target--${option.name}" mentions the default value but that is redundant with the defaultsTo option which is also specified (and preferred).');
246

247 248 249
      final Map<String, String>? allowedHelp = option.allowedHelp;
      if (allowedHelp != null) {
        for (final String allowedValue in allowedHelp.keys) {
250
          expect(
251
            allowedHelp[allowedValue],
252 253 254 255 256
            isNot(anyOf(contains('default'), contains('Default'))),
            reason: '${_header}Help for $target--${option.name} $allowedValue" mentions the default value but that is redundant with the defaultsTo option which is also specified (and preferred).',
          );
        }
      }
257
    }
258
    expect(option.help, isNot(matches(_bannedArgumentReferencePatterns)), reason: '${_header}Help for $target--${option.name}" contains the string "--" in an unexpected way. If it\'s trying to mention another argument, it should be quoted, as in "--foo".');
259
    for (final String line in option.help!.split('\n')) {
260
      if (!line.startsWith('    ')) {
261 262
        expect(line, isNot(contains('  ')), reason: '${_header}Help for $target--${option.name}" has excessive whitespace (check e.g. for double spaces after periods or round line breaks in the source).');
        expect(line, matches(_allowedTrailingPatterns), reason: '${_header}A line in the help for $target--${option.name}" does not end with the expected period that a full sentence should end with. (If the help ends with a URL, place it after a colon, don\'t leave a trailing period; if it\'s sample code, prefix the line with four spaces.)');
263 264
      }
    }
265 266
    expect(option.help, isNot(endsWith(':')), reason: '${_header}Help for $target--${option.name}" ends with a colon, which seems unlikely to be correct.');
    expect(option.help, isNot(contains(_bannedUri)), reason: '${_header}Help for $target--${option.name}" uses the term "URI" rather than "URL".');
267
    expect(option.help, isNot(contains(_nonSecureFlutterDartUrl)), reason: '${_header}Help for $target--${option.name}" links to a non-secure ("http") version of a Flutter or Dart site.');
268 269 270 271
    // TODO(ianh): add some checking for embedded URLs to make sure we're consistent on how we format those.
    // TODO(ianh): arguably we should ban help text that starts with "Whether to..." since by definition a flag is to enable a feature, so the "whether to" is redundant.
  }
}