Skip to content

Commit eef2fd0

Browse files
Merge pull request #74 from Workiva/add-test-apis-for-testing-resolved-ast-suggestors
FEDX-522 Add APIs to help test suggestors with resolved AST
2 parents 3321751 + 5330958 commit eef2fd0

5 files changed

Lines changed: 218 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## [1.2.0](https://github.com/Workiva/dart_codemod/compare/1.1.0...1.2.0)
2+
3+
- Add `PackageContextForTest` to `package:codemod/test.dart` to help test
4+
suggestors that require a fully resolved AST from the analyzer (for example:
5+
suggestors using the `AstVisitingSuggestor` mixin with `shouldResolveAst`
6+
enabled).
7+
18
## [1.1.0](https://github.com/Workiva/dart_codemod/compare/1.0.11...1.1.0)
29

310
- Compatibility with Dart 3 and analyzer 6.

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,52 @@ var foo = 'foo';
349349
}
350350
```
351351

352+
### Testing Suggestors with Resolved AST
353+
354+
The `fileContextForTest()` helper shown above makes it easy to test suggestors
355+
that operate on the _unresolved_ AST, but some suggestors require the _resolved_
356+
AST. For example, a suggestor may need to rename a specific symbol from a specific
357+
package, and so it would need to check the resolved element of a node. This is
358+
only possible if the analysis context is aware of all the relevant files and
359+
package dependencies.
360+
361+
To help with this scenario, the `package:codemod/test.dart` library also exports
362+
a `PackageContextForTest` helper class. This class handles creating a temporary
363+
package directory, installing dependencies, and setting up an analysis context
364+
that has access to the whole package and its dependencies. You can then add
365+
source file(s) and use the wrapping `FileContext`s to test suggestors.
366+
367+
```dart
368+
import 'package:codemod/codemod.dart';
369+
import 'package:source_span/source_span.dart';
370+
import 'package:test/test.dart';
371+
372+
void main() {
373+
group('AlwaysThrowsFixer', () {
374+
test('returns Never instead', () async {
375+
final pkg = await PackageContextForTest.fromPubspec('''
376+
name: pkg
377+
publish_to: none
378+
environment:
379+
sdk: '>=3.0.0 <4.0.0'
380+
dependencies:
381+
meta: ^1.0.0
382+
''');
383+
final context = await pkg.addFile('''
384+
import 'package:meta/meta.dart';
385+
@alwaysThrows toss() { throw 'Thrown'; }
386+
''');
387+
final expectedOutput = '''
388+
import 'package:meta/meta.dart';
389+
Never toss() { throw 'Thrown'; }
390+
''';
391+
expectSuggestorGeneratesPatches(
392+
AlwaysThrowsFixer(), context, expectedOutput);
393+
});
394+
});
395+
}
396+
```
397+
352398
## References
353399

354400
- [over_react_codemod][over_react_codemod]: codemods for the `over_react` UI

dart_dependency_validator.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ignore:
2+
# Flagged as a false positive due to one of the unit test files
3+
- meta

lib/test.dart

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:io';
2+
13
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
24
import 'package:path/path.dart' as p;
35
import 'package:test/test.dart';
@@ -9,7 +11,26 @@ import 'src/util.dart';
911

1012
export 'src/util.dart' show applyPatches;
1113

12-
/// Creates a file with the given [name] and [sourceText] using the
14+
/// Uses [suggestor] to generate a stream of patches for [context] and returns
15+
/// what the resulting file contents would be after applying all of them.
16+
///
17+
/// Use this to test that a suggestor produces the expected result:
18+
/// test('MySuggestor', () async {
19+
/// var context = await fileContextForTest('foo.dart', 'library foo;');
20+
/// var suggestor = MySuggestor();
21+
/// var expectedOutput = '...';
22+
/// expectSuggestorGeneratesPatches(suggestor, context, expectedOutput);
23+
/// });
24+
void expectSuggestorGeneratesPatches(
25+
Suggestor suggestor, FileContext context, dynamic resultMatcher) {
26+
expect(
27+
suggestor(context)
28+
.toList()
29+
.then((patches) => applyPatches(context.sourceFile, patches)),
30+
completion(resultMatcher));
31+
}
32+
33+
/// Creates a temporary file with the given [name] and [sourceText] using the
1334
/// `test_descriptor` package, sets up analysis for that file, and returns a
1435
/// [FileContext] wrapper around it.
1536
///
@@ -19,6 +40,9 @@ export 'src/util.dart' show applyPatches;
1940
/// var patches = MySuggestor().generatePatches(context);
2041
/// expect(patches, ...);
2142
/// });
43+
///
44+
/// See also: [PackageContextForTest] if testing [Suggestor]s that need a fully
45+
/// resolved AST from the analyzer.
2246
Future<FileContext> fileContextForTest(String name, String sourceText) async {
2347
// Use test_descriptor to create the file in a temporary directory
2448
d.Descriptor descriptor;
@@ -32,27 +56,113 @@ Future<FileContext> fileContextForTest(String name, String sourceText) async {
3256
await descriptor.create();
3357

3458
// Setup analysis for this file
35-
final path = p.canonicalize(p.join(d.sandbox, name));
59+
final path = p.canonicalize(d.path(name));
3660
final collection = AnalysisContextCollection(includedPaths: [path]);
3761

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

41-
/// Uses [suggestor] to generate a stream of patches for [context] and returns
42-
/// what the resulting file contents would be after applying all of them.
65+
/// Creates a temporary directory with a pubspec using the `test_descriptor`
66+
/// package, installs dependencies with `dart pub get`, and sets up an analysis
67+
/// context for the package.
4368
///
44-
/// Use this to test that a suggestor produces the expected result:
69+
/// Source files can then be added to the package with [addFile], which will
70+
/// return a [FileContext] wrapper for use in tests.
71+
///
72+
/// Use this to setup tests for [Suggestor]s that require the resolved AST, like
73+
/// the [AstVisitingSuggestor] when `shouldResolveAst()` returns true. Doing so
74+
/// will enable the analyzer to resolve imports and symbols from other source
75+
/// files and dependencies.
4576
/// test('MySuggestor', () async {
46-
/// var context = await fileContextForTest('foo.dart', 'library foo;');
77+
/// var pkg = await PackageContextForTest.fromPubspec('''
78+
/// name: pkg
79+
/// version: 0.0.0
80+
/// environment:
81+
/// sdk: '>=3.0.0 <4.0.0'
82+
/// dependencies:
83+
/// meta: ^1.0.0
84+
/// ''');
85+
/// var context = await pkg.addFile('''
86+
/// import 'package:meta/meta.dart';
87+
/// @visibleForTesting var foo = true;
88+
/// ''');
4789
/// var suggestor = MySuggestor();
4890
/// var expectedOutput = '...';
4991
/// expectSuggestorGeneratesPatches(suggestor, context, expectedOutput);
5092
/// });
51-
void expectSuggestorGeneratesPatches(
52-
Suggestor suggestor, FileContext context, dynamic resultMatcher) {
53-
expect(
54-
suggestor(context)
55-
.toList()
56-
.then((patches) => applyPatches(context.sourceFile, patches)),
57-
completion(resultMatcher));
93+
class PackageContextForTest {
94+
final AnalysisContextCollection _collection;
95+
final String _name;
96+
final String _root;
97+
static int _fileCounter = 0;
98+
static int _packageCounter = 0;
99+
100+
/// Creates a temporary directory named [dirName] using the `test_descriptor`
101+
/// package, installs dependencies with `dart pub get`, sets up an analysis
102+
/// context for the package, and returns a [PackageContextForTest] wrapper
103+
/// that allows you to add source files to the package and use them in tests.
104+
///
105+
/// If [dirName] is null, a unique name will be generated.
106+
///
107+
/// Throws an [ArgumentError] if it fails to install dependencies.
108+
static Future<PackageContextForTest> fromPubspec(
109+
String pubspecContents, [
110+
String? dirName,
111+
]) async {
112+
dirName ??= 'package_${_packageCounter++}';
113+
114+
await d.dir(dirName, [
115+
d.file('pubspec.yaml', pubspecContents),
116+
]).create();
117+
118+
final root = p.canonicalize(d.path(dirName));
119+
final pubGet =
120+
Process.runSync('dart', ['pub', 'get'], workingDirectory: root);
121+
if (pubGet.exitCode != 0) {
122+
printOnFailure('''
123+
PROCESS: dart pub get
124+
WORKING DIR: $root
125+
STDOUT:
126+
${pubGet.stdout}
127+
STDERR:
128+
${pubGet.stderr}
129+
''');
130+
throw ArgumentError('Failed to install dependencies from given pubspec');
131+
}
132+
final collection = AnalysisContextCollection(includedPaths: [root]);
133+
return PackageContextForTest._(dirName, root, collection);
134+
}
135+
136+
PackageContextForTest._(this._name, this._root, this._collection);
137+
138+
/// Creates a temporary file at the given [path] (relative to the root of this
139+
/// package) with the given [sourceText] using the `test_descriptor` package
140+
/// and returns a [FileContext] wrapper around it.
141+
///
142+
/// If [path] is null, a unique filename will be generated.
143+
///
144+
/// The returned [FileContext] will use the analysis context for this whole
145+
/// package rather than just this file, which enables testing of [Suggestor]s
146+
/// that require the resolved AST.
147+
///
148+
/// See [PackageContextForTest] for an example.
149+
Future<FileContext> addFile(String sourceText, [String? path]) async {
150+
path ??= 'test_${_fileCounter++}.dart';
151+
152+
// Use test_descriptor to create the file in a temporary directory
153+
d.Descriptor descriptor;
154+
final segments = p.split(path);
155+
// Last segment should be the file
156+
descriptor = d.file(segments.last, sourceText);
157+
// Any preceding segments (if any) are directories
158+
for (final dir in segments.reversed.skip(1)) {
159+
descriptor = d.dir(dir, [descriptor]);
160+
}
161+
// Add the root directory.
162+
descriptor = d.dir(_name, [descriptor]);
163+
164+
await descriptor.create();
165+
final canonicalizedPath = p.canonicalize(p.join(d.sandbox, _name, path));
166+
return FileContext(canonicalizedPath, _collection, root: _root);
167+
}
58168
}

test/ast_visiting_suggestor_test.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ class LibNameDoubler extends RecursiveAstVisitor<void>
4646
}
4747
}
4848

49+
class AlwaysThrowsFixer extends GeneralizingAstVisitor<void>
50+
with AstVisitingSuggestor {
51+
@override
52+
bool shouldResolveAst(_) => true;
53+
54+
@override
55+
void visitFunctionDeclaration(FunctionDeclaration node) {
56+
for (final annotation in node.metadata) {
57+
final isAlwaysThrows = annotation.name.name == 'alwaysThrows';
58+
final annotationPackage = annotation.element?.library?.identifier ?? '';
59+
final isFromPackageMeta = annotationPackage.startsWith('package:meta/');
60+
if (isAlwaysThrows && isFromPackageMeta) {
61+
yieldPatch('Never', annotation.offset, annotation.end);
62+
}
63+
}
64+
}
65+
}
66+
4967
void main() {
5068
group('AstVisitingSuggestor', () {
5169
test('should get compilation unit, visit it, and yield patches', () async {
@@ -99,5 +117,26 @@ void main() {
99117
expect(await patchesA.toList(), [Patch('aa', 8, 9)]);
100118
expect(await patchesC.toList(), [Patch('cc', 8, 9)]);
101119
});
120+
121+
test('should resolve AST and work with imports', () async {
122+
final pkg = await PackageContextForTest.fromPubspec('''
123+
name: pkg
124+
publish_to: none
125+
environment:
126+
sdk: '>=2.19.0 <4.0.0'
127+
dependencies:
128+
meta: ^1.0.0
129+
''');
130+
final context = await pkg.addFile('''
131+
import 'package:meta/meta.dart';
132+
@alwaysThrows toss() { throw 'Thrown'; }
133+
''');
134+
final expectedOutput = '''
135+
import 'package:meta/meta.dart';
136+
Never toss() { throw 'Thrown'; }
137+
''';
138+
expectSuggestorGeneratesPatches(
139+
AlwaysThrowsFixer(), context, expectedOutput);
140+
});
102141
});
103142
}

0 commit comments

Comments
 (0)