Add Supa account and search changes
This commit is contained in:
@@ -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++;
|
||||
}
|
||||
}
|
||||
+173
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+719
@@ -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);
|
||||
}
|
||||
}
|
||||
+122
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user