// 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 'dart:async';
import 'dart:convert';

import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';

import '../src/common.dart';
import 'test_utils.dart';

// This test depends on some files in ///dev/automated_tests/flutter_test/*

final String automatedTestsDirectory = fileSystem.path.join('..', '..', 'dev', 'automated_tests');
final String missingDependencyDirectory = fileSystem.path.join(
  '..',
  '..',
  'dev',
  'missing_dependency_tests',
);
final String flutterTestDirectory = fileSystem.path.join(automatedTestsDirectory, 'flutter_test');
final String integrationTestDirectory = fileSystem.path.join(
  automatedTestsDirectory,
  'integration_test',
);

// Running Integration Tests in the Flutter Tester will still exercise the same
// flows specific to Integration Tests.
final integrationTestExtraArgs = <String>['-d', 'flutter-tester'];

void main() {
  setUpAll(() async {
    expect(
      await processManager.run(<String>[
        flutterBin,
        'pub',
        'get',
      ], workingDirectory: flutterTestDirectory),
      const ProcessResultMatcher(),
    );
    expect(
      await processManager.run(<String>[
        flutterBin,
        'pub',
        'get',
      ], workingDirectory: missingDependencyDirectory),
      const ProcessResultMatcher(),
    );
  });

  testWithoutContext('flutter test should not have extraneous error messages', () async {
    return _testFile(
      'trivial_widget',
      automatedTestsDirectory,
      flutterTestDirectory,
      exitCode: isZero,
    );
  });

  testWithoutContext('integration test should not have extraneous error messages', () async {
    return _testFile(
      'trivial_widget',
      automatedTestsDirectory,
      integrationTestDirectory,
      exitCode: isZero,
      extraArguments: integrationTestExtraArgs,
    );
  });

  testWithoutContext('flutter test set the working directory correctly', () async {
    return _testFile(
      'working_directory',
      automatedTestsDirectory,
      flutterTestDirectory,
      exitCode: isZero,
    );
  });

  testWithoutContext(
    'flutter test should report nice errors for exceptions thrown within testWidgets()',
    () async {
      return _testFile('exception_handling', automatedTestsDirectory, flutterTestDirectory);
    },
  );

  testWithoutContext(
    'integration test should report nice errors for exceptions thrown within testWidgets()',
    () async {
      return _testFile(
        'exception_handling',
        automatedTestsDirectory,
        integrationTestDirectory,
        extraArguments: integrationTestExtraArgs,
      );
    },
  );

  testWithoutContext(
    'flutter test should report a nice error when a guarded function was called without await',
    () async {
      return _testFile('test_async_utils_guarded', automatedTestsDirectory, flutterTestDirectory);
    },
  );

  testWithoutContext(
    'flutter test should report a nice error when an async function was called without await',
    () async {
      return _testFile('test_async_utils_unguarded', automatedTestsDirectory, flutterTestDirectory);
    },
  );

  testWithoutContext(
    'flutter test should report a nice error when a Ticker is left running',
    () async {
      return _testFile('ticker', automatedTestsDirectory, flutterTestDirectory);
    },
  );

  testWithoutContext(
    'flutter test should report a nice error when a pubspec.yaml is missing a flutter_test dependency',
    () async {
      final String missingDependencyTests = fileSystem.path.join(
        '..',
        '..',
        'dev',
        'missing_dependency_tests',
      );
      return _testFile('trivial', missingDependencyTests, missingDependencyTests);
    },
  );

  testWithoutContext(
    'flutter test should report which user-created widget caused the error',
    () async {
      return _testFile(
        'print_user_created_ancestor',
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--track-widget-creation'],
      );
    },
  );

  testWithoutContext(
    'flutter test should report which user-created widget caused the error - no flag',
    () async {
      return _testFile(
        'print_user_created_ancestor_no_flag',
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--no-track-widget-creation'],
      );
    },
  );

  testWithoutContext(
    'flutter test should report the correct user-created widget that caused the error',
    () async {
      return _testFile(
        'print_correct_local_widget',
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--track-widget-creation'],
      );
    },
  );

  testWithoutContext('flutter test should can load assets within its own package', () async {
    return _testFile(
      'package_assets',
      automatedTestsDirectory,
      flutterTestDirectory,
      exitCode: isZero,
    );
  });

  testWithoutContext('flutter test should support dart defines', () async {
    return _testFile(
      'dart_defines',
      automatedTestsDirectory,
      flutterTestDirectory,
      exitCode: isZero,
      extraArguments: <String>['--dart-define=flutter.test.foo=bar'],
    );
  });

  testWithoutContext('flutter test should run a test when its name matches a regexp', () async {
    final ProcessResult result = await _runFlutterTest(
      'filtering',
      automatedTestsDirectory,
      flutterTestDirectory,
      extraArguments: const <String>['--name', 'inc.*de'],
    );
    expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
  });

  testWithoutContext(
    'flutter test should run a test when its name matches a regexp when --experimental-faster-testing is set',
    () async {
      final ProcessResult result = await _runFlutterTest(
        null,
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--experimental-faster-testing', '--name=inc.*de'],
      );
      expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
    },
  );

  testWithoutContext('flutter test should run a test when its name contains a string', () async {
    final ProcessResult result = await _runFlutterTest(
      'filtering',
      automatedTestsDirectory,
      flutterTestDirectory,
      extraArguments: const <String>['--plain-name', 'include'],
    );
    expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
  });

  testWithoutContext(
    'flutter test should run a test when its name contains a string when --experimental-faster-testing is set',
    () async {
      final ProcessResult result = await _runFlutterTest(
        null,
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--experimental-faster-testing', '--plain-name=include'],
      );
      expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
    },
  );

  testWithoutContext('flutter test should run a test with a given tag', () async {
    final ProcessResult result = await _runFlutterTest(
      'filtering_tag',
      automatedTestsDirectory,
      flutterTestDirectory,
      extraArguments: const <String>['--tags', 'include-tag'],
    );
    expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
  });

  testWithoutContext(
    'flutter test should run a test with a given tag when --experimental-faster-testing is set',
    () async {
      final ProcessResult result = await _runFlutterTest(
        null,
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--experimental-faster-testing', '--tags=include-tag'],
      );
      expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
    },
  );

  testWithoutContext('flutter test should not run a test with excluded tag', () async {
    final ProcessResult result = await _runFlutterTest(
      'filtering_tag',
      automatedTestsDirectory,
      flutterTestDirectory,
      extraArguments: const <String>['--exclude-tags', 'exclude-tag'],
    );
    expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
  });

  testWithoutContext('flutter test should run all tests when tags are unspecified', () async {
    final ProcessResult result = await _runFlutterTest(
      'filtering_tag',
      automatedTestsDirectory,
      flutterTestDirectory,
    );
    expect(
      result,
      ProcessResultMatcher(exitCode: 1, stdoutPattern: RegExp(r'\+\d+ -1: Some tests failed\.')),
    );
  });

  testWithoutContext(
    'flutter test should run all tests when tags are unspecified when --experimental-faster-testing is set',
    () async {
      final ProcessResult result = await _runFlutterTest(
        null,
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--experimental-faster-testing'],
      );
      expect(
        result,
        ProcessResultMatcher(
          exitCode: 1,
          stdoutPattern: RegExp(r'\+\d+ -\d+: Some tests failed\.'),
        ),
      );
    },
  );

  testWithoutContext('flutter test should run a widgetTest with a given tag', () async {
    final ProcessResult result = await _runFlutterTest(
      'filtering_tag_widget',
      automatedTestsDirectory,
      flutterTestDirectory,
      extraArguments: const <String>['--tags', 'include-tag'],
    );
    expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
  });

  testWithoutContext('flutter test should not run a widgetTest with excluded tag', () async {
    final ProcessResult result = await _runFlutterTest(
      'filtering_tag_widget',
      automatedTestsDirectory,
      flutterTestDirectory,
      extraArguments: const <String>['--exclude-tags', 'exclude-tag'],
    );
    expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
  });

  testWithoutContext('flutter test should run all widgetTest when tags are unspecified', () async {
    final ProcessResult result = await _runFlutterTest(
      'filtering_tag_widget',
      automatedTestsDirectory,
      flutterTestDirectory,
    );
    expect(
      result,
      ProcessResultMatcher(exitCode: 1, stdoutPattern: RegExp(r'\+\d+ -1: Some tests failed\.')),
    );
  });

  testWithoutContext('flutter test should run a test with an exact name in URI format', () async {
    final ProcessResult result = await _runFlutterTest(
      'uri_format',
      automatedTestsDirectory,
      flutterTestDirectory,
      query: 'full-name=exactTestName',
    );
    expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
  });

  testWithoutContext('flutter test should run a test by line number in URI format', () async {
    final ProcessResult result = await _runFlutterTest(
      'uri_format',
      automatedTestsDirectory,
      flutterTestDirectory,
      query: 'line=11',
    );
    expect(result, ProcessResultMatcher(stdoutPattern: RegExp(r'\+\d+: All tests passed!')));
  });

  testWithoutContext('flutter test should test runs to completion', () async {
    final ProcessResult result = await _runFlutterTest(
      'trivial',
      automatedTestsDirectory,
      flutterTestDirectory,
      extraArguments: const <String>['--verbose'],
    );
    final String stdout = (result.stdout as String).replaceAll('\r', '\n');
    expect(stdout, contains(RegExp(r'\+\d+: All tests passed\!')));
    expect(stdout, contains('test 0: Starting flutter_tester process with command'));
    expect(stdout, contains('test 0: deleting temporary directory'));
    expect(stdout, contains('test 0: finished'));
    expect(stdout, contains('test package returned with exit code 0'));
    if ((result.stderr as String).isNotEmpty) {
      fail('unexpected error output from test:\n\n${result.stderr}\n-- end stderr --\n\n');
    }
    expect(result, const ProcessResultMatcher());
  });

  testWithoutContext(
    'flutter test should test runs to completion when --experimental-faster-testing is set',
    () async {
      final ProcessResult result = await _runFlutterTest(
        null,
        automatedTestsDirectory,
        '$flutterTestDirectory/child_directory',
        extraArguments: const <String>['--experimental-faster-testing', '--verbose'],
      );
      final String stdout = (result.stdout as String).replaceAll('\r', '\n');
      expect(stdout, contains(RegExp(r'\+\d+: All tests passed\!')));
      expect(stdout, contains('Starting flutter_tester process with command'));
      if ((result.stderr as String).isNotEmpty) {
        fail('unexpected error output from test:\n\n${result.stderr}\n-- end stderr --\n\n');
      }
      expect(result, const ProcessResultMatcher());
    },
  );

  testWithoutContext(
    'flutter test should ignore --experimental-faster-testing when only a single test file is specified',
    () async {
      final ProcessResult result = await _runFlutterTest(
        'trivial',
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--experimental-faster-testing', '--verbose'],
      );
      final String stdout = (result.stdout as String).replaceAll('\r', '\n');
      expect(
        stdout,
        contains(
          '--experimental-faster-testing was parsed but will be ignored. '
          'This option should not be used when running a single test file.',
        ),
      );
      expect(stdout, contains(RegExp(r'\+\d+: All tests passed\!')));
      expect(stdout, contains('test 0: Starting flutter_tester process with command'));
      expect(stdout, contains('test 0: deleting temporary directory'));
      expect(stdout, contains('test 0: finished'));
      expect(stdout, contains('test package returned with exit code 0'));
      if ((result.stderr as String).isNotEmpty) {
        fail('unexpected error output from test:\n\n${result.stderr}\n-- end stderr --\n\n');
      }
      expect(result, const ProcessResultMatcher());
    },
  );

  testWithoutContext(
    'flutter test should ignore --experimental-faster-testing when running integration tests',
    () async {
      final ProcessResult result = await _runFlutterTest(
        'trivial_widget',
        automatedTestsDirectory,
        integrationTestDirectory,
        extraArguments: <String>[...integrationTestExtraArgs, '--experimental-faster-testing'],
      );
      final String stdout = (result.stdout as String).replaceAll('\r', '\n');
      expect(
        stdout,
        contains(
          '--experimental-faster-testing was parsed but will be ignored. '
          'This option is not supported when running integration tests or web '
          'tests.',
        ),
      );
      if ((result.stderr as String).isNotEmpty) {
        fail('unexpected error output from test:\n\n${result.stderr}\n-- end stderr --\n\n');
      }
      expect(result, const ProcessResultMatcher());
    },
  );

  testWithoutContext(
    'flutter test should run all tests inside of a directory with no trailing slash',
    () async {
      final ProcessResult result = await _runFlutterTest(
        null,
        automatedTestsDirectory,
        '$flutterTestDirectory/child_directory',
        extraArguments: const <String>['--verbose'],
      );
      final String stdout = (result.stdout as String).replaceAll('\r', '\n');
      expect(result.stdout, contains(RegExp(r'\+\d+: All tests passed\!')));
      expect(stdout, contains('test 0: Starting flutter_tester process with command'));
      expect(stdout, contains('test 0: deleting temporary directory'));
      expect(stdout, contains('test 0: finished'));
      expect(stdout, contains('test package returned with exit code 0'));
      if ((result.stderr as String).isNotEmpty) {
        fail('unexpected error output from test:\n\n${result.stderr}\n-- end stderr --\n\n');
      }
      expect(result, const ProcessResultMatcher());
    },
  );

  testWithoutContext('flutter gold skips tests where the expectations are missing', () async {
    return _testFile(
      'flutter_gold',
      automatedTestsDirectory,
      flutterTestDirectory,
      exitCode: isZero,
    );
  });

  testWithoutContext('flutter test should serve DevTools', () async {
    Process? process;
    StreamSubscription<String>? sub;
    try {
      process = await _runFlutterTestConcurrent(
        'trivial',
        automatedTestsDirectory,
        flutterTestDirectory,
        extraArguments: const <String>['--start-paused'],
      );
      final completer = Completer<Uri>();
      final devToolsUriRegExp = RegExp(
        r'The Flutter DevTools debugger and profiler is available at: (http://[^\s]+)',
      );
      sub = process.stdout.transform(utf8.decoder).listen((String e) {
        if (!completer.isCompleted && devToolsUriRegExp.hasMatch(e)) {
          completer.complete(Uri.parse(devToolsUriRegExp.firstMatch(e)!.group(1)!));
        }
      });
      final Uri devToolsUri = await completer.future;
      final client = HttpClient();
      final HttpClientRequest request = await client.getUrl(devToolsUri);
      final HttpClientResponse response = await request.close();
      final String content = await response.transform(utf8.decoder).join();
      expect(content, contains('DevTools'));
    } finally {
      await sub?.cancel();
      process?.kill();
    }
  });
}

