| // 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:convert'; | |
| import 'dart:io'; | |
| import 'tool_subsharding.dart'; | |
| /// Reads and decodes the `LUCI_CONTEXT` JSON file, or returns null if it is not | |
| /// set or cannot be read. | |
| /// | |
| /// `LUCI_CONTEXT` is a file path (in the `LUCI_CONTEXT` environment variable) | |
| /// pointing at a JSON document that LUCI populates for the running build. | |
| Map<String, Object?>? readLuciContext([Map<String, String>? environment]) { | |
| final Map<String, String> env = environment ?? Platform.environment; | |
| final String? luciContextPath = env['LUCI_CONTEXT']; | |
| if (luciContextPath == null || luciContextPath.isEmpty) { | |
| return null; | |
| } | |
| final file = File(luciContextPath); | |
| if (!file.existsSync()) { | |
| return null; | |
| } | |
| // Be defensive: an unreadable file or malformed JSON must never crash the | |
| // test run, since this is called outside the reporting try/catch. | |
| try { | |
| if (json.decode(file.readAsStringSync()) case final Map<String, Object?> decoded) { | |
| return decoded; | |
| } | |
| return null; | |
| } catch (_) { | |
| return null; | |
| } | |
| } | |
| /// A client for the [ResultDB Recorder][recorder] `BatchCreateTestResults` API. | |
| /// | |
| /// This uploads test results directly to the build's ResultDB invocation using | |
| /// the `update_token` from `LUCI_CONTEXT["resultdb"]["current_invocation"]`. | |
| /// | |
| /// This works even when the build is not running under `rdb stream` (which is | |
| /// the case for the Flutter recipe): bbagent creates the invocation and exposes | |
| /// `current_invocation` in `LUCI_CONTEXT`, without needing a separate result | |
| /// streaming sidecar. | |
| /// | |
| /// [recorder]: https://pkg.go.dev/go.chromium.org/luci/resultdb/proto/v1 | |
| class ResultDbRecorder { | |
| ResultDbRecorder({ | |
| required this.host, | |
| required this.invocation, | |
| required this.updateToken, | |
| HttpClient? httpClient, | |
| }) : _client = httpClient ?? HttpClient(); | |
| /// The ResultDB hostname (for example, `results.api.luci.app`). | |
| final String host; | |
| /// The invocation name (for example, `invocations/build-123`). | |
| final String invocation; | |
| /// The token that authorizes writes to [invocation]. | |
| final String updateToken; | |
| final HttpClient _client; | |
| /// Creates a [ResultDbRecorder] from the `LUCI_CONTEXT`, or returns null if | |
| /// ResultDB is not configured for the current invocation. | |
| static ResultDbRecorder? fromEnvironment([Map<String, String>? environment]) { | |
| final Map<String, Object?>? luciContext = readLuciContext(environment); | |
| if (luciContext case { | |
| 'resultdb': { | |
| 'hostname': final String host, | |
| 'current_invocation': {'name': final String name, 'update_token': final String updateToken}, | |
| }, | |
| }) { | |
| return ResultDbRecorder(host: host, invocation: name, updateToken: updateToken); | |
| } | |
| return null; | |
| } | |
| /// Reports the given [testResults] to the invocation via | |
| /// `BatchCreateTestResults`. | |
| /// | |
| /// Throws an [HttpException] if the server responds with a non-200 status. | |
| Future<void> reportTestResults(List<LuciTestResult> testResults) async { | |
| if (testResults.isEmpty) { | |
| return; | |
| } | |
| final Uri url = Uri.parse( | |
| 'https://$host/prpc/luci.resultdb.v1.Recorder/BatchCreateTestResults', | |
| ); | |
| for (final List<LuciTestResult> batch in _batches(testResults)) { | |
| final HttpClientRequest request = await _client.postUrl(url); | |
| request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); | |
| request.headers.set(HttpHeaders.acceptHeader, 'application/json'); | |
| // The Recorder authorizes writes to the invocation via this header. | |
| request.headers.set('update-token', updateToken); | |
| request.add( | |
| utf8.encode( | |
| json.encode(<String, Object?>{ | |
| 'invocation': invocation, | |
| 'requests': <Map<String, Object?>>[ | |
| for (final LuciTestResult result in batch) | |
| <String, Object?>{'testResult': result.toJson()}, | |
| ], | |
| }), | |
| ), | |
| ); | |
| final HttpClientResponse response = await request.close(); | |
| final String body = await response.transform(utf8.decoder).join(); | |
| if (response.statusCode != HttpStatus.ok) { | |
| throw HttpException( | |
| 'ResultDB BatchCreateTestResults failed with status ${response.statusCode}: $body', | |
| ); | |
| } | |
| } | |
| } | |
| /// Closes the underlying HTTP client. | |
| void close() { | |
| _client.close(force: true); | |
| } | |
| } | |
| /// The maximum number of test results to send in a single request. | |
| const int _kBatchSize = 500; | |
| /// Splits [testResults] into batches of at most [_kBatchSize]. | |
| Iterable<List<LuciTestResult>> _batches(List<LuciTestResult> testResults) sync* { | |
| for (var i = 0; i < testResults.length; i += _kBatchSize) { | |
| final int end = (i + _kBatchSize < testResults.length) ? i + _kBatchSize : testResults.length; | |
| yield testResults.sublist(i, end); | |
| } | |
| } | |
| /// A ResultDB [structured test id][id] (`testIdStructured`). | |
| /// | |
| /// Using a structured id lets the "Test Results" UI present the test file and | |
| /// the individual test name as separate columns. | |
| /// | |
| /// [id]: https://pkg.go.dev/go.chromium.org/luci/resultdb/proto/v1#TestIdentifier | |
| class LuciStructuredTestId { | |
| const LuciStructuredTestId({ | |
| required this.moduleName, | |
| required this.caseName, | |
| this.moduleScheme = 'flat', | |
| this.moduleVariant = const <String, String>{}, | |
| }); | |
| /// The module name; for Flutter this is the test file relative to the repo | |
| /// root. | |
| final String moduleName; | |
| /// The scheme the module belongs to. `flat` has no intermediate (coarse/fine) | |
| /// hierarchy levels. | |
| final String moduleScheme; | |
| /// The module variant definition (empty for Flutter's tests). | |
| final Map<String, String> moduleVariant; | |
| /// The case name; for Flutter this is the individual test name. | |
| final String caseName; | |
| /// The JSON representation used by the ResultDB Recorder API. | |
| Map<String, Object?> toJson() => <String, Object?>{ | |
| 'moduleName': moduleName, | |
| 'moduleScheme': moduleScheme, | |
| 'moduleVariant': moduleVariant, | |
| 'caseName': caseName, | |
| }; | |
| } | |
| /// A single ResultDB [`TestResult`][result], describing the outcome of one | |
| /// individual test case. | |
| /// | |
| /// [result]: https://pkg.go.dev/go.chromium.org/luci/resultdb/proto/v1#TestResult | |
| class LuciTestResult { | |
| const LuciTestResult({ | |
| required this.testId, | |
| required this.resultId, | |
| required this.expected, | |
| required this.status, | |
| required this.duration, | |
| }); | |
| /// The structured id identifying the test file and case. | |
| final LuciStructuredTestId testId; | |
| /// An id that is unique within the invocation for a given [testId]. | |
| final String resultId; | |
| /// Whether this result is expected (`true`) rather than an unexpected | |
| /// regression (`false`). | |
| final bool expected; | |
| /// The ResultDB `TestStatus` enum value (`PASS`, `FAIL`, or `SKIP`). | |
| final String status; | |
| /// The test duration as a proto `Duration` string (for example, `0.100000s`). | |
| final String duration; | |
| /// The JSON representation used by the ResultDB Recorder API. | |
| Map<String, Object?> toJson() => <String, Object?>{ | |
| 'testIdStructured': testId.toJson(), | |
| 'resultId': resultId, | |
| 'expected': expected, | |
| 'status': status, | |
| 'duration': duration, | |
| }; | |
| } | |
| /// Converts the parsed [results] into a list of [LuciTestResult]s, one per | |
| /// (non-hidden) individual test case. | |
| /// | |
| /// Each result uses a *structured* test id (`testIdStructured`) so the "Test | |
| /// Results" tab can separate the file from the test name: the module name is the | |
| /// test file (relative to the repo root) and the case name is the individual | |
| /// test name. The `flat` scheme is used because it has no intermediate | |
| /// (coarse/fine) hierarchy levels. | |
| /// | |
| /// [workingDirectory] is the directory the shard ran `flutter test`/`dart test` | |
| /// in; it is used to resolve relative suite paths. [rootDirectory], when | |
| /// provided, is stripped from the front of the (resolved) suite path so that the | |
| /// module name is reported relative to the repository root (for example, | |
| /// `packages/flutter/test/foo_test.dart` instead of an absolute path or a bare | |
| /// `test/foo_test.dart`). | |
| /// | |
| /// When [expectFailure] is true, the entire `flutter test` invocation was | |
| /// expected to fail (for example, the `test_smoke_test` negative tests run with | |
| /// `expectFailure: true`). In that case every reported result is marked as | |
| /// `expected`, so ResultDB records them as expected failures rather than | |
| /// surfacing them as red regressions in the "Test Results" tab. | |
| List<LuciTestResult> convertToLuciTestResultsFormat( | |
| TestFileReporterResults results, { | |
| bool expectFailure = false, | |
| String? workingDirectory, | |
| String? rootDirectory, | |
| }) { | |
| var counter = 0; | |
| return <LuciTestResult>[ | |
| for (final testResult in results.testResults.values) | |
| if (!testResult.hidden) | |
| LuciTestResult( | |
| // A structured test id: the module is the test file and the case is the | |
| // individual test name, so ResultDB presents them as separate columns. | |
| testId: LuciStructuredTestId( | |
| moduleName: _sanitizeModuleName( | |
| switch (results.allTestSpecs[testResult.suiteID]?.path) { | |
| final String path when path.isNotEmpty => _repoRelativeSuitePath( | |
| path, | |
| workingDirectory, | |
| rootDirectory, | |
| ), | |
| _ => testResult.name, | |
| }, | |
| ), | |
| caseName: _sanitizeCaseName(testResult.name), | |
| ), | |
| // Result ids must be unique within the invocation for a given test id. | |
| resultId: '${counter++}', | |
| expected: expectFailure || testResult.actual == testResult.expected, | |
| status: _sinkStatus(testResult), | |
| duration: '${testResult.seconds.toStringAsFixed(6)}s', | |
| ), | |
| ]; | |
| } | |
| /// Returns [suitePath] as a forward-slash path relative to the repository root. | |
| /// | |
| /// Suite paths reported by the test runner may be absolute (e.g. | |
| /// `/b/s/w/.../flutter/dev/foo/bar_test.dart`) or relative to the directory the | |
| /// shard ran in (e.g. `test/bar_test.dart` for a `packages/flutter` shard). | |
| /// Relative paths are first resolved against [workingDirectory], then | |
| /// [rootDirectory] (the repo root) is stripped, so every module name is a | |
| /// consistent, repo-relative path regardless of where the shard ran. | |
| String _repoRelativeSuitePath(String suitePath, String? workingDirectory, String? rootDirectory) { | |
| var p = suitePath; | |
| if (workingDirectory != null && workingDirectory.isNotEmpty && !_isAbsolutePath(p)) { | |
| p = '${_stripTrailingSeparators(workingDirectory)}/$p'; | |
| } | |
| // Normalize separators so the id is stable and readable across platforms. | |
| p = p.replaceAll(r'\', '/'); | |
| if (rootDirectory != null && rootDirectory.isNotEmpty) { | |
| final root = '${_stripTrailingSeparators(rootDirectory.replaceAll(r'\', '/'))}/'; | |
| if (p.startsWith(root)) { | |
| p = p.substring(root.length); | |
| } | |
| } | |
| return p; | |
| } | |
| /// Whether [p] is an absolute path on POSIX (`/foo`) or Windows (`C:\foo`, | |
| /// `C:/foo` or `\foo`). | |
| bool _isAbsolutePath(String p) => switch (p) { | |
| String(isEmpty: true) => false, | |
| _ when p.startsWith('/') || p.startsWith(r'\') => true, | |
| // Windows drive-letter path, e.g. `C:\...` or `C:/...`. | |
| _ => p.length >= 3 && p[1] == ':' && (p[2] == r'\' || p[2] == '/'), | |
| }; | |
| /// Removes any trailing `/` or `\` separators from [p]. | |
| String _stripTrailingSeparators(String p) { | |
| int end = p.length; | |
| while (end > 0 && (p[end - 1] == '/' || p[end - 1] == r'\')) { | |
| end--; | |
| } | |
| return p.substring(0, end); | |
| } | |
| /// The maximum length, in bytes, of a structured test id module name. | |
| const int _kMaxModuleNameBytes = 300; | |
| /// The maximum length, in bytes, of a structured test id case name. | |
| const int _kMaxCaseNameBytes = 512; | |
| /// Matches ASCII control characters (including newlines and tabs) and DEL. | |
| final RegExp _controlCharacters = RegExp(r'[\x00-\x1f\x7f]'); | |
| /// Makes [moduleName] safe for a ResultDB structured test id. | |
| /// | |
| /// The module name must be non-empty, printable UTF-8 of at most | |
| /// [_kMaxModuleNameBytes] bytes. Colons are allowed (they commonly appear in | |
| /// build target names), so no escaping is required. | |
| String _sanitizeModuleName(String moduleName) { | |
| // Replace control characters (including newlines/tabs) with spaces. | |
| final String sanitized = moduleName.replaceAll(_controlCharacters, ' '); | |
| return _truncateToBytes(sanitized.isEmpty ? 'unknown' : sanitized, _kMaxModuleNameBytes); | |
| } | |
| /// Makes [caseName] safe for a ResultDB structured (non-legacy) test id case | |
| /// name. | |
| /// | |
| /// Unlike the legacy id format, a non-legacy scheme's case name must: | |
| /// * escape `\` and `:` with a backslash (`:` denotes hierarchy separators), | |
| /// * not start with a character in U+0020..U+002C (unless it is `*fixture`), | |
| /// * be non-empty, printable UTF-8 of at most [_kMaxCaseNameBytes] bytes. | |
| /// | |
| /// Keeping this robust prevents a single unusual test name from causing the | |
| /// whole batch of results to be rejected. | |
| String _sanitizeCaseName(String caseName) { | |
| // Replace control characters (including newlines/tabs) with spaces. | |
| String sanitized = caseName.replaceAll(_controlCharacters, ' '); | |
| // "*fixture" is a reserved value (used for setup/teardown); leave it as-is. | |
| if (sanitized != '*fixture') { | |
| // Escape backslashes first, then colons (order matters). | |
| sanitized = sanitized.replaceAll(r'\', r'\\').replaceAll(':', r'\:'); | |
| } | |
| if (sanitized.isEmpty) { | |
| sanitized = 'unnamed test'; | |
| } | |
| // The first character must not be in U+0020..U+002C (space and !"#$%&'()*+,). | |
| final int first = sanitized.codeUnitAt(0); | |
| if (sanitized != '*fixture' && first >= 0x20 && first <= 0x2c) { | |
| sanitized = '_$sanitized'; | |
| } | |
| sanitized = _truncateToBytes(sanitized, _kMaxCaseNameBytes); | |
| // Truncation must not leave a dangling (unpaired) trailing backslash, which | |
| // would be an invalid escape sequence. | |
| return _stripDanglingBackslash(sanitized); | |
| } | |
| /// Truncates [value] so its UTF-8 encoding is at most [maxBytes] bytes, without | |
| /// splitting a UTF-16 code unit. | |
| String _truncateToBytes(String value, int maxBytes) { | |
| if (utf8.encode(value).length <= maxBytes) { | |
| return value; | |
| } | |
| // A UTF-8 encoding is always at least as long (in bytes) as the string's | |
| // UTF-16 code unit length, so truncating to [maxBytes] code units first keeps | |
| // the byte-length loop below to at most [maxBytes] iterations (avoiding O(N^2) | |
| // behavior on very long names). | |
| if (value.length > maxBytes) { | |
| value = value.substring(0, maxBytes); | |
| } | |
| while (utf8.encode(value).length > maxBytes) { | |
| value = value.substring(0, value.length - 1); | |
| } | |
| return value; | |
| } | |
| /// Removes a trailing backslash if it would be left unpaired (for example after | |
| /// truncation), so the value remains a valid escaped string. | |
| String _stripDanglingBackslash(String value) { | |
| var trailing = 0; | |
| for (int i = value.length - 1; i >= 0 && value[i] == r'\'; i--) { | |
| trailing++; | |
| } | |
| if (trailing.isOdd) { | |
| return value.substring(0, value.length - 1); | |
| } | |
| return value; | |
| } | |
| /// Maps a parsed [testResult] to a ResultDB `TestStatus` enum value. | |
| String _sinkStatus(TestResult testResult) => switch (testResult) { | |
| TestResult(skipped: true) => 'SKIP', | |
| TestResult(actual: 'PASS') => 'PASS', | |
| _ => 'FAIL', | |
| }; |