| // 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', |
| }; |