Future<void> _testFile(
  String testName,
  String workingDirectory,
  String testDirectory, {
  Matcher? exitCode,
  List<String> extraArguments = const <String>[],
}) async {
  exitCode ??= isNonZero;
  final String fullTestExpectation = fileSystem.path.join(
    testDirectory,
    '${testName}_expectation.txt',
  );
  final File expectationFile = fileSystem.file(fullTestExpectation);
  if (!expectationFile.existsSync()) {
    fail('missing expectation file: $expectationFile');
  }

  final ProcessResult exec = await _runFlutterTest(
    testName,
    workingDirectory,
    testDirectory,
    extraArguments: extraArguments,
  );

  expect(
    exec.exitCode,
    exitCode,
    reason:
        '"$testName" returned code ${exec.exitCode}\n\nstdout:\n'
        '${exec.stdout}\nstderr:\n${exec.stderr}',
  );
  List<String> output = (exec.stdout as String).split('\n');

  output = _removeMacFontServerWarning(output);

  if (output.first.startsWith(
    'Waiting for another flutter command to release the startup lock...',
  )) {
    output.removeAt(0);
  }
  if (output.first.startsWith('Running "flutter pub get" in')) {
    output.removeAt(0);
  }
  // Whether cached artifacts need to be downloaded is dependent on what
  // previous tests have run. Disregard these messages.
  output.removeWhere(RegExp(r'Downloading .*\.\.\.').hasMatch);
  output.add('<<stderr>>');
  output.addAll((exec.stderr as String).split('\n'));
  final List<String> expectations = fileSystem.file(fullTestExpectation).readAsLinesSync();
  var allowSkip = false;
  var expectationLineNumber = 0;
  var outputLineNumber = 0;
  var haveSeenStdErrMarker = false;
  while (expectationLineNumber < expectations.length) {
    expect(
      output,
      hasLength(greaterThan(outputLineNumber)),
      reason: 'Failure in $testName to compare to $fullTestExpectation',
    );
    final String expectationLine = expectations[expectationLineNumber];
    String outputLine = output[outputLineNumber];
    if (expectationLine == '<<skip until matching line>>') {
      allowSkip = true;
      expectationLineNumber += 1;
      continue;
    }
    if (allowSkip) {
      if (!RegExp(expectationLine).hasMatch(outputLine)) {
        outputLineNumber += 1;
        continue;
      }
      allowSkip = false;
    }
    if (expectationLine == '<<stderr>>') {
      expect(haveSeenStdErrMarker, isFalse);
      haveSeenStdErrMarker = true;
    }
    if (!RegExp(expectationLine).hasMatch(outputLine) && outputLineNumber + 1 < output.length) {
      // Check if the RegExp can match the next two lines in the output so
      // that it is possible to write expectations that still hold even if a
      // line is wrapped slightly differently due to for example a file name
      // being longer on one platform than another.
      final mergedLines = '$outputLine\n${output[outputLineNumber + 1]}';
      if (RegExp(expectationLine).hasMatch(mergedLines)) {
        outputLineNumber += 1;
        outputLine = mergedLines;
      }
    }

    expect(
      outputLine,
      matches(expectationLine),
      reason:
          'Full output:\n- - - -----8<----- - - -\n${output.join("\n")}\n- - - -----8<----- - - -',
    );
    expectationLineNumber += 1;
    outputLineNumber += 1;
  }
  expect(allowSkip, isFalse);
  if (!haveSeenStdErrMarker) {
    expect(exec.stderr, '');
  }
}

