Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -0,0 +1,244 @@
import 'dart:typed_data';
import 'dart:ui';
import 'package:drift/drift.dart' show Variable;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart';
import 'package:riverpod/riverpod.dart';
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/domain/services/favicon_resolver.dart';
import 'package:weblibre/domain/services/generic_website.dart';
import 'package:weblibre/features/user/data/database/database.dart';
import 'package:weblibre/features/user/data/icon_cache_marker.dart';
import 'package:weblibre/features/user/data/providers.dart';
import 'package:weblibre/features/user/domain/repositories/cache.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late UserDatabase db;
late ProviderContainer container;
late FakeFaviconResolver resolver;
late FakeGeckoIconService geckoIcons;
late GenericWebsiteService service;
late CacheRepository cacheRepository;
setUp(() {
db = UserDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
},
),
);
resolver = FakeFaviconResolver();
geckoIcons = FakeGeckoIconService();
container = ProviderContainer(
overrides: [
userDatabaseProvider.overrideWith((ref) => db),
faviconResolverProvider.overrideWithValue(resolver),
geckoIconServiceProvider.overrideWithValue(geckoIcons),
],
);
service = container.read(genericWebsiteServiceProvider.notifier);
cacheRepository = container.read(cacheRepositoryProvider.notifier);
});
tearDown(() async {
container.dispose();
await db.close();
});
test('returns cached icons without hitting the resolver', () async {
final url = Uri.parse('https://cached.example/article');
await cacheRepository.cacheIcon(url, _cachedSvgBytes);
final icon = await service.getUrlIcon([url]);
expect(icon, isNotNull);
expect(icon!.source, IconSource.disk);
expect(resolver.callCount, 0);
expect(geckoIcons.callCount, 0);
});
test('caches DDG misses and skips retries until the miss expires', () async {
final url = Uri.parse('https://missing.example/');
resolver.enqueue(const FaviconResolveResult.missing());
geckoIcons
..enqueue(_generatorResult)
..enqueue(_generatorResult);
final first = await service.getUrlIcon([url]);
final second = await service.getUrlIcon([url]);
expect(first?.source, IconSource.generator);
expect(second?.source, IconSource.generator);
expect(resolver.callCount, 1);
expect(geckoIcons.callCount, 2);
expect(
isMissingIconMarker(await cacheRepository.getCachedIconRaw(url.origin)),
isTrue,
);
});
test(
'resolver errors fall back to generation without negative caching',
() async {
final url = Uri.parse('https://flaky.example/');
resolver.enqueue(const FaviconResolveResult.error());
resolver.enqueue(const FaviconResolveResult.error());
geckoIcons
..enqueue(_generatorResult)
..enqueue(_generatorResult);
final first = await service.getUrlIcon([url]);
final second = await service.getUrlIcon([url]);
expect(first?.source, IconSource.generator);
expect(second?.source, IconSource.generator);
expect(resolver.callCount, 2);
expect(geckoIcons.callCount, 2);
expect(await cacheRepository.getCachedIconRaw(url.origin), isNull);
},
);
test(
'resolver recovery replaces generated fallback with a real icon',
() async {
final url = Uri.parse('https://recovery.example/');
resolver
..enqueue(const FaviconResolveResult.error())
..enqueue(FaviconResolveResult.hit(_updatedSvgBytes));
geckoIcons.enqueue(_generatorResult);
final first = await service.getUrlIcon([url]);
final second = await service.getUrlIcon([url]);
expect(first?.source, IconSource.generator);
expect(second?.source, IconSource.download);
expect(resolver.callCount, 2);
expect(geckoIcons.callCount, 1);
expect(
listEquals(
await cacheRepository.getCachedIcon(url.origin),
_updatedSvgBytes,
),
isTrue,
);
},
);
test('skips DDG resolution for localhost, onion, and IP hosts', () async {
final urls = [
Uri.parse('http://localhost:8080/'),
Uri.parse('https://hiddenservice.onion/'),
Uri.parse('https://127.0.0.1/'),
Uri.parse('https://192.168.0.10/'),
];
for (var i = 0; i < urls.length; i++) {
geckoIcons.enqueue(_generatorResult);
}
for (final url in urls) {
final icon = await service.getUrlIcon([url]);
expect(icon?.source, IconSource.generator);
}
expect(resolver.callCount, 0);
expect(geckoIcons.callCount, urls.length);
});
test('refreshIconIfStale updates stale cached icons from DDG', () async {
final url = Uri.parse('https://stale.example/');
await cacheRepository.cacheIcon(url, _cachedSvgBytes);
await db.customUpdate(
'UPDATE icon_cache SET fetch_date = ? WHERE origin = ?',
variables: [
Variable(DateTime.now().subtract(const Duration(days: 31))),
Variable(url.origin),
],
);
resolver.enqueue(FaviconResolveResult.hit(_updatedSvgBytes));
await service.refreshIconIfStale(url);
final updatedBytes = await cacheRepository.getCachedIcon(url.origin);
expect(updatedBytes, isNotNull);
expect(listEquals(updatedBytes, _updatedSvgBytes), isTrue);
expect(resolver.callCount, 1);
});
}
final class FakeFaviconResolver implements FaviconResolver {
int callCount = 0;
final List<FaviconResolveResult> _results = [];
void enqueue(FaviconResolveResult result) {
_results.add(result);
}
@override
Future<FaviconResolveResult> resolve(Uri url, {int? proxyPort}) async {
callCount += 1;
if (_results.isEmpty) {
return const FaviconResolveResult.error();
}
return _results.removeAt(0);
}
}
final class FakeGeckoIconService extends GeckoIconService {
int callCount = 0;
final List<IconResult> _results = [];
void enqueue(IconResult result) {
_results.add(result);
}
@override
Future<IconResult> loadIcon({
required Uri url,
List<Resource> resources = const [],
IconSize size = IconSize.defaultSize,
bool isPrivate = false,
bool waitOnNetworkLoad = true,
}) async {
callCount += 1;
if (_results.isEmpty) {
return _generatorResult;
}
return _results.removeAt(0);
}
}
final _generatorResult = IconResult(
image: _generatedSvgBytes,
color: const Color(0xFF5B8DEF).toARGB32(),
source: IconSource.generator,
maskable: false,
);
final _cachedSvgBytes = Uint8List.fromList(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
'<rect width="16" height="16" fill="#ff0000"/>'
'</svg>'
.codeUnits,
);
final _updatedSvgBytes = Uint8List.fromList(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
'<circle cx="8" cy="8" r="8" fill="#0000ff"/>'
'</svg>'
.codeUnits,
);
final _generatedSvgBytes = Uint8List.fromList(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
'<rect width="16" height="16" rx="3" fill="#5B8DEF"/>'
'<text x="8" y="11" text-anchor="middle" font-size="8" fill="#fff">W</text>'
'</svg>'
.codeUnits,
);
@@ -12,6 +12,10 @@ import 'schema_v6.dart' as v6;
import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -33,10 +37,18 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v8.DatabaseAtV8(db);
case 9:
return v9.DatabaseAtV9(db);
case 10:
return v10.DatabaseAtV10(db);
case 11:
return v11.DatabaseAtV11(db);
case 12:
return v12.DatabaseAtV12(db);
case 13:
return v13.DatabaseAtV13(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [2, 3, 4, 5, 6, 7, 8, 9];
static const versions = const [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,8 @@ import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
import 'schema_v3.dart' as v3;
import 'schema_v4.dart' as v4;
import 'schema_v5.dart' as v5;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -18,10 +20,14 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v2.DatabaseAtV2(db);
case 3:
return v3.DatabaseAtV3(db);
case 4:
return v4.DatabaseAtV4(db);
case 5:
return v5.DatabaseAtV5(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3];
static const versions = const [1, 2, 3, 4, 5];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
/*
* 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_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:weblibre/features/account/domain/services/prefs_js_reader.dart';
void main() {
late Directory profileDir;
setUp(() {
profileDir = Directory.systemTemp.createTempSync('prefs_js_reader_test_');
});
tearDown(() {
if (profileDir.existsSync()) {
profileDir.deleteSync(recursive: true);
}
});
Directory makeGeckoProfile(String id) {
final dir = Directory(p.join(profileDir.path, 'files', 'mozilla', id))
..createSync(recursive: true);
return dir;
}
test('returns empty map when no Gecko profile exists', () async {
final reader = PrefsJsReader(selectedProfileDir: profileDir);
expect(await reader.readUserPrefs(), isEmpty);
});
test('returns empty map when prefs.js is missing', () async {
makeGeckoProfile('abc.default');
final reader = PrefsJsReader(selectedProfileDir: profileDir);
expect(await reader.readUserPrefs(), isEmpty);
});
test('parses a realistic prefs.js fixture', () async {
final geckoDir = makeGeckoProfile('abc.default');
File(p.join(geckoDir.path, 'prefs.js')).writeAsStringSync('''
// Mozilla User Preferences
user_pref("browser.startup.homepage", "about:home");
user_pref("dom.webgpu.enabled", true);
user_pref("network.http.max-connections", 900);
''');
final reader = PrefsJsReader(selectedProfileDir: profileDir);
final prefs = await reader.readUserPrefs();
expect(prefs, {
'browser.startup.homepage': 'about:home',
'dom.webgpu.enabled': true,
'network.http.max-connections': 900,
});
});
test('picks newest prefs.js when multiple Gecko profiles exist', () async {
final oldDir = makeGeckoProfile('old.default');
final newDir = makeGeckoProfile('new.default');
final oldFile = File(p.join(oldDir.path, 'prefs.js'))
..writeAsStringSync('user_pref("a.pref", "old");\n');
File(
p.join(newDir.path, 'prefs.js'),
).writeAsStringSync('user_pref("a.pref", "new");\n');
final past = DateTime.now().subtract(const Duration(hours: 1));
oldFile.setLastModifiedSync(past);
final reader = PrefsJsReader(selectedProfileDir: profileDir);
final prefs = await reader.readUserPrefs();
expect(prefs['a.pref'], 'new');
});
}
@@ -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 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/account/domain/utils/user_js_parser.dart';
void main() {
group('parseUserJs', () {
test('parses empty and whitespace-only input', () {
expect(parseUserJs('').prefs, isEmpty);
expect(parseUserJs(' \t\n\r\v\f ').prefs, isEmpty);
});
test('ignores Firefox comment forms and parses metadata comments', () {
final result = parseUserJs('''
// schema_version=7
# ignored comment
/* block comment */
// exported_at=2026-04-15T12:00:00Z
''');
expect(result.prefs, isEmpty);
expect(result.schemaVersion, 7);
expect(result.exportedAt, '2026-04-15T12:00:00Z');
});
test('parses user_pref scalar values', () {
final result = parseUserJs('''
user_pref("bool.pref", true);
user_pref("int.pref", 123);
user_pref("string.pref", "value");
''');
expect(result.prefs, <String, Object>{
'bool.pref': true,
'int.pref': 123,
'string.pref': 'value',
});
});
test('parses signed integers with trivia after the sign', () {
final result = parseUserJs('''
user_pref("int.spaces", + 345);
user_pref("int.comment", - /* hmm */ 456);
user_pref("int.newline", -
567);
user_pref("int.max", +2147483647);
user_pref("int.min", -2147483648);
''');
expect(result.prefs['int.spaces'], 345);
expect(result.prefs['int.comment'], -456);
expect(result.prefs['int.newline'], -567);
expect(result.prefs['int.max'], 2147483647);
expect(result.prefs['int.min'], -2147483648);
});
test('duplicate pref names use last-write-wins semantics', () {
final result = parseUserJs('''
user_pref("dup.pref", 1);
user_pref("dup.pref", 2);
''');
expect(result.prefs['dup.pref'], 2);
});
test('accepts only user_pref entries in user.js input', () {
final result = parseUserJs('''
pref("ignored.pref", true);
user_pref("kept.pref", false);
''');
expect(result.prefs, <String, Object>{'kept.pref': false});
});
test('preserves comment-looking text inside strings', () {
final result = parseUserJs(
'''user_pref("comment.text", "before /* keep */ // still text after");''',
);
expect(
result.prefs['comment.text'],
'before /* keep */ // still text after',
);
});
test('parses single-quoted strings', () {
final result = parseUserJs(
"""user_pref('single.pref', 'single quoted value');""",
);
expect(result.prefs['single.pref'], 'single quoted value');
});
test('parses Firefox hex and unicode escapes', () {
final result = parseUserJs(
r'''user_pref("escaped.pref", "quote \" slash \\ newline \n carriage \r hex \x41 unicode \u0042");''',
);
expect(
result.prefs['escaped.pref'],
'quote " slash \\ newline \n carriage \r hex A unicode B'
.replaceAll(r'\n', '\n')
.replaceAll(r'\r', '\r'),
);
});
test('preserves raw line breaks inside string literals', () {
final result = parseUserJs(
'''
user_pref("raw.newlines", "line 1
line 2\rline 3");
'''
.replaceAll(r'\r', '\r'),
);
expect(result.prefs['raw.newlines'], 'line 1\nline 2\rline 3');
});
});
}
@@ -0,0 +1,72 @@
/*
* 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_test/flutter_test.dart';
import 'package:weblibre/features/account/domain/utils/user_js_parser.dart';
import 'package:weblibre/features/account/domain/utils/user_js_serializer.dart';
void main() {
group('serializeUserJs', () {
test('serializes syncable prefs in sorted order, excluding URL-style', () {
final text = serializeUserJs(
userPrefs: <String, Object>{
'zeta.pref': 7,
'alpha.pref': 'value',
'beta.pref': false,
'unsyncable.pref': 'blob:https://example.com',
},
schemaVersion: 1,
exportedAt: '2026-04-15T12:00:00Z',
);
expect(text, '''
// WebLibre Gecko prefs snapshot
// schema_version=1
// exported_at=2026-04-15T12:00:00Z
user_pref("alpha.pref", "value");
user_pref("beta.pref", false);
user_pref("zeta.pref", 7);
''');
});
test('uses Firefox-compatible escaping and round-trips through parser', () {
final prefValue = 'quote " slash \\ newline \n carriage \r tab \t'
.replaceAll(r'\n', '\n')
.replaceAll(r'\r', '\r')
.replaceAll(r'\t', '\t');
final text = serializeUserJs(
userPrefs: <String, Object>{'text.pref': prefValue},
schemaVersion: 1,
exportedAt: '2026-04-15T12:00:00Z',
);
expect(
text,
contains(
'user_pref("text.pref", "quote \\" slash \\\\ newline \\n carriage \\r tab \t");\n',
),
);
final parsed = parseUserJs(text);
expect(parsed.prefs['text.pref'], prefValue);
});
});
}
@@ -0,0 +1,101 @@
/*
* 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/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/account/data/models/subscription_status.dart';
import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart';
import 'package:weblibre/features/account/presentation/widgets/subscription_card.dart';
void main() {
testWidgets('refreshes subscription status when the app resumes', (
tester,
) async {
final repository = _FakeSubscriptionRepository();
await tester.pumpWidget(
ProviderScope(
overrides: [
subscriptionRepositoryProvider.overrideWith(() => repository),
],
child: MaterialApp(
home: Scaffold(
body: SubscriptionCard(
subscriptionAsync: AsyncData(SubscriptionStatus.inactive),
),
),
),
),
);
expect(repository.refreshCount, 0);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(repository.refreshCount, 1);
});
testWidgets(
'lifecycle refresh still fires when card is in the error branch',
(tester) async {
final repository = _FakeSubscriptionRepository();
await tester.pumpWidget(
ProviderScope(
overrides: [
subscriptionRepositoryProvider.overrideWith(() => repository),
],
child: MaterialApp(
home: Scaffold(
body: SubscriptionCard(
subscriptionAsync: AsyncError(
Exception('offline'),
StackTrace.current,
),
),
),
),
),
);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(repository.refreshCount, 1);
},
);
}
class _FakeSubscriptionRepository extends SubscriptionRepository {
int refreshCount = 0;
@override
Future<SubscriptionStatus> build() async => SubscriptionStatus.inactive;
@override
Future<void> refresh() async {
refreshCount++;
}
}
@@ -0,0 +1,173 @@
/*
* 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/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart';
BangData _bang(String trigger, String name) => BangData(
websiteName: name,
domain: '$trigger.example',
trigger: trigger,
urlTemplate: 'https://$trigger.example/?q={{{s}}}',
group: BangGroup.general,
searxngApi: false,
);
void main() {
group('buildFrequentBangDisplayList', () {
test('keeps selected bang first and default bang trailing', () {
final selectedBang = _bang('ddg', 'DuckDuckGo');
final defaultBang = _bang('wl', 'WebLibre Search');
final frequentBangs = [defaultBang, _bang('g', 'Google')];
final result = buildFrequentBangDisplayList(
frequentBangs: frequentBangs,
selectedBang: selectedBang,
defaultBang: defaultBang,
);
expect(result.map((bang) => bang.trigger), ['ddg', 'g', 'wl']);
});
test('does not duplicate the same bang when default is selected', () {
final defaultBang = _bang('wl', 'WebLibre Search');
final frequentBangs = [defaultBang, _bang('g', 'Google')];
final result = buildFrequentBangDisplayList(
frequentBangs: frequentBangs,
selectedBang: defaultBang,
defaultBang: defaultBang,
);
expect(result.map((bang) => bang.trigger), ['wl', 'g']);
});
});
group('canDeleteFrequentBang', () {
test('implicit default bang is not deletable', () {
final defaultBang = _bang('wl', 'WebLibre Search');
expect(
canDeleteFrequentBang(bang: defaultBang, defaultBang: defaultBang),
isFalse,
);
});
test('explicitly selected default bang remains clearable', () {
final defaultBang = _bang('wl', 'WebLibre Search');
expect(
canDeleteFrequentBang(
bang: defaultBang,
selectedBang: defaultBang,
defaultBang: defaultBang,
),
isTrue,
);
});
});
group('BangChipStrip delete affordances', () {
test('only selected bang keeps clear when frequency reset is disabled', () {
final selectedBang = _bang('ddg', 'DuckDuckGo');
final otherBang = _bang('wl', 'WebLibre Search');
expect(
canDeleteBangChip(
selectedBang,
selectedBang: selectedBang,
allowFrequencyResetAction: false,
),
isTrue,
);
expect(
canDeleteBangChip(
otherBang,
selectedBang: selectedBang,
allowFrequencyResetAction: false,
),
isFalse,
);
expect(
bangChipDeleteIcon(
selectedBang,
selectedBang: selectedBang,
allowFrequencyResetAction: false,
),
Icons.clear,
);
expect(
bangChipDeleteIcon(
otherBang,
selectedBang: selectedBang,
allowFrequencyResetAction: false,
),
isNull,
);
});
test('non-selected bangs use restore when frequency reset is enabled', () {
final selectedBang = _bang('ddg', 'DuckDuckGo');
final otherBang = _bang('wl', 'WebLibre Search');
expect(
canDeleteBangChip(
otherBang,
selectedBang: selectedBang,
allowFrequencyResetAction: true,
),
isTrue,
);
expect(
bangChipDeleteIcon(
selectedBang,
selectedBang: selectedBang,
allowFrequencyResetAction: true,
),
Icons.clear,
);
expect(
bangChipDeleteIcon(
otherBang,
selectedBang: selectedBang,
allowFrequencyResetAction: true,
),
MdiIcons.restore,
);
});
test('selected bangs always use clear even when restore is enabled', () {
final selectedBang = _bang('wl', 'WebLibre Search');
expect(
bangChipDeleteIcon(
selectedBang,
selectedBang: selectedBang,
allowFrequencyResetAction: true,
),
Icons.clear,
);
});
});
}
@@ -0,0 +1,53 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
void main() {
testWidgets('tryDecodeImage rasterizes svg bytes at favicon size', (
tester,
) async {
clearImageCache();
final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon));
final image = await tryDecodeImage(svgBytes);
expect(image, isNotNull);
expect(image!.value, isNotNull);
expect(image.value!.width, 32);
expect(image.value!.height, 32);
final byteData = await image.value!.toByteData(
format: ImageByteFormat.rawRgba,
);
expect(byteData, isNotNull);
// A pixel in the right half should be painted once the SVG is scaled
// to the requested raster size instead of being left in the top-left.
expect(_rgbaAt(byteData!, width: 32, x: 24, y: 16), [47, 128, 237, 255]);
});
}
List<int> _rgbaAt(
ByteData data, {
required int width,
required int x,
required int y,
}) {
final offset = (y * width + x) * 4;
return [
data.getUint8(offset),
data.getUint8(offset + 1),
data.getUint8(offset + 2),
data.getUint8(offset + 3),
];
}
const _svgIcon = '''
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<rect width="16" height="16" rx="3" fill="#2F80ED"/>
<circle cx="8" cy="8" r="4" fill="#FFFFFF"/>
</svg>
''';
@@ -0,0 +1,81 @@
/*
* 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/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
void main() {
group('filterSettingsSections', () {
const sections = [
SettingsSectionDefinition(
title: 'Appearance',
entries: [
SettingsEntryDefinition(
title: 'Theme',
subtitle: 'Choose system, light, or dark mode',
child: SizedBox.shrink(),
),
SettingsEntryDefinition(
title: 'User Interface Zoom',
subtitle: 'Make the user interface smaller or larger',
child: SizedBox.shrink(),
),
],
),
SettingsSectionDefinition(
title: 'Downloads',
entries: [
SettingsEntryDefinition(
title: 'Use external download manager',
subtitle: 'Manage downloads with another app',
child: SizedBox.shrink(),
),
],
),
];
test('returns a single matching entry when query targets an item', () {
final filtered = filterSettingsSections(
sections: sections,
query: 'zoom',
);
expect(filtered, hasLength(1));
expect(filtered.first.title, 'Appearance');
expect(filtered.first.entries, hasLength(1));
expect(filtered.first.entries.first.title, 'User Interface Zoom');
});
test('returns the full section when query matches the section title', () {
final filtered = filterSettingsSections(
sections: sections,
query: 'downloads',
);
expect(filtered, hasLength(1));
expect(filtered.first.title, 'Downloads');
expect(filtered.first.entries, hasLength(1));
expect(
filtered.first.entries.first.title,
'Use external download manager',
);
});
});
}
@@ -0,0 +1,719 @@
/*
* 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:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod/riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart';
import 'package:weblibre/features/user/data/providers.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart';
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
void main() {
group('MetaSearchController', () {
test('transitions to needsCredits when no token is available', () async {
final container = ProviderContainer(
overrides: [
metaSearchEnsureTokenAvailableProvider.overrideWithValue(
() async => TokenAvailabilityOutcome.noCredits,
),
riverpodDatabaseStorageProvider.overrideWith(
(ref) => Storage.inMemory(),
),
],
);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.needsCredits);
expect(state.query, 'lensai');
});
test('transitions to error when token issuance fails', () async {
final container = ProviderContainer(
overrides: [
metaSearchEnsureTokenAvailableProvider.overrideWithValue(
() async => TokenAvailabilityOutcome.issuanceFailed,
),
riverpodDatabaseStorageProvider.overrideWith(
(ref) => Storage.inMemory(),
),
],
);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.error);
expect(
state.errorMessage,
'Could not issue search tokens. Please try again.',
);
expect(state.query, 'lensai');
});
test('transitions to error when token availability check fails', () async {
final container = ProviderContainer(
overrides: [
metaSearchEnsureTokenAvailableProvider.overrideWithValue(
() async => throw Exception('balance unavailable'),
),
riverpodDatabaseStorageProvider.overrideWith(
(ref) => Storage.inMemory(),
),
],
);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.error);
expect(
state.errorMessage,
'Could not check search credits. Please try again.',
);
expect(state.query, 'lensai');
});
test('search results populate the ready state', () async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': 'https://example.com/result',
'content': 'Result summary',
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.ready);
expect(state.results, hasLength(1));
expect(state.results.single.title, 'LensAI result');
expect(session.submittedQueries, ['lensai']);
});
test('image frames update cached media maps', () async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.image,
data: {
'url': 'https://example.com/image.png',
'bytes': base64Encode(Uint8List.fromList([4, 5, 6])),
},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.imagesByUrl['https://example.com/image.png'], [4, 5, 6]);
});
test(
'fetchPage marks a result in flight and stores fetched documents',
() async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
await container
.read(metaSearchControllerProvider.notifier)
.fetchPage(Uri.parse(resultUrl));
expect(
container.read(metaSearchControllerProvider).fetchingUrls,
contains(Uri.parse(resultUrl)),
);
expect(session.fetchRequests, [Uri.parse(resultUrl)]);
session.emit(
StreamMessage(
type: MessageTypes.document,
data: {
'url': resultUrl,
'content': '# Preview\n\nFetched content',
'metadata': {
'date': null,
'description': null,
'filedate': null,
'image': null,
'language': null,
'pagetype': null,
'sitename': null,
'title': 'LensAI result',
'author': null,
'license': null,
},
},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.fetchingUrls, isEmpty);
expect(state.documentsByUrl.keys, contains(Uri.parse(resultUrl)));
expect(
state.documentsByUrl[Uri.parse(resultUrl)]?.content,
contains('Fetched content'),
);
},
);
test(
'fetch failures clear in-flight state and preserve search results',
() async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
await container
.read(metaSearchControllerProvider.notifier)
.fetchPage(Uri.parse(resultUrl));
session.emit(
StreamMessage(
type: MessageTypes.error,
data: {'message': 'Document fetch failed'},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.ready);
expect(state.results, hasLength(1));
expect(state.fetchingUrls, isEmpty);
expect(state.errorMessage, contains('Document fetch failed'));
},
);
test(
'capturePage dispatches capture request and tracks capturing state',
() async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
final uri = Uri.parse(resultUrl);
await container
.read(metaSearchControllerProvider.notifier)
.capturePage(uri, choice: FetchMethodChoice.singlefileHtml);
expect(
container
.read(metaSearchControllerProvider)
.isCapturing(uri, FetchMethodChoice.singlefileHtml),
isTrue,
);
expect(session.captureRequests, hasLength(1));
expect(session.captureRequests.first.url, uri);
expect(session.captureRequests.first.method, 'singlefile');
expect(session.captureRequests.first.variant, 'balanced');
},
);
test('capture frame clears capturing state', () async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
final uri = Uri.parse(resultUrl);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
await container
.read(metaSearchControllerProvider.notifier)
.capturePage(uri, choice: FetchMethodChoice.singlefileHtml);
expect(
container
.read(metaSearchControllerProvider)
.isCapturing(uri, FetchMethodChoice.singlefileHtml),
isTrue,
);
session.emit(
StreamMessage(
type: MessageTypes.capture,
data: {
'captureId': 'abc123',
'url': resultUrl,
'method': 'singlefile',
'variant': 'balanced',
'contentType': 'text/html; charset=utf-8',
'byteLength': 42,
'downloadToken': 'tok-1',
'filename': null,
'finalUrl': null,
},
),
);
await _flushMicrotasks();
expect(
container.read(metaSearchControllerProvider).capturingByUrl,
isEmpty,
);
});
test('capture messages do not populate documentsByUrl', () async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
final uri = Uri.parse(resultUrl);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
await container
.read(metaSearchControllerProvider.notifier)
.capturePage(uri, choice: FetchMethodChoice.singlefileHtml);
session.emit(
StreamMessage(
type: MessageTypes.capture,
data: {
'captureId': 'abc123',
'url': resultUrl,
'method': 'singlefile',
'variant': 'balanced',
'contentType': 'text/html; charset=utf-8',
'byteLength': 42,
'downloadToken': 'tok-1',
'filename': null,
'finalUrl': null,
},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.documentsByUrl, isEmpty);
expect(state.capturingByUrl, isEmpty);
final captured = state.capturedPage(
uri,
FetchMethodChoice.singlefileHtml,
);
expect(captured, isNotNull);
expect(captured?.captureId, 'abc123');
});
test(
'URL-scoped error clears specific fetching and capturing URLs',
() async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
final uri = Uri.parse(resultUrl);
await container
.read(metaSearchControllerProvider.notifier)
.capturePage(uri, choice: FetchMethodChoice.singlefileHtml);
session.emit(
StreamMessage(
type: MessageTypes.error,
data: {'message': 'Capture failed', 'url': resultUrl},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.capturingByUrl, isEmpty);
expect(state.fetchingUrls, isEmpty);
expect(state.status, WebSearchStatus.ready);
expect(state.results, hasLength(1));
},
);
});
}
Map<String, dynamic> _searchResultsPayload({
required String query,
required List<Map<String, dynamic>> results,
int page = 0,
int pageSize = 10,
int? totalResults,
bool hasMore = false,
}) {
return {
'query': query,
'infos': const <Object>[],
'results': results,
'page': page,
'pageSize': pageSize,
'totalResults': totalResults ?? results.length,
'hasMore': hasMore,
};
}
ProviderContainer _createContainer(_FakeMetaSearchSession session) {
return ProviderContainer(
overrides: [
metaSearchEnsureTokenAvailableProvider.overrideWithValue(
() async => TokenAvailabilityOutcome.available,
),
metaSearchSessionFactoryProvider.overrideWithValue(() async => session),
captureArtifactDownloaderProvider.overrideWith(
(ref) => _FakeCaptureDownloader(),
),
riverpodDatabaseStorageProvider.overrideWith((ref) => Storage.inMemory()),
],
);
}
class _FakeCaptureDownloader implements CaptureArtifactDownloader {
@override
Future<String> download(CaptureArtifactReceipt receipt) async {
return '/fake/path/${receipt.captureId}.html';
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<void> _flushMicrotasks() {
return Future<void>.delayed(Duration.zero);
}
class _FakeMetaSearchSession implements MetaSearchSession {
final _messages = StreamController<StreamMessage>.broadcast();
final List<String> submittedQueries = <String>[];
final List<Uri> fetchRequests = <Uri>[];
final List<_CaptureRequest> captureRequests = <_CaptureRequest>[];
bool _closed = false;
@override
Stream<StreamMessage> get messages => _messages.stream;
@override
bool get isClosed => _closed;
void emit(StreamMessage message) {
_messages.add(message);
}
@override
void fetchPage(Uri url) {
fetchRequests.add(url);
}
@override
void capturePage(
Uri url, {
required String method,
required String variant,
CaptureDimensions? dimensions,
}) {
captureRequests.add(
_CaptureRequest(url: url, method: method, variant: variant),
);
}
@override
Future<void> close() async {
_closed = true;
await _messages.close();
}
@override
void submitQuery(
String query, {
required SearchMode mode,
String? language,
String? region,
SafeSearch? safeSearch,
TimeRange? timeRange,
}) {
submittedQueries.add(query);
}
final List<int> loadPageRequests = <int>[];
@override
void loadPage(int page) {
loadPageRequests.add(page);
}
}
class _CaptureRequest {
final Uri url;
final String method;
final String variant;
_CaptureRequest({
required this.url,
required this.method,
required this.variant,
});
}
@@ -0,0 +1,271 @@
/*
* 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:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:weblibre/features/web_search/domain/services/capture_server.dart';
void main() {
late Directory tempDir;
late CaptureServer server;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('capture_server_test_');
server = CaptureServer(storageDir: tempDir);
});
tearDown(() async {
await server.stop();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
Future<File> writeCapture(String captureId, String html) async {
final file = File(p.join(tempDir.path, '$captureId.html'));
await file.writeAsString(html);
return file;
}
group('captures route', () {
test('serves stored HTML with CSP + nosniff', () async {
await writeCapture('abc123', '<html>hi</html>');
final url = await server.publish('abc123');
final res = await _get(url);
expect(res.statusCode, 200);
expect(res.body, contains('hi'));
expect(
res.headers['content-security-policy'],
contains("script-src 'none'"),
);
expect(res.headers['x-content-type-options'], 'nosniff');
expect(res.headers['cache-control'], 'no-store');
});
test('rejects wrong token with 403', () async {
await writeCapture('abc123', 'x');
await server.publish('abc123');
final base = await server.publish('abc123');
final bad = base.replace(queryParameters: {'t': 'invalid'});
final res = await _get(bad);
expect(res.statusCode, 403);
});
test('rejects unknown capture with 403', () async {
final port = await server.ensureStarted();
final url = Uri.parse(
'http://127.0.0.1:$port/captures/unknown.html?t=irrelevant',
);
final res = await _get(url);
expect(res.statusCode, 403);
});
});
group('loader route', () {
test('pending capture returns long-poll shell + locked CSP', () async {
final url = await server.loaderUrl(tabId: 'tab-1', captureId: 'pending1');
final res = await _get(url);
expect(res.statusCode, 200);
// New design: no meta-refresh; the shell ships an inline long-poll
// script that hits /loader/wait. Sanity-check the script is present
// and the pending UI starts in the visible state.
expect(res.body, contains('Capturing'));
expect(res.body, contains('/loader/wait?tab='));
expect(res.body, contains('id="pending"'));
expect(res.body, contains('window.__CAPTURE_ID__="pending1"'));
// Loader CSP must allow inline script (for the long-poll JS) but
// forbid remote subresources — this is the safety boundary that
// makes the inline script acceptable.
expect(
res.headers['content-security-policy'],
contains("script-src 'unsafe-inline'"),
);
expect(
res.headers['content-security-policy'],
contains("default-src 'none'"),
);
});
test('ready capture exposes captureUrl via /loader/wait', () async {
await writeCapture('ready1', '<html>final</html>');
await server.publish('ready1');
final port = await server.ensureStarted();
// The transition to ready is observed via the long-poll endpoint, not
// a Refresh header on /loader. Hit /loader/wait directly and assert
// it returns the loopback capture URL.
final waitUrl = Uri.parse(
'http://127.0.0.1:$port/loader/wait?tab=tab-1&capture=ready1',
);
final res = await _get(waitUrl);
expect(res.statusCode, 200);
expect(res.headers['content-type'], startsWith('application/json'));
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
expect(decoded['status'], 'ready');
expect(decoded['url'], contains('/captures/ready1.html'));
expect(decoded['url'], contains('t='));
});
test('err=1 starts the loader in the error pane with a retry button',
() async {
final url = await server.loaderUrl(
tabId: 'tab-9',
captureId: 'failed9',
error: true,
);
final res = await _get(url);
expect(res.statusCode, 200);
// Retry is now a JS-driven button, not an HTML form — assert the
// shell starts in error mode (error pane visible, pending hidden)
// and that the button + JS retry endpoint string are present.
expect(res.body, contains('id="retry"'));
expect(res.body, contains('id="error" class="error"'));
expect(res.body, contains('id="pending" class="hidden"'));
expect(res.body, contains('/loader/retry?tab='));
});
test('loader with invalid capture id returns 404', () async {
final port = await server.ensureStarted();
final url = Uri.parse(
'http://127.0.0.1:$port/loader?tab=t&capture=not%2Fclean',
);
final res = await _get(url);
expect(res.statusCode, 404);
});
});
group('/loader/wait', () {
test('failed capture returns status=failed immediately', () async {
await server.publish('cap-failed');
server.markFailed('cap-failed');
final port = await server.ensureStarted();
final res = await _get(
Uri.parse(
'http://127.0.0.1:$port/loader/wait?tab=t&capture=cap-failed',
),
);
expect(res.statusCode, 200);
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
expect(decoded['status'], 'failed');
expect(decoded.containsKey('url'), isFalse);
});
test('publish() wakes an in-flight long-poll', () async {
final port = await server.ensureStarted();
// Start the long-poll while nothing has been published yet — it
// should block on the per-id completer, not busy-poll the filesystem.
final pollFuture = _get(
Uri.parse(
'http://127.0.0.1:$port/loader/wait?tab=t&capture=signaled',
),
);
// Race-free: publish only after the request hit the handler. A short
// microtask flush gives the handler time to register its waiter.
await Future<void>.delayed(const Duration(milliseconds: 100));
await writeCapture('signaled', '<html/>');
await server.publish('signaled');
final res = await pollFuture.timeout(const Duration(seconds: 2));
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
expect(decoded['status'], 'ready');
expect(decoded['url'], contains('/captures/signaled.html'));
});
});
group('retry route', () {
test('emits a RetryRequest on retryRequests stream and returns 204',
() async {
final port = await server.ensureStarted();
final retryFuture = server.retryRequests.first;
final res = await _post(
Uri.parse(
'http://127.0.0.1:$port/loader/retry?tab=tab-42&capture=cap42',
),
);
// 204 No Content — the JS loader re-polls; a body would be wasted.
expect(res.statusCode, 204);
final req = await retryFuture.timeout(const Duration(seconds: 2));
expect(req.tabId, 'tab-42');
expect(req.captureId, 'cap42');
});
test('retry rejects GET with 405', () async {
final port = await server.ensureStarted();
final res = await _get(
Uri.parse('http://127.0.0.1:$port/loader/retry?tab=x&capture=y'),
);
expect(res.statusCode, 405);
});
});
group('isReady', () {
test('false until publish + file exist', () async {
expect(server.isReady('x'), isFalse);
await server.publish('x');
expect(server.isReady('x'), isFalse);
await writeCapture('x', '<html/>');
expect(server.isReady('x'), isTrue);
});
});
}
class _Response {
final int statusCode;
final Map<String, String> headers;
final String body;
_Response(this.statusCode, this.headers, this.body);
}
Future<_Response> _get(Uri url) async {
final client = HttpClient();
try {
final req = await client.getUrl(url);
req.followRedirects = false;
final res = await req.close();
return await _readResponse(res);
} finally {
client.close(force: true);
}
}
Future<_Response> _post(Uri url) async {
final client = HttpClient();
try {
final req = await client.postUrl(url);
req.followRedirects = false;
final res = await req.close();
return await _readResponse(res);
} finally {
client.close(force: true);
}
}
Future<_Response> _readResponse(HttpClientResponse response) async {
final bytes = await response.fold<List<int>>(<int>[], (a, c) => a..addAll(c));
final headers = <String, String>{};
response.headers.forEach((name, values) {
headers[name.toLowerCase()] = values.join(',');
});
return _Response(response.statusCode, headers, utf8.decode(bytes));
}
@@ -0,0 +1,122 @@
/*
* 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/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart';
import 'package:weblibre/features/web_search/presentation/screens/page_preview.dart';
import '../../test_harness.dart';
const _testOpenTarget = WebSearchOpenTarget(
tabMode: RegularTabMode(),
containerSelection: TabContainerSelection.unassigned(),
);
void main() {
testWidgets('renders markdown for a fetched document', (tester) async {
const url = 'https://example.com/article';
final opener = _FakeWebSearchTabOpener();
await tester.pumpWidget(
ProviderScope(
overrides: [
...webSearchTestOverrides(),
metaSearchControllerProvider.overrideWithValue(
MetaSearchState(
status: WebSearchStatus.ready,
query: 'lensai',
results: [
CompactSearchResult(
title: 'LensAI article',
url: Uri.parse(url),
),
],
documentsByUrl: {
Uri.parse(url): FetchedDocument(
url: Uri.parse(url),
content: '# Heading\n\nBody copy',
metadata: PageMetadata(
title: 'LensAI article',
author: 'Fabian',
),
),
},
),
),
webSearchTabOpenerProvider.overrideWithValue(opener),
watchCachedIconBytesProvider.overrideWith((ref, origin) {
return Stream.value(null);
}),
],
child: MaterialApp(
home: PagePreviewScreen(
uri: Uri.parse(url),
resolveOpenTarget: () => _testOpenTarget,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Heading'), findsOneWidget);
expect(find.text('Body copy'), findsOneWidget);
expect(find.text('Fabian'), findsOneWidget);
await tester.tap(find.byTooltip('Open in browser'));
await tester.pumpAndSettle();
expect(opener.openedUris, [Uri.parse(url)]);
});
}
class _FakeWebSearchTabOpener implements WebSearchTabOpener {
final List<Uri> openedUris = <Uri>[];
final List<String> openedCaptureIds = <String>[];
@override
Future<void> open(
BuildContext context,
WidgetRef ref,
Uri uri, {
required WebSearchOpenTarget target,
}) async {
openedUris.add(uri);
}
@override
Future<void> openCapture(
BuildContext context,
WidgetRef ref, {
required String captureId,
required Uri sourceUrl,
required WebSearchOpenTarget target,
String? contentType,
String? method,
String? variant,
}) async {
openedCaptureIds.add(captureId);
}
}
@@ -0,0 +1,122 @@
/*
* 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:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/presentation/widgets/search_result_card.dart';
import '../../test_harness.dart';
void main() {
testWidgets('tapping a result uses the open callback', (tester) async {
final openedUris = <Uri>[];
const url = 'https://example.com/result';
await tester.pumpWidget(
ProviderScope(
overrides: [
...webSearchTestOverrides(),
metaSearchControllerProvider.overrideWithValue(
MetaSearchState(status: WebSearchStatus.ready),
),
watchCachedIconBytesProvider.overrideWith((ref, origin) {
return Stream.value(null);
}),
],
child: MaterialApp(
home: Scaffold(
body: WebSearchResultCard(
result: CompactSearchResult(
title: 'LensAI result',
url: Uri.parse(url),
content: 'Result summary',
),
onOpen: (uri) async {
openedUris.add(uri);
},
onFetch: (_) async {},
onPreview: (_) async {},
onOpenCapture: (_) async {},
),
),
),
),
);
await tester.tap(find.text('LensAI result'));
await tester.pumpAndSettle();
expect(openedUris, [Uri.parse(url)]);
});
testWidgets(
'renders thumbnail image when imgSrc is empty but thumbnail bytes exist',
(tester) async {
const thumbnailUrl = 'https://example.com/thumb.png';
final pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
);
await tester.pumpWidget(
ProviderScope(
overrides: [
...webSearchTestOverrides(),
metaSearchControllerProvider.overrideWithValue(
MetaSearchState(
status: WebSearchStatus.ready,
imagesByUrl: {thumbnailUrl: Uint8List.fromList(pngBytes)},
),
),
watchCachedIconBytesProvider.overrideWith((ref, origin) {
return Stream.value(null);
}),
],
child: MaterialApp(
home: Scaffold(
body: WebSearchResultCard(
result: CompactSearchResult(
title: 'LensAI result',
url: Uri.parse('https://example.com/result'),
content: 'Result summary',
imgSrc: '',
thumbnail: thumbnailUrl,
),
onOpen: (_) async {},
onFetch: (_) async {},
onPreview: (_) async {},
onOpenCapture: (_) async {},
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(Image), findsOneWidget);
},
);
}
@@ -0,0 +1,81 @@
/*
* 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/>.
*/
/// Shared fixtures for web_search presentation tests.
///
/// `UrlIcon` (used by every result card / preview / infobox) reaches into
/// `userDatabaseProvider` → `cacheRepositoryProvider` and
/// `geckoIconServiceProvider`. Both touch native code that isn't available in
/// the test runtime, so we wire in-memory + no-op fakes via this helper to
/// keep individual test setup small.
library;
import 'dart:typed_data';
import 'package:drift/native.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod/misc.dart' show Override;
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/domain/services/generic_website.dart';
import 'package:weblibre/features/user/data/database/database.dart';
import 'package:weblibre/features/user/data/providers.dart';
/// Builds the standard set of overrides that any web_search presentation test
/// needs to render `UrlIcon` / settings-backed widgets without touching the
/// real filesystem profile databases. Registers `addTearDown` for the
/// in-memory drift db so the caller doesn't have to.
List<Override> webSearchTestOverrides() {
final userDb = UserDatabase(
NativeDatabase.memory(
setup: (database) => registerLexorankFunctions(database),
),
);
addTearDown(userDb.close);
return [
userDatabaseProvider.overrideWith((ref) => userDb),
riverpodDatabaseStorageProvider.overrideWith((ref) => Storage.inMemory()),
geckoIconServiceProvider.overrideWithValue(_FakeGeckoIconService()),
];
}
/// No-op GeckoIconService — returns a generator-source icon with empty bytes
/// so the cacheOnly branch in UrlIcon falls through to the MdiIcons.web
/// fallback. The real service is a platform-channel adapter and would crash
/// in the test runtime.
final class _FakeGeckoIconService extends GeckoIconService {
@override
Future<IconResult> loadIcon({
required Uri url,
List<Resource> resources = const [],
IconSize size = IconSize.defaultSize,
bool isPrivate = false,
bool waitOnNetworkLoad = true,
}) async {
return IconResult(
image: Uint8List(0),
color: null,
source: IconSource.generator,
maskable: false,
);
}
}
@@ -0,0 +1,168 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/domain/services/favicon_resolver.dart';
import 'package:weblibre/domain/services/generic_website.dart';
import 'package:weblibre/features/user/data/database/database.dart';
import 'package:weblibre/features/user/data/providers.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('selectFirstCachedIconBytes preserves url-list fallback order', () {
final secondBytes = Uint8List.fromList([2, 3, 4]);
final selected = selectFirstCachedIconBytes([
null,
secondBytes,
Uint8List.fromList([9, 9, 9]),
]);
expect(selected, same(secondBytes));
});
testWidgets('distinct cached icons do not collapse to one image', (
tester,
) async {
final firstUrl = Uri.parse('https://first.example/result');
final secondUrl = Uri.parse('https://second.example/result');
final db = UserDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
},
),
);
addTearDown(() async {
await db.close();
});
await tester.pumpWidget(
ProviderScope(
overrides: [
userDatabaseProvider.overrideWith((ref) => db),
faviconResolverProvider.overrideWithValue(_NeverCalledResolver()),
geckoIconServiceProvider.overrideWithValue(_FakeGeckoIconService()),
watchCachedIconBytesProvider(
firstUrl.origin,
).overrideWith((ref) => Stream.value(_firstSvgBytes)),
watchCachedIconBytesProvider(
secondUrl.origin,
).overrideWith((ref) => Stream.value(_secondSvgBytes)),
],
child: MaterialApp(
home: Row(
children: [
UrlIcon([firstUrl], iconSize: 24, cacheOnly: true),
UrlIcon([secondUrl], iconSize: 24, cacheOnly: true),
],
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final rawImages = tester
.widgetList<RawImage>(find.byType(RawImage))
.toList();
expect(rawImages, hasLength(2));
expect(identical(rawImages[0].image, rawImages[1].image), isFalse);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
});
testWidgets('cacheOnly falls back to a generated icon on cache miss', (
tester,
) async {
final db = UserDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
},
),
);
addTearDown(() async {
await db.close();
});
await tester.pumpWidget(
ProviderScope(
overrides: [
userDatabaseProvider.overrideWith((ref) => db),
faviconResolverProvider.overrideWithValue(_NeverCalledResolver()),
geckoIconServiceProvider.overrideWithValue(_FakeGeckoIconService()),
],
child: MaterialApp(
home: UrlIcon(
[Uri.parse('https://generated.example')],
iconSize: 24,
cacheOnly: true,
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(RawImage), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
});
}
final class _NeverCalledResolver implements FaviconResolver {
@override
Future<FaviconResolveResult> resolve(Uri url, {int? proxyPort}) async {
throw UnimplementedError('cacheOnly should not hit the resolver');
}
}
final class _FakeGeckoIconService extends GeckoIconService {
@override
Future<IconResult> loadIcon({
required Uri url,
List<Resource> resources = const [],
IconSize size = IconSize.defaultSize,
bool isPrivate = false,
bool waitOnNetworkLoad = true,
}) async {
return IconResult(
image: _firstSvgBytes,
color: null,
source: IconSource.generator,
maskable: false,
);
}
}
final _firstSvgBytes = Uint8List.fromList(
utf8.encode('''
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<rect width="16" height="16" fill="#ff0000"/>
</svg>
'''),
);
final _secondSvgBytes = Uint8List.fromList(
utf8.encode('''
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="8" fill="#0000ff"/>
</svg>
'''),
);
@@ -0,0 +1,62 @@
/*
* 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_test/flutter_test.dart';
import 'package:weblibre/utils/url_canonical.dart';
void main() {
group('canonicalizeUrl', () {
test('canonicalizes http URLs structurally', () {
final canonical = canonicalizeUrl(
'HTTPS://Example.com:443/path?q=1#fragment',
);
expect(canonical, isNotNull);
expect(canonical!.canonical, 'https://example.com/path?q=1');
expect(canonical.host, 'example.com');
expect(canonical.path, '/path');
});
test('preserves about reader URL shape', () {
const readerUrl = 'about:reader?url=https%3A%2F%2Fexample.com%2Fstory';
final canonical = canonicalizeUrl(readerUrl);
expect(canonical, isNotNull);
expect(canonical!.canonical, readerUrl);
expect(canonical.host, isEmpty);
expect(canonical.path, 'reader');
expect(Uri.parse(canonical.canonical).toString(), readerUrl);
});
});
group('isUrlIndexable', () {
test('accepts about reader URLs', () {
expect(
isUrlIndexable('about:reader?url=https%3A%2F%2Fexample.com%2Fstory'),
isTrue,
);
});
test('rejects other about URLs', () {
expect(isUrlIndexable('about:config'), isFalse);
});
});
}