Skip to content

Commit

Permalink
Merge pull request #74 from Workiva/add-test-apis-for-testing-resolve…
Browse files Browse the repository at this point in the history
…d-ast-suggestors

FEDX-522 Add APIs to help test suggestors with resolved AST
  • Loading branch information
rmconsole4-wk authored Nov 20, 2023
2 parents 3321751 + 5330958 commit eef2fd0
Show file tree
Hide file tree
Showing 5 changed files with 218 additions and 13 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## [1.2.0](https://github.com/Workiva/dart_codemod/compare/1.1.0...1.2.0)

- Add `PackageContextForTest` to `package:codemod/test.dart` to help test
suggestors that require a fully resolved AST from the analyzer (for example:
suggestors using the `AstVisitingSuggestor` mixin with `shouldResolveAst`
enabled).

## [1.1.0](https://github.com/Workiva/dart_codemod/compare/1.0.11...1.1.0)

- Compatibility with Dart 3 and analyzer 6.
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,52 @@ var foo = 'foo';
}
```

### Testing Suggestors with Resolved AST

The `fileContextForTest()` helper shown above makes it easy to test suggestors
that operate on the _unresolved_ AST, but some suggestors require the _resolved_
AST. For example, a suggestor may need to rename a specific symbol from a specific
package, and so it would need to check the resolved element of a node. This is
only possible if the analysis context is aware of all the relevant files and
package dependencies.

To help with this scenario, the `package:codemod/test.dart` library also exports
a `PackageContextForTest` helper class. This class handles creating a temporary
package directory, installing dependencies, and setting up an analysis context
that has access to the whole package and its dependencies. You can then add
source file(s) and use the wrapping `FileContext`s to test suggestors.

```dart
import 'package:codemod/codemod.dart';
import 'package:source_span/source_span.dart';
import 'package:test/test.dart';
void main() {
group('AlwaysThrowsFixer', () {
test('returns Never instead', () async {
final pkg = await PackageContextForTest.fromPubspec('''
name: pkg
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
meta: ^1.0.0
''');
final context = await pkg.addFile('''
import 'package:meta/meta.dart';
@alwaysThrows toss() { throw 'Thrown'; }
''');
final expectedOutput = '''
import 'package:meta/meta.dart';
Never toss() { throw 'Thrown'; }
''';
expectSuggestorGeneratesPatches(
AlwaysThrowsFixer(), context, expectedOutput);
});
});
}
```

## References

- [over_react_codemod][over_react_codemod]: codemods for the `over_react` UI
Expand Down
3 changes: 3 additions & 0 deletions dart_dependency_validator.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ignore:
# Flagged as a false positive due to one of the unit test files
- meta
136 changes: 123 additions & 13 deletions lib/test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:io';

import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
Expand All @@ -9,7 +11,26 @@ import 'src/util.dart';

export 'src/util.dart' show applyPatches;

/// Creates a file with the given [name] and [sourceText] using the
/// Uses [suggestor] to generate a stream of patches for [context] and returns
/// what the resulting file contents would be after applying all of them.
///
/// Use this to test that a suggestor produces the expected result:
/// test('MySuggestor', () async {
/// var context = await fileContextForTest('foo.dart', 'library foo;');
/// var suggestor = MySuggestor();
/// var expectedOutput = '...';
/// expectSuggestorGeneratesPatches(suggestor, context, expectedOutput);
/// });
void expectSuggestorGeneratesPatches(
Suggestor suggestor, FileContext context, dynamic resultMatcher) {
expect(
suggestor(context)
.toList()
.then((patches) => applyPatches(context.sourceFile, patches)),
completion(resultMatcher));
}

/// Creates a temporary file with the given [name] and [sourceText] using the
/// `test_descriptor` package, sets up analysis for that file, and returns a
/// [FileContext] wrapper around it.
///
Expand All @@ -19,6 +40,9 @@ export 'src/util.dart' show applyPatches;
/// var patches = MySuggestor().generatePatches(context);
/// expect(patches, ...);
/// });
///
/// See also: [PackageContextForTest] if testing [Suggestor]s that need a fully
/// resolved AST from the analyzer.
Future<FileContext> fileContextForTest(String name, String sourceText) async {
// Use test_descriptor to create the file in a temporary directory
d.Descriptor descriptor;
Expand All @@ -32,27 +56,113 @@ Future<FileContext> fileContextForTest(String name, String sourceText) async {
await descriptor.create();

// Setup analysis for this file
final path = p.canonicalize(p.join(d.sandbox, name));
final path = p.canonicalize(d.path(name));
final collection = AnalysisContextCollection(includedPaths: [path]);

return FileContext(path, collection, root: d.sandbox);
}

/// Uses [suggestor] to generate a stream of patches for [context] and returns
/// what the resulting file contents would be after applying all of them.
/// Creates a temporary directory with a pubspec using the `test_descriptor`
/// package, installs dependencies with `dart pub get`, and sets up an analysis
/// context for the package.
///
/// Use this to test that a suggestor produces the expected result:
/// Source files can then be added to the package with [addFile], which will
/// return a [FileContext] wrapper for use in tests.
///
/// Use this to setup tests for [Suggestor]s that require the resolved AST, like
/// the [AstVisitingSuggestor] when `shouldResolveAst()` returns true. Doing so
/// will enable the analyzer to resolve imports and symbols from other source
/// files and dependencies.
/// test('MySuggestor', () async {
/// var context = await fileContextForTest('foo.dart', 'library foo;');
/// var pkg = await PackageContextForTest.fromPubspec('''
/// name: pkg
/// version: 0.0.0
/// environment:
/// sdk: '>=3.0.0 <4.0.0'
/// dependencies:
/// meta: ^1.0.0
/// ''');
/// var context = await pkg.addFile('''
/// import 'package:meta/meta.dart';
/// @visibleForTesting var foo = true;
/// ''');
/// var suggestor = MySuggestor();
/// var expectedOutput = '...';
/// expectSuggestorGeneratesPatches(suggestor, context, expectedOutput);
/// });
void expectSuggestorGeneratesPatches(
Suggestor suggestor, FileContext context, dynamic resultMatcher) {
expect(
suggestor(context)
.toList()
.then((patches) => applyPatches(context.sourceFile, patches)),
completion(resultMatcher));
class PackageContextForTest {
final AnalysisContextCollection _collection;
final String _name;
final String _root;
static int _fileCounter = 0;
static int _packageCounter = 0;

/// Creates a temporary directory named [dirName] using the `test_descriptor`
/// package, installs dependencies with `dart pub get`, sets up an analysis
/// context for the package, and returns a [PackageContextForTest] wrapper
/// that allows you to add source files to the package and use them in tests.
///
/// If [dirName] is null, a unique name will be generated.
///
/// Throws an [ArgumentError] if it fails to install dependencies.
static Future<PackageContextForTest> fromPubspec(
String pubspecContents, [
String? dirName,
]) async {
dirName ??= 'package_${_packageCounter++}';

await d.dir(dirName, [
d.file('pubspec.yaml', pubspecContents),
]).create();

final root = p.canonicalize(d.path(dirName));
final pubGet =
Process.runSync('dart', ['pub', 'get'], workingDirectory: root);
if (pubGet.exitCode != 0) {
printOnFailure('''
PROCESS: dart pub get
WORKING DIR: $root
STDOUT:
${pubGet.stdout}
STDERR:
${pubGet.stderr}
''');
throw ArgumentError('Failed to install dependencies from given pubspec');
}
final collection = AnalysisContextCollection(includedPaths: [root]);
return PackageContextForTest._(dirName, root, collection);
}

PackageContextForTest._(this._name, this._root, this._collection);

/// Creates a temporary file at the given [path] (relative to the root of this
/// package) with the given [sourceText] using the `test_descriptor` package
/// and returns a [FileContext] wrapper around it.
///
/// If [path] is null, a unique filename will be generated.
///
/// The returned [FileContext] will use the analysis context for this whole
/// package rather than just this file, which enables testing of [Suggestor]s
/// that require the resolved AST.
///
/// See [PackageContextForTest] for an example.
Future<FileContext> addFile(String sourceText, [String? path]) async {
path ??= 'test_${_fileCounter++}.dart';

// Use test_descriptor to create the file in a temporary directory
d.Descriptor descriptor;
final segments = p.split(path);
// Last segment should be the file
descriptor = d.file(segments.last, sourceText);
// Any preceding segments (if any) are directories
for (final dir in segments.reversed.skip(1)) {
descriptor = d.dir(dir, [descriptor]);
}
// Add the root directory.
descriptor = d.dir(_name, [descriptor]);

await descriptor.create();
final canonicalizedPath = p.canonicalize(p.join(d.sandbox, _name, path));
return FileContext(canonicalizedPath, _collection, root: _root);
}
}
39 changes: 39 additions & 0 deletions test/ast_visiting_suggestor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ class LibNameDoubler extends RecursiveAstVisitor<void>
}
}

class AlwaysThrowsFixer extends GeneralizingAstVisitor<void>
with AstVisitingSuggestor {
@override
bool shouldResolveAst(_) => true;

@override
void visitFunctionDeclaration(FunctionDeclaration node) {
for (final annotation in node.metadata) {
final isAlwaysThrows = annotation.name.name == 'alwaysThrows';
final annotationPackage = annotation.element?.library?.identifier ?? '';
final isFromPackageMeta = annotationPackage.startsWith('package:meta/');
if (isAlwaysThrows && isFromPackageMeta) {
yieldPatch('Never', annotation.offset, annotation.end);
}
}
}
}

void main() {
group('AstVisitingSuggestor', () {
test('should get compilation unit, visit it, and yield patches', () async {
Expand Down Expand Up @@ -99,5 +117,26 @@ void main() {
expect(await patchesA.toList(), [Patch('aa', 8, 9)]);
expect(await patchesC.toList(), [Patch('cc', 8, 9)]);
});

test('should resolve AST and work with imports', () async {
final pkg = await PackageContextForTest.fromPubspec('''
name: pkg
publish_to: none
environment:
sdk: '>=2.19.0 <4.0.0'
dependencies:
meta: ^1.0.0
''');
final context = await pkg.addFile('''
import 'package:meta/meta.dart';
@alwaysThrows toss() { throw 'Thrown'; }
''');
final expectedOutput = '''
import 'package:meta/meta.dart';
Never toss() { throw 'Thrown'; }
''';
expectSuggestorGeneratesPatches(
AlwaysThrowsFixer(), context, expectedOutput);
});
});
}

0 comments on commit eef2fd0

Please sign in to comment.