blob: 668e82a0194a4308b31871e08dd57e0cc543f2da [file] [edit]
// 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',
};