blob: 417f33d672fd3be6d920531656d4f929aa404cb2 [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. 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:io' as io;
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/src/dart/ast/ast.dart';
import 'package:analyzer/src/utilities/extensions/collection.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
class TextExpectationsCollector {
/// If this flag is `true`, we accumulate updates to expectations.
/// This should only happen locally, to update tests or implementation.
///
/// This flag should be `false` during code review.
static const updatingIsEnabled = false;
static final assertMethods = [
_AssertMethod(
className: 'AbstractCompletionDriverTest',
methodName: 'assertResponse',
argument: _ArgumentIndex(0),
),
_AssertMethod(
className: '_AnalysisDomainTest',
methodName: 'assertNotificationsText',
argument: _ArgumentIndex(0),
),
_AssertMethod(
className: 'SelectionTest',
methodName: '_assertSelection',
argument: _ArgumentIndex(1),
),
];
static final Map<String, _File> _files = {};
static void add(String actual) {
if (!updatingIsEnabled) {
return;
}
var traceLines = '${StackTrace.current}'.split('\n');
for (var assertMethod in assertMethods) {
for (var traceIndex = 0; traceIndex < traceLines.length; traceIndex++) {
var traceLine = traceLines[traceIndex];
if (!traceLine.contains(' ${assertMethod.stackTracePattern} ')) {
continue;
}
// Find the invocation of the assert method in the stack trace.
var invocationTraceIndex = traceIndex + 1;
if (traceLines[invocationTraceIndex] == '<asynchronous suspension>') {
invocationTraceIndex++;
}
var invocationTraceLine = traceLines[invocationTraceIndex];
// Parse the invocation stack trace line.
var locationMatch = RegExp(
r'(file://.+_test.dart):(\d+):',
).firstMatch(invocationTraceLine);
if (locationMatch == null) {
fail('Cannot parse: $invocationTraceLine');
}
var path = Uri.parse(locationMatch.group(1)!).toFilePath();
var line = int.parse(locationMatch.group(2)!);
var file = _getFile(path);
var invocation = file.findInvocation(
invocationLine: line,
);
if (invocation == null) {
fail('Cannot find MethodInvocation.');
}
if (invocation.methodName.name != assertMethod.methodName) {
fail(
'Expected: ${assertMethod.methodName}\n'
'Actual: ${invocation.methodName.name}\n',
);
}
if (invocation.isInFailingTest) {
return;
}
var argumentList = invocation.argumentList;
var argument = assertMethod.argument.get(argumentList);
if (argument is! SimpleStringLiteral) {
fail('Not a literal: ${argument.runtimeType}');
}
file.addReplacement(
_Replacement(
argument.contentsOffset,
argument.contentsEnd,
actual,
),
);
// Stop after the first (most specific) assert method.
return;
}
}
}
static void apply() {
for (var file in _files.values) {
file.applyReplacements();
}
_files.clear();
}
static _File _getFile(String path) {
return _files[path] ??= _File(path);
}
}
@reflectiveTest
class UpdateTextExpectations {
test_applyReplacements() {
TextExpectationsCollector.apply();
}
}
sealed class _Argument {
Expression get(ArgumentList argumentList);
}
final class _ArgumentIndex extends _Argument {
final int index;
_ArgumentIndex(this.index);
@override
Expression get(ArgumentList argumentList) {
return argumentList.arguments
.whereNotType<NamedExpression>()
.elementAt(index);
}
}
class _AssertMethod {
final String methodName;
final String stackTracePattern;
final _Argument argument;
const _AssertMethod({
required String className,
required this.methodName,
required this.argument,
}) : stackTracePattern = '$className.$methodName';
}
class _File {
final String path;
final String content;
final LineInfo lineInfo;
final CompilationUnit unit;
final List<_Replacement> replacements = [];
factory _File(String path) {
var content = io.File(path).readAsStringSync();
var collection = AnalysisContextCollection(
resourceProvider: PhysicalResourceProvider.INSTANCE,
includedPaths: [path],
);
var analysisContext = collection.contextFor(path);
var analysisSession = analysisContext.currentSession;
var parseResult = analysisSession.getParsedUnit(path);
parseResult as ParsedUnitResult;
return _File._(
path: path,
content: content,
lineInfo: LineInfo.fromContent(content),
unit: parseResult.unit,
);
}
_File._({
required this.path,
required this.content,
required this.lineInfo,
required this.unit,
});
void addReplacement(_Replacement replacement) {
// Check if there is the same replacement.
for (var existing in replacements) {
if (existing.offset == replacement.offset) {
// Sanity check.
if (existing.end != replacement.end) {
fail(
'At offset: ${existing.offset}\n'
'Existing end: ${existing.end}\n'
'New end: ${replacement.end}\n',
);
}
if (existing.text != replacement.text) {
fail(
'At offset: ${existing.offset}\n'
'Existing text:\n${existing.text}\n'
'New text:\n${replacement.end}\n',
);
}
// We already have the same replacement, exit.
return;
}
}
// This is a new replacement, add it.
replacements.add(replacement);
}
void applyReplacements() {
replacements.sort((a, b) => b.offset - a.offset);
var newCode = content;
for (var replacement in replacements) {
newCode = newCode.substring(0, replacement.offset) +
replacement.text +
newCode.substring(replacement.end);
}
io.File(path).writeAsStringSync(newCode);
}
MethodInvocation? findInvocation({
required int invocationLine,
}) {
var visitor = _InvocationVisitor(
lineInfo: lineInfo,
requestedLine: invocationLine,
);
unit.accept(visitor);
return visitor.result;
}
}
class _InvocationVisitor extends RecursiveAstVisitor<void> {
final LineInfo lineInfo;
final int requestedLine;
MethodInvocation? result;
_InvocationVisitor({
required this.lineInfo,
required this.requestedLine,
});
@override
void visitMethodInvocation(MethodInvocation node) {
if (result != null) {
return;
}
var nodeLine = lineInfo.getLocation(node.offset).lineNumber;
if (nodeLine == requestedLine) {
result = node;
}
super.visitMethodInvocation(node);
}
}
class _Replacement {
final int offset;
final int end;
final String text;
_Replacement(this.offset, this.end, this.text);
}
extension on AstNode {
bool get isInFailingTest {
var testMethod = thisOrAncestorOfType<MethodDeclaration>();
if (testMethod != null && testMethod.name.lexeme.startsWith('test_')) {
for (var annotation in testMethod.metadata) {
var name = annotation.name.name;
if (const {'FailingTest', 'failingTest'}.contains(name)) {
return true;
}
}
}
return false;
}
}