bookmark feature rewrite
This commit is contained in:
@@ -86,7 +86,9 @@ void main() {
|
||||
|
||||
test('is producible even for an ambiguous resolution', () {
|
||||
// neverOpen never launches, so it does not need a bound package.
|
||||
final rule = neverOpenRuleFor(_target(isAmbiguous: true, packageName: null));
|
||||
final rule = neverOpenRuleFor(
|
||||
_target(isAmbiguous: true, packageName: null),
|
||||
);
|
||||
expect(rule.decision, AppLinkRuleDecision.neverOpen);
|
||||
expect(rule.isValid, isTrue);
|
||||
});
|
||||
|
||||
@@ -80,15 +80,15 @@ void main() {
|
||||
},
|
||||
});
|
||||
expect(parsed.length, 2);
|
||||
expect(parsed['host:youtube.com']!.decision, AppLinkRuleDecision.alwaysOpen);
|
||||
expect(
|
||||
parsed['host:youtube.com']!.decision,
|
||||
AppLinkRuleDecision.alwaysOpen,
|
||||
);
|
||||
});
|
||||
|
||||
test('drops entries whose map key disagrees with the rule scope', () {
|
||||
final parsed = parseAppLinkRules({
|
||||
'host:wrong.com': {
|
||||
'decision': 'neverOpen',
|
||||
'scope': 'host:right.com',
|
||||
},
|
||||
'host:wrong.com': {'decision': 'neverOpen', 'scope': 'host:right.com'},
|
||||
});
|
||||
expect(parsed, isEmpty);
|
||||
});
|
||||
|
||||
@@ -23,11 +23,8 @@ import 'package:weblibre/features/app_links/domain/services/effective_routing.da
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
|
||||
SiteAssignment _assignment(String site, {String? contextId}) => SiteAssignment(
|
||||
id: site,
|
||||
contextualIdentity: contextId,
|
||||
assignedSite: site,
|
||||
);
|
||||
SiteAssignment _assignment(String site, {String? contextId}) =>
|
||||
SiteAssignment(id: site, contextualIdentity: contextId, assignedSite: site);
|
||||
|
||||
void main() {
|
||||
group('resolveContainerAssignment', () {
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
|
||||
|
||||
BookmarkEntry entry(String guid, String title) => BookmarkEntry(
|
||||
guid: guid,
|
||||
parentGuid: BookmarkRoot.menu.id,
|
||||
url: Uri.parse('https://example.com/$guid'),
|
||||
title: title,
|
||||
previewImageUrl: Uri.parse('https://example.com/$guid'),
|
||||
position: 0,
|
||||
dateAdded: 0,
|
||||
);
|
||||
|
||||
BookmarkFolder folder(String guid, String title, {List<BookmarkItem>? kids}) =>
|
||||
BookmarkFolder(
|
||||
guid: guid,
|
||||
parentGuid: BookmarkRoot.root.id,
|
||||
title: title,
|
||||
position: 0,
|
||||
dateAdded: 0,
|
||||
children: kids,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('sortBookmarkChildren', () {
|
||||
test('should leave order untouched for manual sorting', () {
|
||||
final children = [entry('b', 'Beta'), entry('a', 'Alpha')];
|
||||
|
||||
final sorted = sortBookmarkChildren(children, BookmarkSortType.manual);
|
||||
|
||||
expect(sorted.map((c) => c.guid), equals(['b', 'a']));
|
||||
});
|
||||
|
||||
test('should sort a plain level by title', () {
|
||||
final children = [
|
||||
entry('c', 'Charlie'),
|
||||
entry('a', 'Alpha'),
|
||||
entry('b', 'Bravo'),
|
||||
];
|
||||
|
||||
final sorted = sortBookmarkChildren(children, BookmarkSortType.titleAsc);
|
||||
|
||||
expect(sorted.map((c) => c.title), equals(['Alpha', 'Bravo', 'Charlie']));
|
||||
});
|
||||
|
||||
test('should not mutate the list it was given', () {
|
||||
final children = [entry('c', 'Charlie'), entry('a', 'Alpha')];
|
||||
|
||||
sortBookmarkChildren(children, BookmarkSortType.titleAsc);
|
||||
|
||||
expect(children.map((c) => c.guid), equals(['c', 'a']));
|
||||
});
|
||||
|
||||
test('should keep built-in roots pinned ahead of the rest at root', () {
|
||||
final children = <BookmarkItem>[
|
||||
entry('z', 'Zulu'),
|
||||
folder(BookmarkRoot.mobile.id, 'WebLibre'),
|
||||
entry('a', 'Alpha'),
|
||||
folder(BookmarkRoot.menu.id, 'Menu'),
|
||||
];
|
||||
|
||||
final sorted = sortBookmarkChildren(
|
||||
children,
|
||||
BookmarkSortType.titleAsc,
|
||||
isRoot: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
sorted.map((c) => c.guid),
|
||||
equals([BookmarkRoot.mobile.id, BookmarkRoot.menu.id, 'a', 'z']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveSelectedItems', () {
|
||||
test('should return the children whose guids are selected', () {
|
||||
final children = [entry('a', 'Alpha'), entry('b', 'Bravo')];
|
||||
|
||||
final selected = resolveSelectedItems(children, {'b'});
|
||||
|
||||
expect(selected.single.guid, equals('b'));
|
||||
});
|
||||
|
||||
test('should ignore guids that are not in the given list', () {
|
||||
final children = [entry('a', 'Alpha')];
|
||||
|
||||
expect(resolveSelectedItems(children, {'somewhere-else'}), isEmpty);
|
||||
});
|
||||
|
||||
test('should resolve items that live in other folders', () {
|
||||
// Search results come from anywhere in the library, so the list handed to
|
||||
// this function is not always one folder's children. Callers must pass
|
||||
// whatever is actually on screen — resolving against the current folder
|
||||
// instead would silently drop every result from a subfolder.
|
||||
final results = [
|
||||
entry('a', 'Alpha').copyWith(parentGuid: 'folder_one__'),
|
||||
entry('b', 'Bravo').copyWith(parentGuid: 'folder_two__'),
|
||||
];
|
||||
|
||||
final selected = resolveSelectedItems(results, {'a', 'b'});
|
||||
|
||||
expect(selected.map((item) => item.guid), equals(['a', 'b']));
|
||||
});
|
||||
});
|
||||
|
||||
group('normalizeSelection', () {
|
||||
test('should keep selections that are siblings', () {
|
||||
final rows = [
|
||||
BookmarkRow(entry('a', 'Alpha'), 0),
|
||||
BookmarkRow(entry('b', 'Bravo'), 0),
|
||||
];
|
||||
|
||||
expect(normalizeSelection(rows, {'a', 'b'}), equals({'a', 'b'}));
|
||||
});
|
||||
|
||||
test('should drop children of a selected folder', () {
|
||||
final rows = [
|
||||
BookmarkRow(folder('f1__________', 'Folder'), 0),
|
||||
BookmarkRow(entry('child_______', 'Child'), 1),
|
||||
BookmarkRow(entry('after_______', 'After'), 0),
|
||||
];
|
||||
|
||||
final normalized = normalizeSelection(rows, {
|
||||
'f1__________',
|
||||
'child_______',
|
||||
'after_______',
|
||||
});
|
||||
|
||||
expect(normalized, equals({'f1__________', 'after_______'}));
|
||||
});
|
||||
|
||||
test('should drop the whole subtree under a selected folder', () {
|
||||
final rows = [
|
||||
BookmarkRow(folder('outer_______', 'Outer'), 0),
|
||||
BookmarkRow(folder('inner_______', 'Inner'), 1),
|
||||
BookmarkRow(entry('deep________', 'Deep'), 2),
|
||||
BookmarkRow(entry('sibling_____', 'Sibling'), 0),
|
||||
];
|
||||
|
||||
final normalized = normalizeSelection(rows, {
|
||||
'outer_______',
|
||||
'inner_______',
|
||||
'deep________',
|
||||
'sibling_____',
|
||||
});
|
||||
|
||||
expect(normalized, equals({'outer_______', 'sibling_____'}));
|
||||
});
|
||||
|
||||
test('should keep a nested selection when its parent is not selected', () {
|
||||
final rows = [
|
||||
BookmarkRow(folder('f1__________', 'Folder'), 0),
|
||||
BookmarkRow(entry('child_______', 'Child'), 1),
|
||||
];
|
||||
|
||||
expect(
|
||||
normalizeSelection(rows, {'child_______'}),
|
||||
equals({'child_______'}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not count a loading placeholder as a second occurrence', () {
|
||||
// While an expanded folder loads, a placeholder row repeats it one level
|
||||
// down. Treating that as a real row would resolve the folder twice and
|
||||
// apply the same move or delete to it twice over.
|
||||
final selectedFolder = folder('f1__________', 'Folder');
|
||||
final rows = [
|
||||
BookmarkRow(selectedFolder, 0),
|
||||
BookmarkRow(selectedFolder, 1, isPlaceholder: true),
|
||||
];
|
||||
|
||||
final normalized = normalizeSelection(rows, {'f1__________'});
|
||||
|
||||
expect(normalized, equals({'f1__________'}));
|
||||
});
|
||||
|
||||
test('should resume after leaving a selected folder subtree', () {
|
||||
final rows = [
|
||||
BookmarkRow(folder('f1__________', 'One'), 0),
|
||||
BookmarkRow(entry('inside______', 'Inside'), 1),
|
||||
BookmarkRow(folder('f2__________', 'Two'), 0),
|
||||
BookmarkRow(entry('later_______', 'Later'), 1),
|
||||
];
|
||||
|
||||
final normalized = normalizeSelection(rows, {
|
||||
'f1__________',
|
||||
'inside______',
|
||||
'later_______',
|
||||
});
|
||||
|
||||
expect(normalized, equals({'f1__________', 'later_______'}));
|
||||
});
|
||||
});
|
||||
|
||||
group('canFlattenFolder', () {
|
||||
test('should reject built-in roots', () {
|
||||
expect(canFlattenFolder(folder(BookmarkRoot.menu.id, 'Menu')), isFalse);
|
||||
});
|
||||
|
||||
test('should accept an ordinary folder even before its children load', () {
|
||||
// The list only loads one level, so a folder shown in it has no children
|
||||
// attached; emptiness is decided by the repository at operation time.
|
||||
expect(canFlattenFolder(folder('normal______', 'Normal')), isTrue);
|
||||
});
|
||||
|
||||
test('should reject a folder without a parent', () {
|
||||
final orphan = BookmarkFolder(
|
||||
guid: 'orphan______',
|
||||
parentGuid: null,
|
||||
title: 'Orphan',
|
||||
position: 0,
|
||||
dateAdded: 0,
|
||||
children: null,
|
||||
);
|
||||
|
||||
expect(canFlattenFolder(orphan), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
+421
-181
@@ -24,11 +24,26 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
|
||||
|
||||
@GenerateMocks([GeckoBookmarksService])
|
||||
import 'bookmark_html_utils_test.mocks.dart';
|
||||
|
||||
/// Nodes the parser routed to [root], or an empty list if it produced no such
|
||||
/// section.
|
||||
List<ImportBookmarkNode> section(ImportBookmarkTree tree, BookmarkRoot root) =>
|
||||
tree.sections[root.id] ?? const [];
|
||||
|
||||
/// Mirrors what the native side reports back: bookmark items only, recursively.
|
||||
int countItems(List<BookmarkImportNode> nodes) => nodes.fold(
|
||||
0,
|
||||
(total, node) =>
|
||||
total +
|
||||
(node.type == BookmarkNodeType.item ? 1 : 0) +
|
||||
countItems(node.children),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late MockGeckoBookmarksService mockService;
|
||||
late BookmarkHTMLUtils utils;
|
||||
@@ -36,72 +51,20 @@ void main() {
|
||||
setUp(() {
|
||||
mockService = MockGeckoBookmarksService();
|
||||
utils = BookmarkHTMLUtils(mockService);
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(mockService.insertTree(any, any)).thenAnswer(
|
||||
(invocation) async => BookmarkInsertTreeResult(
|
||||
insertedItemCount: countItems(
|
||||
invocation.positionalArguments[1] as List<BookmarkImportNode>,
|
||||
),
|
||||
failedNodeCount: 0,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
group('BookmarkHTMLUtils - Import', () {
|
||||
test('should handle corrupt HTML file with malformed URIs', () async {
|
||||
// Load the corrupt fixture
|
||||
final fixtureFile = File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.corrupt.html',
|
||||
);
|
||||
final htmlString = await fixtureFile.readAsString();
|
||||
|
||||
// Mock the service calls
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'generated_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'generated_guid');
|
||||
|
||||
final count = await utils.importFromHTML(htmlString, replace: true);
|
||||
|
||||
// Should import valid bookmarks and skip the corrupt one
|
||||
expect(count, greaterThan(0));
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
});
|
||||
|
||||
test('should import from valid HTML file', () async {
|
||||
final fixtureFile = File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.preplaces.html',
|
||||
);
|
||||
final htmlString = await fixtureFile.readAsString();
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark_guid');
|
||||
|
||||
final count = await utils.importFromHTML(htmlString, replace: true);
|
||||
|
||||
expect(count, greaterThan(0));
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
// Verify some bookmarks were added
|
||||
verify(mockService.addItem(any, any, any, any)).called(greaterThan(0));
|
||||
});
|
||||
|
||||
test('should handle empty HTML', () async {
|
||||
const emptyHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
|
||||
final count = await utils.importFromHTML(emptyHtml, replace: true);
|
||||
|
||||
expect(count, equals(0));
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
});
|
||||
|
||||
test('should not erase when replace is false', () async {
|
||||
group('parseBookmarkHtml', () {
|
||||
test('should route everything under menu without root markers', () {
|
||||
const simpleHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -111,16 +74,19 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(simpleHtml, preserveRootFolders: false);
|
||||
|
||||
await utils.importFromHTML(simpleHtml);
|
||||
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
|
||||
expect(
|
||||
section(tree, BookmarkRoot.menu).single,
|
||||
isA<ImportBookmarkItem>()
|
||||
.having((i) => i.url, 'url', Uri.parse('https://example.com'))
|
||||
.having((i) => i.title, 'title', 'Example'),
|
||||
);
|
||||
expect(tree.stats.bookmarkCount, equals(1));
|
||||
});
|
||||
|
||||
test('should handle bookmarks with special characters in title', () async {
|
||||
test('should decode HTML entities in titles', () {
|
||||
const htmlWithSpecialChars = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -130,21 +96,16 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithSpecialChars,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithSpecialChars);
|
||||
|
||||
expect(count, equals(1));
|
||||
final captured = verify(
|
||||
mockService.addItem(any, any, captureAny, any),
|
||||
).captured;
|
||||
// Should properly decode HTML entities
|
||||
expect(captured[0], equals('<unescaped="test">'));
|
||||
final item = section(tree, BookmarkRoot.menu).single;
|
||||
expect((item as ImportBookmarkItem).title, equals('<unescaped="test">'));
|
||||
});
|
||||
|
||||
test('should import bookmarks with timestamps', () async {
|
||||
test('should preserve item timestamps as seconds since epoch', () {
|
||||
const htmlWithDates = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -154,16 +115,63 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(htmlWithDates, preserveRootFolders: false);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithDates);
|
||||
|
||||
expect(count, equals(1));
|
||||
final item =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
|
||||
expect(
|
||||
item.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375336 * 1000)),
|
||||
);
|
||||
expect(
|
||||
item.lastModified,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375423 * 1000)),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle folder hierarchy', () async {
|
||||
test('should fall back to LAST_MODIFIED when ADD_DATE is absent', () {
|
||||
const html = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com" LAST_MODIFIED="1177375423">Test</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(html, preserveRootFolders: false);
|
||||
|
||||
final item =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
|
||||
expect(item.dateAdded, equals(item.lastModified));
|
||||
});
|
||||
|
||||
test('should preserve folder timestamps', () {
|
||||
const html = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 ADD_DATE="1177375336" LAST_MODIFIED="1177375423">Dated</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Child</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(html, preserveRootFolders: false);
|
||||
|
||||
final folder =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder;
|
||||
expect(
|
||||
folder.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375336 * 1000)),
|
||||
);
|
||||
expect(
|
||||
folder.lastModified,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375423 * 1000)),
|
||||
);
|
||||
});
|
||||
|
||||
test('should nest folders and keep child order', () {
|
||||
const htmlWithFolders = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -180,24 +188,98 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark_guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithFolders,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithFolders);
|
||||
final parent =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder;
|
||||
expect(parent.title, equals('Parent Folder'));
|
||||
expect(parent.children, hasLength(2));
|
||||
|
||||
expect(count, equals(2)); // 2 bookmarks
|
||||
verify(mockService.addFolder(any, any, any)).called(2); // 2 folders
|
||||
expect(
|
||||
(parent.children[0] as ImportBookmarkItem).title,
|
||||
equals('Child 1'),
|
||||
);
|
||||
|
||||
final nested = parent.children[1] as ImportBookmarkFolder;
|
||||
expect(nested.title, equals('Nested Folder'));
|
||||
expect(
|
||||
(nested.children.single as ImportBookmarkItem).title,
|
||||
equals('Grandchild'),
|
||||
);
|
||||
|
||||
expect(tree.stats.bookmarkCount, equals(2));
|
||||
expect(tree.stats.folderCount, equals(2));
|
||||
});
|
||||
|
||||
test('should recognize toolbar folder', () async {
|
||||
const htmlWithToolbar = '''
|
||||
test('should keep empty folders', () {
|
||||
const html = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>Empty</H3>
|
||||
<DL><p>
|
||||
</DL><p>
|
||||
<DT><A HREF="https://example.com">After</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(html, preserveRootFolders: false);
|
||||
final nodes = section(tree, BookmarkRoot.menu);
|
||||
|
||||
expect(nodes, hasLength(2));
|
||||
expect(
|
||||
nodes[0],
|
||||
isA<ImportBookmarkFolder>()
|
||||
.having((f) => f.title, 'title', 'Empty')
|
||||
.having((f) => f.children, 'children', isEmpty),
|
||||
);
|
||||
expect(nodes[1], isA<ImportBookmarkItem>());
|
||||
});
|
||||
|
||||
test('should route root-marked folders when preserving roots', () {
|
||||
const htmlWithRoots = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/toolbar">Toolbar Bookmark</A>
|
||||
</DL><p>
|
||||
<DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/unfiled">Unfiled Bookmark</A>
|
||||
</DL><p>
|
||||
<DT><A HREF="https://example.com/loose">Loose</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(htmlWithRoots, preserveRootFolders: true);
|
||||
|
||||
expect(
|
||||
(section(tree, BookmarkRoot.toolbar).single as ImportBookmarkItem)
|
||||
.title,
|
||||
equals('Toolbar Bookmark'),
|
||||
);
|
||||
expect(
|
||||
(section(tree, BookmarkRoot.unfiled).single as ImportBookmarkItem)
|
||||
.title,
|
||||
equals('Unfiled Bookmark'),
|
||||
);
|
||||
// The marked folders themselves are not recreated, only their contents.
|
||||
expect(
|
||||
(section(tree, BookmarkRoot.menu).single as ImportBookmarkItem).title,
|
||||
equals('Loose'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should treat root markers as plain folders when not preserving', () {
|
||||
const htmlWithToolbar = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
|
||||
<DL><p>
|
||||
@@ -206,47 +288,19 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithToolbar,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
await utils.importFromHTML(htmlWithToolbar, replace: true);
|
||||
|
||||
// When replace is true, should add to toolbar
|
||||
final captured = verify(
|
||||
mockService.addItem(captureAny, any, any, any),
|
||||
).captured;
|
||||
expect(captured[0], equals(BookmarkRoot.toolbar.id));
|
||||
expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
|
||||
expect(
|
||||
(section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder).title,
|
||||
equals('Bookmarks Toolbar'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should recognize unfiled folder', () async {
|
||||
const htmlWithUnfiled = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Unfiled Bookmark</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
|
||||
await utils.importFromHTML(htmlWithUnfiled, replace: true);
|
||||
|
||||
final captured = verify(
|
||||
mockService.addItem(captureAny, any, any, any),
|
||||
).captured;
|
||||
expect(captured[0], equals(BookmarkRoot.unfiled.id));
|
||||
});
|
||||
|
||||
test('should handle separators', () async {
|
||||
test('should keep separators between bookmarks', () {
|
||||
const htmlWithSeparator = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -258,17 +312,19 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithSeparator,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
final nodes = section(tree, BookmarkRoot.menu);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithSeparator);
|
||||
|
||||
// Should import 2 bookmarks (separator is not supported by Android API)
|
||||
expect(count, equals(2));
|
||||
expect(nodes, hasLength(3));
|
||||
expect(nodes[1], isA<ImportBookmarkSeparator>());
|
||||
expect(tree.stats.bookmarkCount, equals(2));
|
||||
expect(tree.stats.separatorCount, equals(1));
|
||||
});
|
||||
|
||||
test('should skip bookmarks without URLs', () async {
|
||||
test('should skip bookmarks without URLs', () {
|
||||
const htmlWithoutUrl = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -279,16 +335,16 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithoutUrl,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithoutUrl);
|
||||
|
||||
expect(count, equals(1)); // Only the valid one
|
||||
expect(section(tree, BookmarkRoot.menu), hasLength(1));
|
||||
expect(tree.stats.skippedUrlCount, equals(1));
|
||||
});
|
||||
|
||||
test('should skip bookmarks with invalid URLs', () async {
|
||||
test('should skip bookmarks with schemeless URLs', () {
|
||||
const htmlWithInvalidUrl = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -299,31 +355,209 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithInvalidUrl,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithInvalidUrl);
|
||||
|
||||
expect(count, equals(1));
|
||||
expect(section(tree, BookmarkRoot.menu), hasLength(1));
|
||||
expect(tree.stats.skippedUrlCount, equals(1));
|
||||
});
|
||||
|
||||
test('should handle single frame HTML', () async {
|
||||
final fixtureFile = File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks_html_singleframe.html',
|
||||
test('should produce no sections for an empty document', () {
|
||||
const emptyHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(emptyHtml, preserveRootFolders: true);
|
||||
|
||||
expect(tree.isEmpty, isTrue);
|
||||
expect(tree.stats.bookmarkCount, equals(0));
|
||||
});
|
||||
|
||||
test('should keep headings that never open a list', () {
|
||||
// Firefox writes empty folders without a `<DL>`; the folder must still be
|
||||
// emitted, and the heading after it must not inherit its metadata.
|
||||
const html = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>First</H3>
|
||||
<DT><H3 ADD_DATE="1177375336">Second</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Child</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(html, preserveRootFolders: false);
|
||||
final nodes = section(tree, BookmarkRoot.menu);
|
||||
|
||||
expect(nodes, hasLength(2));
|
||||
expect((nodes[0] as ImportBookmarkFolder).title, equals('First'));
|
||||
expect((nodes[0] as ImportBookmarkFolder).dateAdded, isNull);
|
||||
|
||||
final second = nodes[1] as ImportBookmarkFolder;
|
||||
expect(second.title, equals('Second'));
|
||||
expect(
|
||||
second.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375336 * 1000)),
|
||||
);
|
||||
final htmlString = await fixtureFile.readAsString();
|
||||
expect(second.children, hasLength(1));
|
||||
});
|
||||
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark_guid');
|
||||
test('should handle deeply nested folders', () {
|
||||
const depth = 60;
|
||||
final buffer = StringBuffer(
|
||||
'<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<DL><p>',
|
||||
);
|
||||
for (var i = 0; i < depth; i++) {
|
||||
buffer.write('<DT><H3>Level $i</H3>\n<DL><p>');
|
||||
}
|
||||
buffer.write('<DT><A HREF="https://example.com">Deep</A>');
|
||||
for (var i = 0; i < depth; i++) {
|
||||
buffer.write('</DL><p>');
|
||||
}
|
||||
buffer.write('</DL>');
|
||||
|
||||
final count = await utils.importFromHTML(htmlString);
|
||||
final tree = parseBookmarkHtml(
|
||||
buffer.toString(),
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
expect(count, greaterThan(0));
|
||||
var node = section(tree, BookmarkRoot.menu).single;
|
||||
for (var i = 0; i < depth; i++) {
|
||||
node = (node as ImportBookmarkFolder).children.single;
|
||||
}
|
||||
expect(node, isA<ImportBookmarkItem>());
|
||||
expect(tree.stats.folderCount, equals(depth));
|
||||
});
|
||||
|
||||
test('should parse the corrupt fixture without throwing', () async {
|
||||
final htmlString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.corrupt.html',
|
||||
).readAsString();
|
||||
|
||||
final tree = parseBookmarkHtml(htmlString, preserveRootFolders: true);
|
||||
|
||||
expect(tree.stats.bookmarkCount, greaterThan(0));
|
||||
});
|
||||
|
||||
test('should parse the pre-places fixture', () async {
|
||||
final htmlString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.preplaces.html',
|
||||
).readAsString();
|
||||
|
||||
final tree = parseBookmarkHtml(htmlString, preserveRootFolders: true);
|
||||
|
||||
expect(tree.stats.bookmarkCount, greaterThan(0));
|
||||
});
|
||||
|
||||
test('should parse the single frame fixture', () async {
|
||||
final htmlString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks_html_singleframe.html',
|
||||
).readAsString();
|
||||
|
||||
final tree = parseBookmarkHtml(htmlString, preserveRootFolders: false);
|
||||
|
||||
expect(tree.stats.bookmarkCount, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
group('BookmarkHTMLUtils - Import', () {
|
||||
test('should insert each section with a single bulk call', () async {
|
||||
const htmlWithFolders = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>Parent Folder</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/1">Child 1</A>
|
||||
<DT><H3>Nested Folder</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/2">Grandchild</A>
|
||||
</DL><p>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithFolders);
|
||||
|
||||
expect(count, equals(2));
|
||||
verify(mockService.insertTree(BookmarkRoot.menu.id, any)).called(1);
|
||||
verifyNever(mockService.addItem(any, any, any, any));
|
||||
verifyNever(mockService.addFolder(any, any, any));
|
||||
});
|
||||
|
||||
test(
|
||||
'should erase every root except the tree root when replacing',
|
||||
() async {
|
||||
final htmlString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.preplaces.html',
|
||||
).readAsString();
|
||||
|
||||
final count = await utils.importFromHTML(htmlString, replace: true);
|
||||
|
||||
expect(count, greaterThan(0));
|
||||
for (final root in BookmarkRoot.values) {
|
||||
if (root == BookmarkRoot.root) {
|
||||
verifyNever(mockService.eraseEverything(root));
|
||||
} else {
|
||||
verify(mockService.eraseEverything(root)).called(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('should not erase when replace is false', () async {
|
||||
const simpleHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Example</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
await utils.importFromHTML(simpleHtml);
|
||||
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
|
||||
test('should route root-marked sections to their Places roots', () async {
|
||||
const htmlWithToolbar = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Toolbar Bookmark</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
await utils.importFromHTML(htmlWithToolbar, replace: true);
|
||||
|
||||
verify(mockService.insertTree(BookmarkRoot.toolbar.id, any)).called(1);
|
||||
});
|
||||
|
||||
test('should insert nothing for an empty document', () async {
|
||||
const emptyHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final count = await utils.importFromHTML(emptyHtml, replace: true);
|
||||
|
||||
expect(count, equals(0));
|
||||
verifyNever(mockService.insertTree(any, any));
|
||||
// Nothing parsed means nothing to replace, so existing bookmarks survive.
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -747,18 +981,24 @@ void main() {
|
||||
expect(html, isNotEmpty);
|
||||
|
||||
// Re-import
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder1_____');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
|
||||
final count = await utils.importFromHTML(html, replace: true);
|
||||
|
||||
expect(count, equals(1)); // One bookmark imported
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
verify(mockService.eraseEverything(BookmarkRoot.menu)).called(1);
|
||||
|
||||
final inserted =
|
||||
verify(
|
||||
mockService.insertTree(BookmarkRoot.menu.id, captureAny),
|
||||
).captured.single
|
||||
as List<BookmarkImportNode>;
|
||||
|
||||
final folder = inserted.single;
|
||||
expect(folder.type, equals(BookmarkNodeType.folder));
|
||||
expect(folder.title, equals('Test Folder'));
|
||||
|
||||
final bookmark = folder.children.single;
|
||||
expect(bookmark.title, equals('Test Bookmark'));
|
||||
expect(bookmark.url, equals('https://example.com'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+69
-39
@@ -3,11 +3,11 @@
|
||||
// Do not manually edit this file.
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _i3;
|
||||
import 'dart:async' as _i4;
|
||||
|
||||
import 'package:flutter_mozilla_components/src/domain/services/gecko_bookmarks.dart'
|
||||
as _i2;
|
||||
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i4;
|
||||
as _i3;
|
||||
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i2;
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i5;
|
||||
|
||||
@@ -26,46 +26,52 @@ import 'package:mockito/src/dummies.dart' as _i5;
|
||||
// ignore_for_file: subtype_of_sealed_class
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
class _FakeBookmarkInsertTreeResult_0 extends _i1.SmartFake
|
||||
implements _i2.BookmarkInsertTreeResult {
|
||||
_FakeBookmarkInsertTreeResult_0(Object parent, Invocation parentInvocation)
|
||||
: super(parent, parentInvocation);
|
||||
}
|
||||
|
||||
/// A class which mocks [GeckoBookmarksService].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockGeckoBookmarksService extends _i1.Mock
|
||||
implements _i2.GeckoBookmarksService {
|
||||
implements _i3.GeckoBookmarksService {
|
||||
MockGeckoBookmarksService() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i3.Future<_i4.BookmarkNode?> getTree(
|
||||
_i4.Future<_i2.BookmarkNode?> getTree(
|
||||
String? guid, {
|
||||
bool? recursive = false,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getTree, [guid], {#recursive: recursive}),
|
||||
returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
|
||||
returnValue: _i4.Future<_i2.BookmarkNode?>.value(),
|
||||
)
|
||||
as _i3.Future<_i4.BookmarkNode?>);
|
||||
as _i4.Future<_i2.BookmarkNode?>);
|
||||
|
||||
@override
|
||||
_i3.Future<_i4.BookmarkNode?> getBookmark(String? guid) =>
|
||||
_i4.Future<_i2.BookmarkNode?> getBookmark(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmark, [guid]),
|
||||
returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
|
||||
returnValue: _i4.Future<_i2.BookmarkNode?>.value(),
|
||||
)
|
||||
as _i3.Future<_i4.BookmarkNode?>);
|
||||
as _i4.Future<_i2.BookmarkNode?>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
_i4.Future<List<_i2.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmarksWithUrl, [url]),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getRecentBookmarks(
|
||||
_i4.Future<List<_i2.BookmarkNode>> getRecentBookmarks(
|
||||
int? limit, {
|
||||
Duration? maxAge = Duration.zero,
|
||||
DateTime? currentTime,
|
||||
@@ -76,27 +82,27 @@ class MockGeckoBookmarksService extends _i1.Mock
|
||||
[limit],
|
||||
{#maxAge: maxAge, #currentTime: currentTime},
|
||||
),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> searchBookmarks(
|
||||
_i4.Future<List<_i2.BookmarkNode>> searchBookmarks(
|
||||
String? query, {
|
||||
int? limit = 10,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#searchBookmarks, [query], {#limit: limit}),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addItem(
|
||||
_i4.Future<String> addItem(
|
||||
String? parentGuid,
|
||||
Uri? url,
|
||||
String? title,
|
||||
@@ -104,55 +110,79 @@ class MockGeckoBookmarksService extends _i1.Mock
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addFolder(
|
||||
_i4.Future<String> addFolder(
|
||||
String? parentGuid,
|
||||
String? title,
|
||||
int? position,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> updateNode(String? guid, _i4.BookmarkInfo? info) =>
|
||||
_i4.Future<void> updateNode(String? guid, _i2.BookmarkInfo? info) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#updateNode, [guid, info]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i3.Future<bool> deleteNode(String? guid) =>
|
||||
_i4.Future<bool> deleteNode(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#deleteNode, [guid]),
|
||||
returnValue: _i3.Future<bool>.value(false),
|
||||
returnValue: _i4.Future<bool>.value(false),
|
||||
)
|
||||
as _i3.Future<bool>);
|
||||
as _i4.Future<bool>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> eraseEverything(_i2.BookmarkRoot? root) =>
|
||||
_i4.Future<_i2.BookmarkInsertTreeResult> insertTree(
|
||||
String? parentGuid,
|
||||
List<_i2.BookmarkImportNode>? children,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#insertTree, [parentGuid, children]),
|
||||
returnValue: _i4.Future<_i2.BookmarkInsertTreeResult>.value(
|
||||
_FakeBookmarkInsertTreeResult_0(
|
||||
this,
|
||||
Invocation.method(#insertTree, [parentGuid, children]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i4.Future<_i2.BookmarkInsertTreeResult>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> countBookmarksInTrees(List<String>? guids) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#countBookmarksInTrees, [guids]),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
)
|
||||
as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> eraseEverything(_i3.BookmarkRoot? root) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#eraseEverything, [root]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
as _i4.Future<void>);
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('bookmark-import-test');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<File> writeFixture(String name, String contents) async {
|
||||
final file = File('${tempDir.path}/$name');
|
||||
await file.writeAsString(contents);
|
||||
return file;
|
||||
}
|
||||
|
||||
group('parseBookmarkFile', () {
|
||||
test('should return an HTML tree across the isolate boundary', () async {
|
||||
final file = await writeFixture('bookmarks.html', '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>Folder</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com" ADD_DATE="1177375336">Example</A>
|
||||
<HR>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''');
|
||||
|
||||
final tree = await parseBookmarkFile(
|
||||
path: file.path,
|
||||
format: BookmarkImportFormat.html,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final folder =
|
||||
tree.sections[BookmarkRoot.menu.id]!.single as ImportBookmarkFolder;
|
||||
expect(folder.title, equals('Folder'));
|
||||
|
||||
final item = folder.children[0] as ImportBookmarkItem;
|
||||
expect(item.url, equals(Uri.parse('https://example.com')));
|
||||
expect(
|
||||
item.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375336 * 1000)),
|
||||
);
|
||||
expect(folder.children[1], isA<ImportBookmarkSeparator>());
|
||||
|
||||
expect(tree.stats.bookmarkCount, equals(1));
|
||||
expect(tree.stats.separatorCount, equals(1));
|
||||
});
|
||||
|
||||
test('should return a JSON tree across the isolate boundary', () async {
|
||||
final file = await writeFixture('bookmarks.json', '''
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"guid": "menu________",
|
||||
"type": "text/x-moz-place-container",
|
||||
"children": [
|
||||
{
|
||||
"guid": "bookmark1___",
|
||||
"title": "Example",
|
||||
"type": "text/x-moz-place",
|
||||
"uri": "https://example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
''');
|
||||
|
||||
final tree = await parseBookmarkFile(
|
||||
path: file.path,
|
||||
format: BookmarkImportFormat.json,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final item =
|
||||
tree.sections[BookmarkRoot.menu.id]!.single as ImportBookmarkItem;
|
||||
expect(item.title, equals('Example'));
|
||||
expect(item.url, equals(Uri.parse('https://example.com')));
|
||||
});
|
||||
|
||||
test('should propagate a missing file as an error', () {
|
||||
expect(
|
||||
parseBookmarkFile(
|
||||
path: '${tempDir.path}/does-not-exist.html',
|
||||
format: BookmarkImportFormat.html,
|
||||
preserveRootFolders: false,
|
||||
),
|
||||
throwsA(isA<FileSystemException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('should propagate a malformed JSON document as an error', () async {
|
||||
final file = await writeFixture('broken.json', '{not json');
|
||||
|
||||
expect(
|
||||
parseBookmarkFile(
|
||||
path: file.path,
|
||||
format: BookmarkImportFormat.json,
|
||||
preserveRootFolders: false,
|
||||
),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+305
-223
@@ -27,11 +27,26 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
|
||||
|
||||
@GenerateMocks([GeckoBookmarksService])
|
||||
import 'bookmark_json_utils_test.mocks.dart';
|
||||
|
||||
/// Nodes the parser routed to [root], or an empty list if it produced no such
|
||||
/// section.
|
||||
List<ImportBookmarkNode> section(ImportBookmarkTree tree, BookmarkRoot root) =>
|
||||
tree.sections[root.id] ?? const [];
|
||||
|
||||
/// Mirrors what the native side reports back: bookmark items only, recursively.
|
||||
int countItems(List<BookmarkImportNode> nodes) => nodes.fold(
|
||||
0,
|
||||
(total, node) =>
|
||||
total +
|
||||
(node.type == BookmarkNodeType.item ? 1 : 0) +
|
||||
countItems(node.children),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late MockGeckoBookmarksService mockService;
|
||||
late BookmarkJSONUtils utils;
|
||||
@@ -39,108 +54,52 @@ void main() {
|
||||
setUp(() {
|
||||
mockService = MockGeckoBookmarksService();
|
||||
utils = BookmarkJSONUtils(mockService);
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(mockService.insertTree(any, any)).thenAnswer(
|
||||
(invocation) async => BookmarkInsertTreeResult(
|
||||
insertedItemCount: countItems(
|
||||
invocation.positionalArguments[1] as List<BookmarkImportNode>,
|
||||
),
|
||||
failedNodeCount: 0,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
group('BookmarkJSONUtils - Import', () {
|
||||
test('should reject invalid JSON format', () {
|
||||
const invalidJson = '[]';
|
||||
|
||||
expect(
|
||||
() => utils.importFromJSON(invalidJson),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
group('parseBookmarkJson', () {
|
||||
test('should reject a document that is not an object', () {
|
||||
expect(() => parseBookmarkJson('[]'), throwsA(isA<FormatException>()));
|
||||
});
|
||||
|
||||
test('should return 0 for empty children', () async {
|
||||
const emptyJson = '{"children": []}';
|
||||
|
||||
final count = await utils.importFromJSON(emptyJson);
|
||||
|
||||
expect(count, equals(0));
|
||||
test('should produce nothing for empty or missing children', () {
|
||||
expect(parseBookmarkJson('{"children": []}').isEmpty, isTrue);
|
||||
expect(parseBookmarkJson('{"guid": "root________"}').isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('should return 0 when children is null', () async {
|
||||
const noChildrenJson = '{"guid": "root________"}';
|
||||
|
||||
final count = await utils.importFromJSON(noChildrenJson);
|
||||
|
||||
expect(count, equals(0));
|
||||
});
|
||||
|
||||
test('should filter out tags folder during import', () async {
|
||||
test('should filter out the tags folder', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'tags________',
|
||||
'root': 'tagsFolder',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [],
|
||||
'children': [
|
||||
{
|
||||
'guid': 'tag1________',
|
||||
'title': 'Tagged',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com/tagged',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'root': 'bookmarksMenuFolder',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
|
||||
final count = await utils.importFromJSON(
|
||||
jsonEncode(jsonData),
|
||||
replace: true,
|
||||
);
|
||||
|
||||
// Only the menu folder should be processed, tags should be filtered
|
||||
expect(count, equals(0)); // No bookmarks, just folders
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
});
|
||||
|
||||
test('should erase everything when replace is true', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData), replace: true);
|
||||
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
});
|
||||
|
||||
test('should not erase when replace is false', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
|
||||
test('should import bookmarks with URI field', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Test Bookmark',
|
||||
'title': 'Kept',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
},
|
||||
@@ -149,59 +108,65 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(1));
|
||||
verify(
|
||||
mockService.addItem(
|
||||
'menu________',
|
||||
Uri.parse('https://example.com'),
|
||||
'Test Bookmark',
|
||||
0,
|
||||
),
|
||||
).called(1);
|
||||
expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
|
||||
expect(tree.stats.bookmarkCount, equals(1));
|
||||
});
|
||||
|
||||
test('should import bookmarks with URL field', () async {
|
||||
test('should ignore top-level nodes that are not Places roots', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'guid': 'notaroot____',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Test Bookmark',
|
||||
'title': 'Orphan',
|
||||
'type': 'text/x-moz-place',
|
||||
'url': 'https://example.com',
|
||||
'uri': 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(1));
|
||||
verify(
|
||||
mockService.addItem(
|
||||
'menu________',
|
||||
Uri.parse('https://example.com'),
|
||||
'Test Bookmark',
|
||||
0,
|
||||
),
|
||||
).called(1);
|
||||
expect(parseBookmarkJson(jsonEncode(jsonData)).isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('should skip bookmarks with invalid URLs', () async {
|
||||
test('should accept both the uri and url fields', () {
|
||||
for (final field in ['uri', 'url']) {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Test Bookmark',
|
||||
'type': 'text/x-moz-place',
|
||||
field: 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
expect(
|
||||
section(tree, BookmarkRoot.menu).single,
|
||||
isA<ImportBookmarkItem>()
|
||||
.having((i) => i.url, 'url', Uri.parse('https://example.com'))
|
||||
.having((i) => i.title, 'title', 'Test Bookmark'),
|
||||
reason: 'field "$field" should be read as the bookmark URL',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should skip bookmarks with invalid URLs', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -225,26 +190,13 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'valid1______');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
// Only one valid bookmark should be imported
|
||||
expect(count, equals(1));
|
||||
// Note: position is 1 because the invalid bookmark was skipped first
|
||||
verify(
|
||||
mockService.addItem(
|
||||
'menu________',
|
||||
Uri.parse('https://example.com'),
|
||||
'Valid URL',
|
||||
1,
|
||||
),
|
||||
).called(1);
|
||||
expect(section(tree, BookmarkRoot.menu), hasLength(1));
|
||||
expect(tree.stats.skippedUrlCount, equals(1));
|
||||
});
|
||||
|
||||
test('should import nested folders recursively', () async {
|
||||
test('should nest folders recursively', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -269,28 +221,20 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder1_____');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(1));
|
||||
verify(mockService.addFolder('menu________', 'Folder 1', 0)).called(1);
|
||||
verify(
|
||||
mockService.addItem(
|
||||
'folder1_____',
|
||||
Uri.parse('https://example.com'),
|
||||
'Nested Bookmark',
|
||||
0,
|
||||
),
|
||||
).called(1);
|
||||
final folder =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder;
|
||||
expect(folder.title, equals('Folder 1'));
|
||||
expect(
|
||||
(folder.children.single as ImportBookmarkItem).title,
|
||||
equals('Nested Bookmark'),
|
||||
);
|
||||
expect(tree.stats.bookmarkCount, equals(1));
|
||||
expect(tree.stats.folderCount, equals(1));
|
||||
});
|
||||
|
||||
test('should handle separators gracefully (skip them)', () async {
|
||||
test('should keep separators', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -315,18 +259,80 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((invocation) async => 'generated_guid');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
final nodes = section(tree, BookmarkRoot.menu);
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
// Two bookmarks, separator should be skipped
|
||||
expect(count, equals(2));
|
||||
verify(mockService.addItem(any, any, any, any)).called(2);
|
||||
expect(nodes, hasLength(3));
|
||||
expect(nodes[1], isA<ImportBookmarkSeparator>());
|
||||
expect(tree.stats.bookmarkCount, equals(2));
|
||||
expect(tree.stats.separatorCount, equals(1));
|
||||
});
|
||||
|
||||
test('should fixup place: queries with folder shortcuts', () async {
|
||||
test('should read microsecond timestamps from Firefox backups', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Dated',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
'dateAdded': 1361551979350273,
|
||||
'lastModified': 1361551979376699,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final item =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
|
||||
expect(
|
||||
item.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1361551979350)),
|
||||
);
|
||||
expect(
|
||||
item.lastModified,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1361551979376)),
|
||||
);
|
||||
});
|
||||
|
||||
test('should read millisecond timestamps from WebLibre exports', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Dated',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
'dateAdded': 1361551979350,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final item =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
|
||||
expect(
|
||||
item.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1361551979350)),
|
||||
);
|
||||
expect(item.lastModified, isNull);
|
||||
});
|
||||
|
||||
test('should fixup place: queries with folder shortcuts', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -352,25 +358,14 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder1_____');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'shortcut1___');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
// Capture the URI argument to verify it was fixed up
|
||||
// Note: position is 1 because the folder was added first at position 0
|
||||
final captured = verify(
|
||||
mockService.addItem('unfiled_____', captureAny, 'Folder Shortcut', 1),
|
||||
).captured;
|
||||
|
||||
expect((captured[0] as Uri).toString(), contains('parent=folder1_____'));
|
||||
final shortcut =
|
||||
section(tree, BookmarkRoot.unfiled)[1] as ImportBookmarkItem;
|
||||
expect(shortcut.url.toString(), contains('parent=folder1_____'));
|
||||
});
|
||||
|
||||
test('should handle invalid folder references in place: queries', () async {
|
||||
test('should handle invalid folder references in place: queries', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -388,48 +383,129 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'shortcut1___');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
final captured = verify(
|
||||
mockService.addItem(
|
||||
'unfiled_____',
|
||||
captureAny,
|
||||
'Invalid Folder Shortcut',
|
||||
0,
|
||||
),
|
||||
).captured;
|
||||
|
||||
final url = (captured[0] as Uri).toString();
|
||||
final url =
|
||||
(section(tree, BookmarkRoot.unfiled).single as ImportBookmarkItem).url
|
||||
.toString();
|
||||
expect(url, contains('invalidOldParentId=999999'));
|
||||
expect(url, contains('excludeItems=1'));
|
||||
});
|
||||
|
||||
test('should count imported bookmarks correctly from fixture', () async {
|
||||
// Load the fixture
|
||||
final fixtureFile = File('test/utils/bookmarks/fixtures/bookmarks.json');
|
||||
final jsonString = await fixtureFile.readAsString();
|
||||
test('should parse the bookmarks fixture', () async {
|
||||
final jsonString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.json',
|
||||
).readAsString();
|
||||
|
||||
// Mock the service calls
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((invocation) async => 'generated_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((invocation) async => 'generated_guid');
|
||||
final tree = parseBookmarkJson(jsonString);
|
||||
|
||||
final count = await utils.importFromJSON(jsonString, replace: true);
|
||||
expect(tree.stats.bookmarkCount, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
// The fixture has several bookmarks - we should count only valid ones
|
||||
expect(count, greaterThan(0));
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
group('BookmarkJSONUtils - Import', () {
|
||||
test('should reject invalid JSON format', () {
|
||||
expect(() => utils.importFromJSON('[]'), throwsA(isA<Exception>()));
|
||||
});
|
||||
|
||||
test('should handle import errors gracefully', () async {
|
||||
test('should return 0 without touching storage for empty input', () async {
|
||||
expect(await utils.importFromJSON('{"children": []}'), equals(0));
|
||||
expect(await utils.importFromJSON('{"guid": "root________"}'), equals(0));
|
||||
|
||||
verifyNever(mockService.insertTree(any, any));
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
|
||||
test('should insert each root section with a single bulk call', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'folder1_____',
|
||||
'title': 'Folder 1',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Nested Bookmark',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'guid': 'unfiled_____',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark2___',
|
||||
'title': 'Unfiled Bookmark',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com/2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(2));
|
||||
verify(mockService.insertTree(BookmarkRoot.menu.id, any)).called(1);
|
||||
verify(mockService.insertTree(BookmarkRoot.unfiled.id, any)).called(1);
|
||||
verifyNever(mockService.addItem(any, any, any, any));
|
||||
verifyNever(mockService.addFolder(any, any, any));
|
||||
});
|
||||
|
||||
test(
|
||||
'should erase every root except the tree root when replacing',
|
||||
() async {
|
||||
final jsonString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.json',
|
||||
).readAsString();
|
||||
|
||||
final count = await utils.importFromJSON(jsonString, replace: true);
|
||||
|
||||
expect(count, greaterThan(0));
|
||||
for (final root in BookmarkRoot.values) {
|
||||
if (root == BookmarkRoot.root) {
|
||||
verifyNever(mockService.eraseEverything(root));
|
||||
} else {
|
||||
verify(mockService.eraseEverything(root)).called(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('should not erase when replace is false', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Test Bookmark',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
|
||||
test('should rethrow storage failures', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -448,13 +524,13 @@ void main() {
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
mockService.insertTree(any, any),
|
||||
).thenThrow(Exception('Database error'));
|
||||
|
||||
// Should not throw, but should log and continue
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(0)); // Failed to add
|
||||
expect(
|
||||
() => utils.importFromJSON(jsonEncode(jsonData)),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -776,21 +852,27 @@ void main() {
|
||||
expect(exported, isNotNull);
|
||||
|
||||
// Re-import
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder1_____');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
|
||||
final jsonString = jsonEncode({
|
||||
'children': [exported],
|
||||
});
|
||||
final count = await utils.importFromJSON(jsonString, replace: true);
|
||||
|
||||
expect(count, equals(1)); // One bookmark imported
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
verify(mockService.eraseEverything(BookmarkRoot.menu)).called(1);
|
||||
|
||||
final inserted =
|
||||
verify(
|
||||
mockService.insertTree(BookmarkRoot.menu.id, captureAny),
|
||||
).captured.single
|
||||
as List<BookmarkImportNode>;
|
||||
|
||||
final folder = inserted.single;
|
||||
expect(folder.type, equals(BookmarkNodeType.folder));
|
||||
expect(folder.title, equals('Test Folder'));
|
||||
|
||||
final bookmark = folder.children.single;
|
||||
expect(bookmark.title, equals('Test Bookmark'));
|
||||
expect(bookmark.url, equals('https://example.com'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+69
-39
@@ -3,11 +3,11 @@
|
||||
// Do not manually edit this file.
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _i3;
|
||||
import 'dart:async' as _i4;
|
||||
|
||||
import 'package:flutter_mozilla_components/src/domain/services/gecko_bookmarks.dart'
|
||||
as _i2;
|
||||
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i4;
|
||||
as _i3;
|
||||
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i2;
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i5;
|
||||
|
||||
@@ -26,46 +26,52 @@ import 'package:mockito/src/dummies.dart' as _i5;
|
||||
// ignore_for_file: subtype_of_sealed_class
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
class _FakeBookmarkInsertTreeResult_0 extends _i1.SmartFake
|
||||
implements _i2.BookmarkInsertTreeResult {
|
||||
_FakeBookmarkInsertTreeResult_0(Object parent, Invocation parentInvocation)
|
||||
: super(parent, parentInvocation);
|
||||
}
|
||||
|
||||
/// A class which mocks [GeckoBookmarksService].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockGeckoBookmarksService extends _i1.Mock
|
||||
implements _i2.GeckoBookmarksService {
|
||||
implements _i3.GeckoBookmarksService {
|
||||
MockGeckoBookmarksService() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i3.Future<_i4.BookmarkNode?> getTree(
|
||||
_i4.Future<_i2.BookmarkNode?> getTree(
|
||||
String? guid, {
|
||||
bool? recursive = false,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getTree, [guid], {#recursive: recursive}),
|
||||
returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
|
||||
returnValue: _i4.Future<_i2.BookmarkNode?>.value(),
|
||||
)
|
||||
as _i3.Future<_i4.BookmarkNode?>);
|
||||
as _i4.Future<_i2.BookmarkNode?>);
|
||||
|
||||
@override
|
||||
_i3.Future<_i4.BookmarkNode?> getBookmark(String? guid) =>
|
||||
_i4.Future<_i2.BookmarkNode?> getBookmark(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmark, [guid]),
|
||||
returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
|
||||
returnValue: _i4.Future<_i2.BookmarkNode?>.value(),
|
||||
)
|
||||
as _i3.Future<_i4.BookmarkNode?>);
|
||||
as _i4.Future<_i2.BookmarkNode?>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
_i4.Future<List<_i2.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmarksWithUrl, [url]),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getRecentBookmarks(
|
||||
_i4.Future<List<_i2.BookmarkNode>> getRecentBookmarks(
|
||||
int? limit, {
|
||||
Duration? maxAge = Duration.zero,
|
||||
DateTime? currentTime,
|
||||
@@ -76,27 +82,27 @@ class MockGeckoBookmarksService extends _i1.Mock
|
||||
[limit],
|
||||
{#maxAge: maxAge, #currentTime: currentTime},
|
||||
),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> searchBookmarks(
|
||||
_i4.Future<List<_i2.BookmarkNode>> searchBookmarks(
|
||||
String? query, {
|
||||
int? limit = 10,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#searchBookmarks, [query], {#limit: limit}),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addItem(
|
||||
_i4.Future<String> addItem(
|
||||
String? parentGuid,
|
||||
Uri? url,
|
||||
String? title,
|
||||
@@ -104,55 +110,79 @@ class MockGeckoBookmarksService extends _i1.Mock
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addFolder(
|
||||
_i4.Future<String> addFolder(
|
||||
String? parentGuid,
|
||||
String? title,
|
||||
int? position,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> updateNode(String? guid, _i4.BookmarkInfo? info) =>
|
||||
_i4.Future<void> updateNode(String? guid, _i2.BookmarkInfo? info) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#updateNode, [guid, info]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i3.Future<bool> deleteNode(String? guid) =>
|
||||
_i4.Future<bool> deleteNode(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#deleteNode, [guid]),
|
||||
returnValue: _i3.Future<bool>.value(false),
|
||||
returnValue: _i4.Future<bool>.value(false),
|
||||
)
|
||||
as _i3.Future<bool>);
|
||||
as _i4.Future<bool>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> eraseEverything(_i2.BookmarkRoot? root) =>
|
||||
_i4.Future<_i2.BookmarkInsertTreeResult> insertTree(
|
||||
String? parentGuid,
|
||||
List<_i2.BookmarkImportNode>? children,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#insertTree, [parentGuid, children]),
|
||||
returnValue: _i4.Future<_i2.BookmarkInsertTreeResult>.value(
|
||||
_FakeBookmarkInsertTreeResult_0(
|
||||
this,
|
||||
Invocation.method(#insertTree, [parentGuid, children]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i4.Future<_i2.BookmarkInsertTreeResult>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> countBookmarksInTrees(List<String>? guids) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#countBookmarksInTrees, [guids]),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
)
|
||||
as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> eraseEverything(_i3.BookmarkRoot? root) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#eraseEverything, [root]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
as _i4.Future<void>);
|
||||
}
|
||||
|
||||
+1
-4
@@ -66,10 +66,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('defaults to false', () {
|
||||
expect(
|
||||
ContainerMetadata.withDefaults().isolatedAppLinkSettings,
|
||||
isFalse,
|
||||
);
|
||||
expect(ContainerMetadata.withDefaults().isolatedAppLinkSettings, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user