final _fontServerProtocolPattern = RegExp(r'flutter_tester.*Font server protocol version mismatch');
final _unableToConnectToFontDaemonPattern = RegExp(
  r'flutter_tester.*XType: unable to make a connection to the font daemon!',
);
final _xtFontStaticRegistryPattern = RegExp(
  r'flutter_tester.*XType: XTFontStaticRegistry is enabled as fontd is not available',
);

// https://github.com/flutter/flutter/issues/132990
List<String> _removeMacFontServerWarning(List<String> output) {
  return output.where((String line) {
    if (_fontServerProtocolPattern.hasMatch(line)) {
      return false;
    }
    if (_unableToConnectToFontDaemonPattern.hasMatch(line)) {
      return false;
    }
    if (_xtFontStaticRegistryPattern.hasMatch(line)) {
      return false;
    }
    return true;
  }).toList();
}

Future<ProcessResult> _runFlutterTest(
  String? testName,
  String workingDirectory,
  String testDirectory, {
  List<String> extraArguments = const <String>[],
  String? query,
}) async {
  String testPath;
  if (testName == null) {
    // Test everything in the directory.
    testPath = testDirectory;
    final Directory directoryToTest = fileSystem.directory(testPath);
    if (!directoryToTest.existsSync()) {
      fail('missing test directory: $directoryToTest');
    }
  } else {
    // Test just a specific test file.
    testPath = fileSystem.path.join(testDirectory, '${testName}_test.dart');
    final File testFile = fileSystem.file(testPath);
    if (!testFile.existsSync()) {
      fail('missing test file: $testFile');
    }
  }

  final args = <String>[
    'test',
    '--no-color',
    '--no-version-check',
    '--no-pub',
    '--reporter',
    'compact',
    ...extraArguments,
    if (query != null) Uri.file(testPath).replace(query: query).toString() else testPath,
  ];

  return Process.run(
    flutterBin, // Uses the precompiled flutter tool for faster tests,
    args,
    workingDirectory: workingDirectory,
    stdoutEncoding: utf8,
    stderrEncoding: utf8,
  );
}

Future<Process> _runFlutterTestConcurrent(
  String? testName,
  String workingDirectory,
  String testDirectory, {
  List<String> extraArguments = const <String>[],
}) async {
  String testPath;
  if (testName == null) {
    // Test everything in the directory.
    testPath = testDirectory;
    final Directory directoryToTest = fileSystem.directory(testPath);
    if (!directoryToTest.existsSync()) {
      fail('missing test directory: $directoryToTest');
    }
  } else {
    // Test just a specific test file.
    testPath = fileSystem.path.join(testDirectory, '${testName}_test.dart');
    final File testFile = fileSystem.file(testPath);
    if (!testFile.existsSync()) {
      fail('missing test file: $testFile');
    }
  }

  final args = <String>[
    'test',
    '--no-color',
    '--no-version-check',
    '--no-pub',
    '--reporter',
    'compact',
    ...extraArguments,
    testPath,
  ];

  return Process.start(
    flutterBin, // Uses the precompiled flutter tool for faster tests,
    args,
    workingDirectory: workingDirectory,
  );
}
