diff --git a/.gitignore b/.gitignore index bdb7f50..b0f829a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ doc/api/ .failed_tracker custom_lint.log logs/analyzer.txt +.mcp_logs/ diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 3e70962..495b2e0 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -155,12 +155,28 @@ class Document { for (final line in _lines) { /// wait until we see a line that is after the parent. if (line.lineNo > parent.lineNo) { + final isCommentOrBlank = line.lineType == LineType.comment || + line.lineType == LineType.blank; + /// If the ident decreases then we have passed all /// of the parent's children. - if (line.indent <= parent.indent) { + if (!isCommentOrBlank && line.indent <= parent.indent) { break; } + // Comments/blanks that are not indented as children don't belong to + // this section, but they should not terminate child scanning. + if (isCommentOrBlank && line.indent <= parent.indent) { + continue; + } + + // Descendant traversal is used to compose section line ownership. + // Comments/blanks are owned via the Comments model, so including them + // here can cause a line to belong to multiple sections. + if (descendants && isCommentOrBlank) { + continue; + } + /// filter out children that don't match the key [type] if (type != null && line.lineType != type) { continue; diff --git a/lib/src/pubspec/internal_parts.dart b/lib/src/pubspec/internal_parts.dart index c4f11e8..b832e90 100644 --- a/lib/src/pubspec/internal_parts.dart +++ b/lib/src/pubspec/internal_parts.dart @@ -45,6 +45,7 @@ part 'executable.dart'; part 'executable_builder.dart'; part 'executables.dart'; part 'homepage.dart'; +part 'screenshots.dart'; part 'issue_tracker.dart'; part 'name.dart'; part 'platform_support.dart'; @@ -52,6 +53,7 @@ part 'platforms.dart'; part 'publish_to.dart'; part 'pubspec.dart'; part 'repository.dart'; +part 'string_list.dart'; part 'version.dart'; part 'version_constraint.dart'; part 'version_constraint_builder.dart'; diff --git a/lib/src/pubspec/pubspec.dart b/lib/src/pubspec/pubspec.dart index 7bbfbc6..177a4fb 100644 --- a/lib/src/pubspec/pubspec.dart +++ b/lib/src/pubspec/pubspec.dart @@ -43,6 +43,21 @@ class PubSpec { /// Url of the documentation for the package. late final Documentation documentation; + /// List of URLs where users can support the package maintainers. + late final StringListSection funding; + + /// List of false-positive paths for accidental secret scanning. + late final StringListSection falseSecrets; + + /// List of package topics. + late final StringListSection topics; + + /// List of advisory IDs that are intentionally ignored. + late final StringListSection ignoredAdvisories; + + /// Screenshots shown on pub.dev. + late final Screenshots screenshots; + /// List of dependencies for the package. late final Dependencies dependencies; @@ -88,15 +103,16 @@ class PubSpec { repository = Repository.missing(document); issueTracker = IssueTracker._missing(document); documentation = Documentation._missing(document); + funding = StringListSection._missing(this, 'funding'); + falseSecrets = StringListSection._missing(this, 'false_secrets'); + topics = StringListSection._missing(this, 'topics'); + ignoredAdvisories = StringListSection._missing(this, 'ignored_advisories'); + screenshots = Screenshots._missing(this); dependencies = Dependencies._missing(this, 'dependencies'); devDependencies = Dependencies._missing(this, 'dev_dependencies'); dependencyOverrides = Dependencies._missing(this, 'dependency_overrides'); platforms = Platforms._missing(this); executables = Executables._missing(this); - // funding = SectionImpl.missing(document, 'funding'); - // falseSecrets = SectionImpl.missing(document, 'false_secrets'); - // screenshots = SectionImpl.missing(document, 'screenshots'); - // topics = SectionImpl.missing(document, 'topics'); } /// Loads the content of a pubspec.yaml from [content]. @@ -116,6 +132,11 @@ class PubSpec { repository = Repository._fromDocument(document); issueTracker = IssueTracker._fromDocument(document); documentation = Documentation._fromDocument(document); + funding = _initStringList('funding'); + falseSecrets = _initStringList('false_secrets'); + topics = _initStringList('topics'); + ignoredAdvisories = _initStringList('ignored_advisories'); + screenshots = _initScreenshots(); dependencies = _initDependencies('dependencies'); devDependencies = _initDependencies('dev_dependencies'); @@ -123,14 +144,26 @@ class PubSpec { platforms = _initPlatforms(); executables = _initExecutables(); - // funding = document.findSectionForKey('funding'); - // falseSecrets = document.findSectionForKey('falseSecrets'); - // screenshots = document.findSectionForKey('screenshots'); - // topics = document.findSectionForKey('topics'); _validate(); } + StringListSection _initStringList(String key) { + final line = document.findTopLevelKey(key); + if (line.missing) { + return StringListSection._missing(this, key); + } + return StringListSection._fromLine(this, line); + } + + Screenshots _initScreenshots() { + final line = document.findTopLevelKey(Screenshots.keyName); + if (line.missing) { + return Screenshots._missing(this); + } + return Screenshots._fromLine(this, line); + } + /// Loads the pubspec.yaml file from the given [directory] or /// the current work directory if [directory] is not passed. /// @@ -284,15 +317,16 @@ class PubSpec { ..render(repository) ..render(issueTracker) ..render(documentation) + ..render(funding) + ..render(falseSecrets) + ..render(topics) + ..render(ignoredAdvisories) + ..render(screenshots) ..render(dependencies._section) ..render(devDependencies._section) ..render(dependencyOverrides._section) ..render(executables) ..render(platforms._section) - // ..render(funding) - // ..render(falseSecrets) - // ..render(screenshots) - // ..render(topics) ..renderMissing() ..write(writer); } diff --git a/lib/src/pubspec/screenshots.dart b/lib/src/pubspec/screenshots.dart new file mode 100644 index 0000000..fd59d21 --- /dev/null +++ b/lib/src/pubspec/screenshots.dart @@ -0,0 +1,181 @@ +part of 'internal_parts.dart'; + +class Screenshots implements Section { + static const keyName = 'screenshots'; + + SectionImpl _section; + // We pass pubpsec to every element for consistency + // ignore: unused_field + final PubSpec _pubspec; + final _screenshots = []; + + Screenshots._missing(this._pubspec) + : _section = SectionImpl.missing(_pubspec.document, keyName); + + Screenshots._fromLine(this._pubspec, LineImpl line) + : _section = SectionImpl.fromLine(line) { + for (final item in line.childrenOf(type: LineType.indexed)) { + _screenshots.add(Screenshot._fromIndexedLine(item)); + } + } + + List get list => List.unmodifiable(_screenshots); + + int get length => _screenshots.length; + + Screenshots add({required String description, required String path}) { + _ensureSectionExists(); + var lineBefore = _section.headerLine; + if (_screenshots.isNotEmpty) { + lineBefore = _screenshots.last._lines.last; + } + + final descriptionLine = LineImpl.forInsertion(_section.document, + '${_section.headerLine.childIndent}- description: $description'); + lineBefore = _section.document.insertAfter(descriptionLine, lineBefore); + final pathLine = LineImpl.forInsertion( + _section.document, '${descriptionLine.childIndent}path: $path'); + lineBefore = _section.document.insertAfter(pathLine, lineBefore); + + _screenshots.add(Screenshot._fromAttachedLines(descriptionLine, pathLine)); + + return this; + } + + void removeAt(int index) { + if (index < 0 || index >= _screenshots.length) { + throw RangeError.range(index, 0, _screenshots.length - 1); + } + final screenshot = _screenshots.removeAt(index); + for (final line in screenshot._lines) { + _section._removeChild(line); + } + } + + void removeAll() { + for (final screenshot in _screenshots.reversed) { + for (final line in screenshot._lines.reversed) { + _section._removeChild(line); + } + } + _screenshots.clear(); + } + + void _ensureSectionExists() { + if (_section.missing) { + final headerLine = _section.document.append(LineDetached('$keyName:')); + _section = SectionImpl.fromLine(headerLine); + } + } + + @override + Comments get comments => _section.comments; + + @override + List get lines => _section.lines; + + @override + bool get missing => _section.missing; +} + +class Screenshot { + String _description; + String _path; + final List _lines; + + Screenshot._(this._description, this._path, this._lines); + + factory Screenshot._fromAttachedLines( + LineImpl descriptionLine, LineImpl pathLine) => + Screenshot._(_readValue(descriptionLine, requiredKey: 'description'), + pathLine.value, [descriptionLine, pathLine]); + + factory Screenshot._fromIndexedLine(LineImpl indexedLine) { + String? description; + String? path; + final lines = _collectEntryLines(indexedLine); + + final inline = _extractInlineKeyValue(indexedLine); + if (inline != null) { + switch (inline.key) { + case 'description': + description = inline.value; + case 'path': + path = inline.value; + } + } + + for (final child in indexedLine.childrenOf(type: LineType.key)) { + switch (child.key) { + case 'description': + description = child.value; + case 'path': + path = child.value; + } + } + + if (description == null || path == null) { + throw PubSpecException(indexedLine, + 'Each screenshot entry must contain both description and path.'); + } + + return Screenshot._(description, path, lines); + } + + String get description => _description; + + String get path => _path; + + static KeyValue? _extractInlineKeyValue(LineImpl indexedLine) { + final trimmed = indexedLine.text.trimLeft(); + if (!trimmed.startsWith('-')) { + return null; + } + final inlineValue = trimmed.substring(1).trimLeft(); + if (!inlineValue.contains(':')) { + return null; + } + return KeyValue.fromText(inlineValue); + } + + static String _readValue(LineImpl line, {required String requiredKey}) { + final trimmed = line.text.trimLeft(); + if (!trimmed.startsWith('-')) { + throw PubSpecException( + line, 'Invalid screenshot entry, expected "-" prefix.'); + } + final keyValue = KeyValue.fromText(trimmed.substring(1).trimLeft()); + if (keyValue.key != requiredKey) { + throw PubSpecException( + line, 'Invalid screenshot entry, expected key: $requiredKey.'); + } + return keyValue.value; + } + + static List _collectEntryLines(LineImpl indexedLine) { + final lines = [indexedLine]; + final document = indexedLine._document; + + for (final line in document._lines) { + if (line.lineNo <= indexedLine.lineNo) { + continue; + } + + final isCommentOrBlank = + line.lineType == LineType.comment || line.lineType == LineType.blank; + + if (!isCommentOrBlank && line.indent <= indexedLine.indent) { + break; + } + + // Misindented comments/blanks should not be treated as children. + if (isCommentOrBlank && line.indent <= indexedLine.indent) { + continue; + } + + lines.add(line); + } + + return lines; + } +} diff --git a/lib/src/pubspec/string_list.dart b/lib/src/pubspec/string_list.dart new file mode 100644 index 0000000..7aa873f --- /dev/null +++ b/lib/src/pubspec/string_list.dart @@ -0,0 +1,135 @@ +part of 'internal_parts.dart'; + +class StringListSection implements Section { + SectionImpl _section; + // We pass pubpsec to every element for consistency + // ignore: unused_field + final PubSpec _pubspec; + final String keyName; + final _entries = <_StringListEntry>[]; + + StringListSection._missing(this._pubspec, this.keyName) + : _section = SectionImpl.missing(_pubspec.document, keyName); + + StringListSection._fromLine(this._pubspec, LineImpl line) + : keyName = line.key, + _section = SectionImpl.fromLine(line) { + for (final child in line.childrenOf(type: LineType.indexed)) { + _entries.add( + _StringListEntry(_parseIndexedValue(child), _collectEntryLines(child))); + } + } + + List get list => + List.unmodifiable(_entries.map((entry) => entry.value)); + + int get length => _entries.length; + + bool exists(String value) => + _entries.any((entry) => entry.value.trim() == value.trim()); + + StringListSection add(String value) { + _ensureSectionExists(); + final text = Strings.isBlank(value) + ? '${_section.headerLine.childIndent}-' + : '${_section.headerLine.childIndent}- $value'; + final line = LineImpl.forInsertion(_section.document, text); + final lineBefore = + _entries.isEmpty ? _section.headerLine : _entries.last.lines.last; + _section.document.insertAfter(line, lineBefore); + _entries.add(_StringListEntry(value, [line])); + return this; + } + + StringListSection addAll(List values) { + for (final value in values) { + add(value); + } + return this; + } + + void remove(String value) { + final index = + _entries.indexWhere((entry) => entry.value.trim() == value.trim()); + if (index == -1) { + throw NotFoundException('$value not found in the $keyName section'); + } + removeAt(index); + } + + void removeAt(int index) { + if (index < 0 || index >= _entries.length) { + throw RangeError.range(index, 0, _entries.length - 1); + } + final removed = _entries.removeAt(index); + for (final line in removed.lines.reversed) { + _section._removeChild(line); + } + } + + void removeAll() { + for (final entry in _entries.reversed) { + for (final line in entry.lines.reversed) { + _section._removeChild(line); + } + } + _entries.clear(); + } + + static String _parseIndexedValue(LineImpl line) { + final trimmed = line.text.trimLeft(); + if (!trimmed.startsWith('-')) { + throw PubSpecException(line, 'Expected a list entry starting with "-"'); + } + return trimmed.substring(1).trimLeft(); + } + + static List _collectEntryLines(LineImpl indexedLine) { + final lines = [indexedLine]; + final document = indexedLine._document; + + for (final line in document._lines) { + if (line.lineNo <= indexedLine.lineNo) { + continue; + } + + final isCommentOrBlank = + line.lineType == LineType.comment || line.lineType == LineType.blank; + + if (!isCommentOrBlank && line.indent <= indexedLine.indent) { + break; + } + + if (isCommentOrBlank && line.indent <= indexedLine.indent) { + continue; + } + + lines.add(line); + } + + return lines; + } + + void _ensureSectionExists() { + if (_section.missing) { + final headerLine = _section.document.append(LineDetached('$keyName:')); + _section = SectionImpl.fromLine(headerLine); + } + } + + @override + Comments get comments => _section.comments; + + @override + List get lines => _section.lines; + + @override + bool get missing => _section.missing; +} + +class _StringListEntry { + final String value; + final List lines; + + _StringListEntry(this.value, this.lines); +} diff --git a/test/src/pubspec/additional_metadata_keys_test.dart b/test/src/pubspec/additional_metadata_keys_test.dart new file mode 100644 index 0000000..074ab0f --- /dev/null +++ b/test/src/pubspec/additional_metadata_keys_test.dart @@ -0,0 +1,216 @@ +import 'package:pubspec_manager/pubspec_manager.dart'; +import 'package:test/test.dart'; + +import '../util/read_file.dart'; +import '../util/with_temp_file.dart'; + +void main() { + test('load additional documented metadata keys', () { + const content = ''' +name: sample +funding: + - https://example.com/sponsor +false_secrets: + - /test/fixtures/not_a_secret.txt +topics: + - tooling +ignored_advisories: + - GHSA-4rgh-jx4f-qfcq +screenshots: + - description: Home screen + path: assets/home.png +'''; + + final pubspec = PubSpec.loadFromString(content); + expect(pubspec.funding.list, equals(['https://example.com/sponsor'])); + expect( + pubspec.falseSecrets.list, equals(['/test/fixtures/not_a_secret.txt'])); + expect(pubspec.topics.list, equals(['tooling'])); + expect(pubspec.ignoredAdvisories.list, equals(['GHSA-4rgh-jx4f-qfcq'])); + expect(pubspec.screenshots.length, equals(1)); + expect(pubspec.screenshots.list.first.description, equals('Home screen')); + expect(pubspec.screenshots.list.first.path, equals('assets/home.png')); + }); + + test('add and persist additional documented metadata keys', () async { + final pubspec = PubSpec(name: 'sample') + ..funding.add('https://example.com/sponsor') + ..falseSecrets.add('/test/fixtures/not_a_secret.txt') + ..topics.add('tooling') + ..ignoredAdvisories.add('GHSA-4rgh-jx4f-qfcq') + ..screenshots.add(description: 'Home screen', path: 'assets/home.png'); + + await withTempFile((tempFile) async { + pubspec.saveTo(tempFile); + + final saved = readFile(tempFile); + expect(saved, contains('funding:')); + expect(saved, contains('false_secrets:')); + expect(saved, contains('topics:')); + expect(saved, contains('ignored_advisories:')); + expect(saved, contains('screenshots:')); + + final reloaded = PubSpec.loadFromPath(tempFile); + expect(reloaded.funding.list, equals(['https://example.com/sponsor'])); + expect(reloaded.falseSecrets.list, + equals(['/test/fixtures/not_a_secret.txt'])); + expect(reloaded.topics.list, equals(['tooling'])); + expect(reloaded.ignoredAdvisories.list, equals(['GHSA-4rgh-jx4f-qfcq'])); + expect(reloaded.screenshots.length, equals(1)); + expect( + reloaded.screenshots.list.first.description, equals('Home screen')); + expect(reloaded.screenshots.list.first.path, equals('assets/home.png')); + }); + }); + + test('exists semantics for new list keys', () { + final pubspec = PubSpec(name: 'sample') + ..funding.add('https://example.com/sponsor') + ..falseSecrets.add('/test/fixtures/not_a_secret.txt') + ..topics.add('tooling') + ..ignoredAdvisories.add('GHSA-4rgh-jx4f-qfcq'); + + expect(pubspec.funding.exists('https://example.com/sponsor'), isTrue); + expect(pubspec.funding.exists('https://example.com/other'), isFalse); + + expect(pubspec.falseSecrets.exists('/test/fixtures/not_a_secret.txt'), + isTrue); + expect(pubspec.falseSecrets.exists('/tmp/other.txt'), isFalse); + + expect(pubspec.topics.exists('tooling'), isTrue); + expect(pubspec.topics.exists('backend'), isFalse); + + expect(pubspec.ignoredAdvisories.exists('GHSA-4rgh-jx4f-qfcq'), isTrue); + expect(pubspec.ignoredAdvisories.exists('GHSA-missing-id'), isFalse); + }); + + test('new keys with no values load and round-trip', () async { + const content = ''' +name: sample +funding: +false_secrets: +topics: +ignored_advisories: +screenshots: +'''; + + final pubspec = PubSpec.loadFromString(content); + expect(pubspec.funding.missing, isFalse); + expect(pubspec.falseSecrets.missing, isFalse); + expect(pubspec.topics.missing, isFalse); + expect(pubspec.ignoredAdvisories.missing, isFalse); + expect(pubspec.screenshots.missing, isFalse); + + expect(pubspec.funding.list, isEmpty); + expect(pubspec.falseSecrets.list, isEmpty); + expect(pubspec.topics.list, isEmpty); + expect(pubspec.ignoredAdvisories.list, isEmpty); + expect(pubspec.screenshots.list, isEmpty); + + await withTempFile((tempFile) async { + pubspec.saveTo(tempFile); + expect(readFile(tempFile), equals(content)); + }); + }); + + test('new keys allow empty list item values', () async { + const content = ''' +name: sample +funding: + - +screenshots: + - description: + path: +'''; + + final pubspec = PubSpec.loadFromString(content); + expect(pubspec.funding.list, equals([''])); + expect(pubspec.screenshots.length, equals(1)); + expect(pubspec.screenshots.list.first.description, equals('')); + expect(pubspec.screenshots.list.first.path, equals('')); + + final generated = PubSpec(name: 'sample') + ..funding.add('') + ..screenshots.add(description: '', path: ''); + + await withTempFile((tempFile) async { + generated.saveTo(tempFile); + const generatedContent = ''' +name: sample +funding: + - +screenshots: + - description: + path: +'''; + expect(readFile(tempFile), equals(generatedContent)); + }); + }); + + test('existing single-line key with no value is preserved', () async { + const content = ''' +name: sample +homepage: +'''; + + final pubspec = PubSpec.loadFromString(content); + expect(pubspec.homepage.value, isEmpty); + expect(pubspec.homepage.missing, isFalse); + + await withTempFile((tempFile) async { + pubspec.saveTo(tempFile); + expect(readFile(tempFile), equals(content)); + }); + }); + + test('removing screenshot removes descendant lines', () async { + const content = ''' +name: sample +screenshots: + - description: Home screen + path: assets/home.png + # screenshot comment + extra: custom +'''; + + final pubspec = PubSpec.loadFromString(content); + expect(pubspec.screenshots.length, equals(1)); + + pubspec.screenshots.removeAt(0); + expect(pubspec.screenshots.list, isEmpty); + + await withTempFile((tempFile) async { + pubspec.saveTo(tempFile); + const expected = ''' +name: sample +screenshots: +'''; + expect(readFile(tempFile), equals(expected)); + }); + }); + + test('removing list entry removes descendant lines for list keys', () async { + const content = ''' +name: sample +funding: + - https://example.com/sponsor + # funding comment + extra: custom +'''; + + final pubspec = PubSpec.loadFromString(content); + expect(pubspec.funding.length, equals(1)); + + pubspec.funding.removeAt(0); + expect(pubspec.funding.list, isEmpty); + + await withTempFile((tempFile) async { + pubspec.saveTo(tempFile); + const expected = ''' +name: sample +funding: +'''; + expect(readFile(tempFile), equals(expected)); + }); + }); +} diff --git a/test/src/pubspec/comments_test.dart b/test/src/pubspec/comments_test.dart index 6c5b828..c69d342 100644 --- a/test/src/pubspec/comments_test.dart +++ b/test/src/pubspec/comments_test.dart @@ -1,6 +1,10 @@ +import 'dart:io' as io; + import 'package:pubspec_manager/pubspec_manager.dart'; import 'package:test/test.dart'; +import '../util/with_temp_file.dart'; + const content = ''' name: pubspec3 version: 0.0.1 @@ -17,7 +21,7 @@ dev_dependencies: '''; void main() { - test('dependency append', () { + test('dependency append', () { const version = '1.5.1'; final pubspec = PubSpec.loadFromString(content); final devDependencies = pubspec.devDependencies @@ -43,14 +47,14 @@ void main() { expect(pubspec.document.lines.length, equals(15)); }); - test('dependency remove last', () { + test('dependency remove last', () { final pubspec = PubSpec.loadFromString(content); final dependencies = pubspec.dependencies..remove('money'); final dcli = dependencies['money']; expect(dcli == null, isTrue); }); - test('dependency remove first', () { + test('dependency remove first', () { final pubspec = PubSpec.loadFromString(content); final dependencies = pubspec.dependencies..remove('dcli'); final dcli = dependencies['dcli']; @@ -58,7 +62,7 @@ void main() { print(pubspec); }); - test('dependency add comment', () { + test('dependency add comment', () { final pubspec = PubSpec.loadFromString(content); final dependencies = pubspec.dependencies; final dcli = dependencies['dcli']; @@ -67,7 +71,7 @@ void main() { print(pubspec); }); - test('dependency removeAll comments', () { + test('dependency removeAll comments', () { final pubspec = PubSpec.loadFromString(content); final document = pubspec.document; expect(document.lines.length, equals(13)); @@ -79,7 +83,7 @@ void main() { expect(document.lines.length, equals(12)); }); - test('dependency removeAt comments', () { + test('dependency removeAt comments', () { final pubspec = PubSpec.loadFromString(content); final document = pubspec.document; expect(document.lines.length, equals(13)); @@ -93,7 +97,7 @@ void main() { expect(document.lines.length, equals(12)); }); - test('dependency removeAt invalid ', () { + test('dependency removeAt invalid ', () { final pubspec = PubSpec.loadFromString(content); final dependencies = pubspec.dependencies; final dcli = dependencies['dcli']; @@ -101,7 +105,7 @@ void main() { dcli!.comments.removeAt(0); expect(() => dcli.comments.removeAt(0), throwsA(isA())); }); - test('dependency removeAll empty list ', () { + test('dependency removeAll empty list ', () { final pubspec = PubSpec.loadFromString(content); final document = pubspec.document; @@ -137,4 +141,42 @@ dependencies: '''; PubSpec.loadFromString(pubspec); }); + + test('misindented comment attaches to subsequent key', () { + const pubspec = ''' +name: pubspec3 +dependencies: + dcli: +# comment should attach to path despite indent level + path: ../dcli +'''; + + final loaded = PubSpec.loadFromString(pubspec); + final dep = loaded.dependencies['dcli']; + expect(dep, isA()); + + final pathSection = loaded.document.findSectionForKey('path'); + expect(pathSection.comments.length, equals(1)); + expect(pathSection.lines.first.lineType, equals(LineType.comment)); + expect(pathSection.lines.first.text, + equals('# comment should attach to path despite indent level')); + }); + + test('misindented comment is preserved when writing yaml', () async { + const pubspec = ''' +name: pubspec3 +dependencies: + dcli: +# comment should attach to path despite indent level + path: ../dcli +'''; + + final loaded = PubSpec.loadFromString(pubspec); + + await withTempFile((tempFile) async { + loaded.saveTo(tempFile); + final written = await io.File(tempFile).readAsString(); + expect(written, equals(pubspec)); + }); + }); }