Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -9,6 +9,9 @@ import 'schema_v2.dart' as v2;
import 'schema_v3.dart' as v3;
import 'schema_v4.dart' as v4;
import 'schema_v5.dart' as v5;
import 'schema_v6.dart' as v6;
import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -24,10 +27,16 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v4.DatabaseAtV4(db);
case 5:
return v5.DatabaseAtV5(db);
case 6:
return v6.DatabaseAtV6(db);
case 7:
return v7.DatabaseAtV7(db);
case 8:
return v8.DatabaseAtV8(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5];
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8];
}
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
@@ -0,0 +1,174 @@
import 'dart:async';
import 'dart:ui';
import 'package:drift/native.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_tor/flutter_tor.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/data/database/functions/url_functions.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'container assignments ensure sing-box profiles are running before syncing',
() async {
final profileId = SingboxProxyConnectionId('profile-1');
final assignedContainer = _container(
id: 'container-1',
contextId: 'context-a',
proxyConnectionId: profileId,
);
final db = TabDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
registerUrlFunctions(database);
},
),
);
final containerProxyRepository = _FakeContainerProxyRepository();
final containerRepository = _FakeContainerRepository([assignedContainer]);
final runtimeRepository = _FakeSingboxProxyRuntimeRepository();
final container = ProviderContainer(
overrides: [
tabDatabaseProvider.overrideWith((ref) => db),
containerProxyRepositoryProvider.overrideWith(
() => containerProxyRepository,
),
containerRepositoryProvider.overrideWith(() => containerRepository),
singboxProxyRuntimeRepositoryProvider.overrideWith(
() => runtimeRepository,
),
torProxyServiceProvider.overrideWith(_FakeTorProxyService.new),
proxyRoutingSettingsWithDefaultsProvider.overrideWith(
(ref) => ProxyRoutingSettings.withDefaults(),
),
watchContainersWithCountProvider.overrideWith(
(ref) => Stream.value([assignedContainer]),
),
watchAllAssignedSitesProvider.overrideWith(
(ref) => Stream.value(const <SiteAssignment>[]),
),
watchIsolatedContextContainerMapProvider.overrideWith(
(ref) => Stream.value(const <String, Set<String>>{}),
),
],
);
addTearDown(() async {
container.dispose();
await db.close();
});
final subscription = container.listen<void>(
proxySettingsReplicationProvider,
(previous, next) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await pumpEventQueue();
expect(runtimeRepository.ensuredProxyConnectionIds, [profileId]);
expect(containerProxyRepository.setContainerProxyCalls, [
('context-a', profileId.encode()),
]);
},
);
}
ContainerDataWithCount _container({
required String id,
required String contextId,
required ProxyConnectionId proxyConnectionId,
}) {
return ContainerDataWithCount(
id: id,
name: 'Container',
color: const Color(0xFF336699),
orderKey: 'a',
metadata: ContainerMetadata.withDefaults(
contextualIdentity: contextId,
proxyConnectionId: proxyConnectionId,
),
tabCount: 0,
);
}
class _FakeContainerProxyRepository extends ContainerProxyRepository {
final setContainerProxyCalls = <(String, String)>[];
@override
Future<void> setTorProxyPort(int? port) async {}
@override
Future<void> setSiteAssignments(List<SiteAssignment> assignements) async {}
@override
Future<void> setContainerProxy(String contextId, String proxyId) async {
setContainerProxyCalls.add((contextId, proxyId));
}
@override
Future<void> clearContainerProxy(String contextId) async {}
@override
void build() {}
}
class _FakeContainerRepository extends ContainerRepository {
final List<ContainerDataWithCount> containers;
_FakeContainerRepository(this.containers);
@override
Future<List<ContainerDataWithCount>> getAllContainersWithCount() async {
return containers;
}
@override
void build() {}
}
class _FakeSingboxProxyRuntimeRepository extends SingboxProxyRuntimeRepository {
final ensuredProxyConnectionIds = <SingboxProxyConnectionId>[];
@override
Future<void> ensureProxyConnectionAvailable(
SingboxProxyConnectionId connectionId,
) async {
ensuredProxyConnectionIds.add(connectionId);
}
@override
Future<SingboxProxyRuntimeState> build() async {
return SingboxProxyRuntimeState(
status: SingboxProxyRuntimeStatus.stopped,
endpoints: [],
);
}
}
class _FakeTorProxyService extends TorProxyService {
@override
Stream<TorStatus> build() async* {
// Mirror production: the real TorProxyService seeds the provider with the
// current status so downstream `selectAsync` paths don't stay in
// AsyncLoading forever. Tor is stopped in these tests.
yield TorStatus(isRunning: false, bootstrapProgress: 0);
}
}
@@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
void main() {
group('ContainerMetadata proxy serialization', () {
test('round-trips proxyConnectionId through JSON', () {
final metadata = ContainerMetadata.withDefaults(
proxyConnectionId: const SingboxProxyConnectionId('profile-1'),
clearDataOnExit: true,
);
final json = metadata.toJson();
final restored = ContainerMetadata.fromJson(json);
expect(
json['proxyConnectionId'],
const SingboxProxyConnectionId('profile-1').encode(),
);
expect(json['clearDataOnExit'], isTrue);
expect(restored.proxyConnectionId, metadata.proxyConnectionId);
expect(restored.usesTorProxy, isFalse);
});
test('usesTorProxy is true only for the Tor connection id', () {
final tor = ContainerMetadata.withDefaults(
proxyConnectionId: const TorProxyConnectionId(),
);
expect(tor.usesTorProxy, isTrue);
});
});
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
void main() {
testWidgets(
'clearing proxy selection does not leave cookie isolation enabled in create mode',
(tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
proxyConnectionOptionsProvider.overrideWith(
(ref) => const <ProxyConnectionOption>[],
),
],
child: MaterialApp(
home: ContainerEditScreen.create(
initialContainer: ContainerData(
id: 'container-1',
color: Colors.blue,
orderKey: 'a',
metadata: ContainerMetadata.withDefaults(
proxyConnectionId: SingboxProxyConnectionId('missing-proxy'),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Proxy Connection'));
await tester.pumpAndSettle();
await tester.tap(find.text('Clear'));
await tester.pumpAndSettle();
final cookieIsolationTile = tester.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Cookie Isolation'),
);
expect(cookieIsolationTile.value, isFalse);
expect(find.text('None'), findsOneWidget);
},
);
testWidgets(
'dismissing proxy picker does not leave cookie isolation enabled in create mode',
(tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
proxyConnectionOptionsProvider.overrideWith(
(ref) => const <ProxyConnectionOption>[],
),
],
child: MaterialApp(
home: ContainerEditScreen.create(
initialContainer: ContainerData(
id: 'container-1',
color: Colors.blue,
orderKey: 'a',
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Proxy Connection'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(8, 8));
await tester.pumpAndSettle();
final cookieIsolationTile = tester.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Cookie Isolation'),
);
expect(cookieIsolationTile.value, isFalse);
expect(find.text('New Container'), findsOneWidget);
},
);
}
@@ -0,0 +1,345 @@
import 'dart:convert';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
import 'package:weblibre/features/proxy/data/parsers/singbox_proxy_uri.dart';
void main() {
group('SingboxProxyFormSpec', () {
test('builds public and secret JSON for Shadowsocks', () {
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.shadowsocks]!;
final values = {
'server': 'ss.example.com',
'server_port': '8388',
'method': '2022-blake3-aes-128-gcm',
'password': 'secret',
};
expect(spec.validate(values), isNull);
final config = jsonDecode(spec.toConfigJson(values));
final secrets = jsonDecode(spec.toSecretJson(values)!);
expect(config, {
'type': 'shadowsocks',
'server': 'ss.example.com',
'server_port': 8388,
'method': '2022-blake3-aes-128-gcm',
});
expect(secrets, {'password': 'secret'});
});
test('hydrates structured values from public and secret JSON', () {
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.vless]!;
final values = spec.valuesFromJson(
configJson: '''
{
"type": "vless",
"server": "vless.example.com",
"server_port": 443,
"flow": "xtls-rprx-vision"
}''',
secretJson: '{"uuid":"00000000-0000-0000-0000-000000000000"}',
);
expect(values['server'], 'vless.example.com');
expect(values['server_port'], '443');
expect(values['uuid'], '00000000-0000-0000-0000-000000000000');
expect(values['flow'], 'xtls-rprx-vision');
});
test('rejects invalid server ports', () {
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.http]!;
expect(
spec.validate({'server': 'proxy.example.com', 'server_port': '70000'}),
'Server Port must be between 1 and 65535.',
);
});
test('builds nested advanced config fields', () {
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.vless]!;
final values = {
'server': 'vless.example.com',
'server_port': '443',
'uuid': '00000000-0000-0000-0000-000000000000',
'flow': 'xtls-rprx-vision',
'tls.enabled': 'true',
'tls.server_name': 'front.example.com',
'tls.alpn': 'h2, http/1.1',
'transport.type': 'ws',
'transport.path': '/proxy',
'multiplex.enabled': 'true',
'multiplex.max_connections': '4',
'domain_strategy': 'prefer_ipv4',
};
expect(spec.validate(values), isNull);
final config =
jsonDecode(spec.toConfigJson(values)) as Map<String, dynamic>;
final secrets =
jsonDecode(spec.toSecretJson(values)!) as Map<String, dynamic>;
expect(config['tls'], {
'enabled': true,
'server_name': 'front.example.com',
'alpn': ['h2', 'http/1.1'],
});
expect(config['transport'], {'type': 'ws', 'path': '/proxy'});
expect(config['multiplex'], {'enabled': true, 'max_connections': 4});
expect(config['domain_strategy'], 'prefer_ipv4');
expect(secrets, {'uuid': '00000000-0000-0000-0000-000000000000'});
});
test('builds public and secret JSON for WireGuard', () {
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.wireguard]!;
final values = {
'server': 'wg.example.com',
'server_port': '51820',
'local_address': '10.0.0.2/32\nfd00::2/128',
'private_key': 'private-key',
'peer_public_key': 'peer-public-key',
'pre_shared_key': 'pre-shared-key',
'mtu': '1420',
'reserved': '1, 2, 3',
};
expect(spec.validate(values), isNull);
final config = jsonDecode(spec.toConfigJson(values));
final secrets = jsonDecode(spec.toSecretJson(values)!);
expect(config, {
'type': 'wireguard',
'server': 'wg.example.com',
'server_port': 51820,
'local_address': ['10.0.0.2/32', 'fd00::2/128'],
'peer_public_key': 'peer-public-key',
'mtu': 1420,
'reserved': [1, 2, 3],
});
expect(secrets, {
'private_key': 'private-key',
'pre_shared_key': 'pre-shared-key',
});
});
test('hydrates WireGuard values from public and secret JSON', () {
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.wireguard]!;
final values = spec.valuesFromJson(
configJson: '''
{
"type": "wireguard",
"server": "wg.example.com",
"server_port": 51820,
"local_address": ["10.0.0.2/32"],
"peer_public_key": "peer-public-key",
"mtu": 1420,
"reserved": [1, 2, 3]
}''',
secretJson: '''
{
"private_key": "private-key",
"pre_shared_key": "pre-shared-key"
}''',
);
expect(values['server'], 'wg.example.com');
expect(values['server_port'], '51820');
expect(values['local_address'], '10.0.0.2/32');
expect(values['private_key'], 'private-key');
expect(values['peer_public_key'], 'peer-public-key');
expect(values['pre_shared_key'], 'pre-shared-key');
expect(values['mtu'], '1420');
expect(values['reserved'], '1\n2\n3');
});
test('rejects invalid WireGuard reserved bytes', () {
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.wireguard]!;
expect(
spec.validate({
'server': 'wg.example.com',
'server_port': '51820',
'local_address': '10.0.0.2/32',
'private_key': 'private-key',
'peer_public_key': 'peer-public-key',
'mtu': '1420',
'reserved': '1, 2',
}),
'Reserved Bytes must contain 3 numbers.',
);
});
});
group('importSingboxProxyUri', () {
test('imports Shadowsocks SIP002 URI', () {
final credentials = base64Url.encode(
utf8.encode('aes-256-gcm:secret-password'),
);
final imported = importSingboxProxyUri(
'ss://$credentials@ss.example.com:8388#Work%20SS',
);
expect(imported.type, SingboxProxyProfileType.shadowsocks);
expect(imported.name, 'Work SS');
expect(imported.values, {
'server': 'ss.example.com',
'server_port': '8388',
'method': 'aes-256-gcm',
'password': 'secret-password',
});
});
test('imports legacy base64 Shadowsocks URI', () {
final payload = base64Url.encode(
utf8.encode('chacha20-ietf-poly1305:secret@[2001:db8::1]:8388'),
);
final imported = importSingboxProxyUri('ss://$payload');
expect(imported.type, SingboxProxyProfileType.shadowsocks);
expect(imported.values['server'], '2001:db8::1');
expect(imported.values['server_port'], '8388');
expect(imported.values['method'], 'chacha20-ietf-poly1305');
expect(imported.values['password'], 'secret');
});
test('imports Trojan URI', () {
final imported = importSingboxProxyUri(
'trojan://secret%20password@trojan.example.com:443#Trojan',
);
expect(imported.type, SingboxProxyProfileType.trojan);
expect(imported.name, 'Trojan');
expect(imported.values, {
'server': 'trojan.example.com',
'server_port': '443',
'password': 'secret password',
'tls.enabled': 'true',
});
});
test('imports VLESS URI', () {
final imported = importSingboxProxyUri(
'vless://00000000-0000-0000-0000-000000000000@vless.example.com:443?flow=xtls-rprx-vision#VLESS',
);
expect(imported.type, SingboxProxyProfileType.vless);
expect(imported.name, 'VLESS');
expect(imported.values, {
'server': 'vless.example.com',
'server_port': '443',
'uuid': '00000000-0000-0000-0000-000000000000',
'flow': 'xtls-rprx-vision',
});
});
test('imports VMess URI', () {
final payload = base64Url.encode(
utf8.encode(
jsonEncode({
'v': '2',
'ps': 'VMess',
'add': 'vmess.example.com',
'port': '443',
'id': '00000000-0000-0000-0000-000000000000',
'aid': '0',
'scy': 'auto',
}),
),
);
final imported = importSingboxProxyUri('vmess://$payload');
expect(imported.type, SingboxProxyProfileType.vmess);
expect(imported.name, 'VMess');
expect(imported.values, {
'server': 'vmess.example.com',
'server_port': '443',
'uuid': '00000000-0000-0000-0000-000000000000',
'security': 'auto',
'alter_id': '0',
'tls.enabled': '',
'tls.server_name': '',
'transport.type': '',
'transport.path': '',
});
});
test('imports SOCKS URI', () {
final imported = importSingboxProxyUri(
'socks://user:secret@socks.example.com:1080#SOCKS',
);
expect(imported.type, SingboxProxyProfileType.socks);
expect(imported.name, 'SOCKS');
expect(imported.values, {
'server': 'socks.example.com',
'server_port': '1080',
'version': '5',
'username': 'user',
'password': 'secret',
});
});
test('imports HTTPS proxy URI', () {
final imported = importSingboxProxyUri(
'https://user:secret@proxy.example.com:443?sni=proxy.example.com#HTTP',
);
expect(imported.type, SingboxProxyProfileType.http);
expect(imported.values['server'], 'proxy.example.com');
expect(imported.values['server_port'], '443');
expect(imported.values['username'], 'user');
expect(imported.values['password'], 'secret');
expect(imported.values['tls.enabled'], 'true');
expect(imported.values['tls.server_name'], 'proxy.example.com');
});
test('imports Hysteria2 URI', () {
final imported = importSingboxProxyUri(
'hy2://secret@hy2.example.com:443?obfs=salamander&obfs-password=obfs-secret&sni=front.example.com#HY2',
);
expect(imported.type, SingboxProxyProfileType.hysteria2);
expect(imported.name, 'HY2');
expect(imported.values['server'], 'hy2.example.com');
expect(imported.values['server_port'], '443');
expect(imported.values['password'], 'secret');
expect(imported.values['obfs.type'], 'salamander');
expect(imported.values['obfs.password'], 'obfs-secret');
expect(imported.values['tls.enabled'], 'true');
expect(imported.values['tls.server_name'], 'front.example.com');
});
test('imports TUIC URI', () {
final imported = importSingboxProxyUri(
'tuic://00000000-0000-0000-0000-000000000000:secret@tuic.example.com:443?congestion_control=bbr&udp_relay_mode=quic&sni=front.example.com#TUIC',
);
expect(imported.type, SingboxProxyProfileType.tuic);
expect(imported.name, 'TUIC');
expect(imported.values['server'], 'tuic.example.com');
expect(imported.values['server_port'], '443');
expect(imported.values['uuid'], '00000000-0000-0000-0000-000000000000');
expect(imported.values['password'], 'secret');
expect(imported.values['congestion_control'], 'bbr');
expect(imported.values['udp_relay_mode'], 'quic');
expect(imported.values['tls.enabled'], 'true');
expect(imported.values['tls.server_name'], 'front.example.com');
});
test('rejects unsupported URI schemes', () {
expect(
() => importSingboxProxyUri('hysteria2://example.com:443'),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/proxy/data/models/wireguard_config_import.dart';
void main() {
group('WireguardConfigImport', () {
test('imports standard WireGuard config text', () {
final imported = WireguardConfigImport.fromConfigText('''
[Interface]
PrivateKey = private-key
Address = 10.0.0.2/32, fd00::2/128
MTU = 1420
DNS = 1.1.1.1
[Peer]
PublicKey = peer-public-key
PresharedKey = pre-shared-key
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = wg.example.com:51820
''');
expect(imported.values, {
'server': 'wg.example.com',
'server_port': '51820',
'local_address': '10.0.0.2/32\nfd00::2/128',
'private_key': 'private-key',
'peer_public_key': 'peer-public-key',
'pre_shared_key': 'pre-shared-key',
'mtu': '1420',
});
expect(imported.primaryDnsAddress, 'udp://1.1.1.1');
});
test('imports bracketed IPv6 WireGuard endpoint', () {
final imported = WireguardConfigImport.fromConfigText('''
[Interface]
PrivateKey = private-key
Address = fd00::2/128
[Peer]
PublicKey = peer-public-key
Endpoint = [2001:db8::1]:51820
''');
expect(imported.values['server'], '2001:db8::1');
expect(imported.values['server_port'], '51820');
expect(imported.values['mtu'], '1408');
});
test('rejects configs without interface and peer sections', () {
expect(
() => WireguardConfigImport.fromConfigText('PrivateKey = private-key'),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,66 @@
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
void main() {
group('proxyConnectionOptionsProvider', () {
test('returns Tor and saved sing-box profiles', () async {
final createdAt = DateTime(2026);
final container = ProviderContainer(
overrides: [
singboxProxyProfilesRepositoryProvider.overrideWith(
() => _FakeProfilesRepository([
ProxyProfile(
id: 'profile-1',
name: 'Mullvad',
type: SingboxProxyProfileType.customOutbound,
configJson: '{"type":"socks"}',
createdAt: createdAt,
updatedAt: createdAt,
),
]),
),
],
);
addTearDown(container.dispose);
container.listen(
singboxProxyProfilesRepositoryProvider,
(_, _) {},
fireImmediately: true,
);
await Future<void>.delayed(Duration.zero);
final options = container.read(proxyConnectionOptionsProvider);
expect(options.map((option) => option.id), [
const TorProxyConnectionId(),
const SingboxProxyConnectionId('profile-1'),
]);
expect(options.map((option) => option.title), ['Tor', 'Mullvad']);
});
test('labels unknown proxy ids explicitly', () {
expect(
proxyConnectionTitle(
const [],
const SingboxProxyConnectionId('missing'),
),
'Unknown proxy',
);
});
});
}
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
final List<ProxyProfile> profiles;
_FakeProfilesRepository(this.profiles);
@override
Stream<List<ProxyProfile>> build() => Stream.value(profiles);
}
@@ -0,0 +1,155 @@
import 'package:drift/native.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/user/data/database/database.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/providers.dart';
void main() {
test(
'createProfile persists metadata and writes secrets separately',
() async {
final credentials = _FakeCredentialsRepository();
final container = _container(credentials);
addTearDown(container.dispose);
final repository = container.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
final created = await repository.createProfile(
name: 'Mullvad WG',
type: SingboxProxyProfileType.wireguard,
configJson: '{"server":"vpn.example"}',
secretJson: '{"private_key":"secret"}',
);
final profiles = await repository.fetchProfiles();
expect(profiles, hasLength(1));
expect(profiles.single.id, created.id);
expect(profiles.single.name, 'Mullvad WG');
expect(profiles.single.type, SingboxProxyProfileType.wireguard);
expect(profiles.single.configJson, '{"server":"vpn.example"}');
expect(credentials.writtenSecrets, {
created.id: '{"private_key":"secret"}',
});
},
);
test(
'updateProfile updates metadata without touching secrets by default',
() async {
final credentials = _FakeCredentialsRepository();
final container = _container(credentials);
addTearDown(container.dispose);
final repository = container.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
final profile = _profile(id: 'profile-1', name: 'Original');
await repository.updateProfile(profile);
await repository.updateProfile(
profile.copyWith(name: 'Updated', configJson: '{"type":"http"}'),
);
final profiles = await repository.fetchProfiles();
expect(profiles, hasLength(1));
expect(profiles.single.id, 'profile-1');
expect(profiles.single.name, 'Updated');
expect(profiles.single.configJson, '{"type":"http"}');
expect(credentials.writtenSecrets, isEmpty);
},
);
test('updateProfile writes secrets only when requested', () async {
final credentials = _FakeCredentialsRepository();
final container = _container(credentials);
addTearDown(container.dispose);
final repository = container.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
final profile = _profile(id: 'profile-1', name: 'SOCKS');
await repository.updateProfile(
profile,
secretJson: '{"password":"secret"}',
);
expect(credentials.writtenSecrets, {'profile-1': '{"password":"secret"}'});
});
test('deleteProfile removes metadata and deletes secrets', () async {
final credentials = _FakeCredentialsRepository();
final container = _container(credentials);
addTearDown(container.dispose);
final repository = container.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
await repository.updateProfile(
_profile(id: 'profile-1', name: 'SOCKS'),
secretJson: '{"password":"secret"}',
);
await repository.deleteProfile('profile-1');
expect(await repository.fetchProfiles(), isEmpty);
expect(credentials.deletedSecretIds, ['profile-1']);
});
}
ProviderContainer _container(_FakeCredentialsRepository credentials) {
final db = UserDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
},
),
);
addTearDown(db.close);
return ProviderContainer(
overrides: [
userDatabaseProvider.overrideWith((ref) => db),
singboxProxyCredentialsRepositoryProvider.overrideWith(() => credentials),
],
);
}
ProxyProfile _profile({required String id, required String name}) {
final createdAt = DateTime(2026);
return ProxyProfile(
id: id,
name: name,
type: SingboxProxyProfileType.socks,
configJson: '{"type":"socks"}',
createdAt: createdAt,
updatedAt: createdAt,
);
}
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
final writtenSecrets = <String, String?>{};
final deletedSecretIds = <String>[];
@override
Future<void> writeSecretJson(String profileId, String? secretJson) async {
writtenSecrets[profileId] = secretJson;
}
@override
Future<void> deleteSecretJson(String profileId) async {
deletedSecretIds.add(profileId);
}
@override
void build() {}
}
@@ -0,0 +1,451 @@
import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/domain/services/singbox_proxy_endpoint_sync.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'ensureProxyConnectionAvailable starts an assigned stopped profile',
() async {
final profile = _profile(id: 'profile-1', name: 'First');
final client = _FakeSingboxProxyClient(_state(const []));
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile]),
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
final repository = container.read(
singboxProxyRuntimeRepositoryProvider.notifier,
);
await repository.ensureProxyConnectionAvailable(
SingboxProxyConnectionId(profile.id),
);
await repository.ensureProxyConnectionAvailable(
SingboxProxyConnectionId(profile.id),
);
expect(client.startCalls, hasLength(1));
expect(
client.startCalls.single.map((runtimeProfile) => runtimeProfile.id),
[profile.proxyConnectionId],
);
},
);
test('startProfile preserves already-running profiles', () async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
final client = _FakeSingboxProxyClient(
_state([_endpoint(profile1.proxyConnectionId, port: 12080)]),
);
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
await container
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.startProfile(profile2.id);
expect(client.startCalls, hasLength(1));
expect(
client.startCalls.single.map((profile) => profile.id),
unorderedEquals([profile1.proxyConnectionId, profile2.proxyConnectionId]),
);
});
test(
'concurrent startProfile calls preserve both requested profiles',
() async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
final client = _FakeSingboxProxyClient(_state(const []));
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
final repository = container.read(
singboxProxyRuntimeRepositoryProvider.notifier,
);
await Future.wait([
repository.startProfile(profile1.id),
repository.startProfile(profile2.id),
]);
expect(client.startCalls, hasLength(2));
expect(
client.startCalls.last.map((profile) => profile.id),
unorderedEquals([
profile1.proxyConnectionId,
profile2.proxyConnectionId,
]),
);
expect(
container
.read(singboxProxyRuntimeRepositoryProvider)
.requireValue
.endpoints
.map((endpoint) => endpoint.profileId),
unorderedEquals([
profile1.proxyConnectionId,
profile2.proxyConnectionId,
]),
);
},
);
test('stopProfiles unregisters removed Gecko proxy endpoints', () async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
final client =
_FakeSingboxProxyClient(
_state([
_endpoint(profile1.proxyConnectionId, port: 12080),
_endpoint(profile2.proxyConnectionId, port: 12081),
]),
)
..stateAfterStop = _state([
_endpoint(profile1.proxyConnectionId, port: 12080),
]);
final containerProxyRepository = _FakeContainerProxyRepository();
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
containerProxyRepository: containerProxyRepository,
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
await container
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.stopProfiles([profile2.id]);
await _drainSync();
expect(client.stopCalls, [profile2.proxyConnectionId]);
expect(containerProxyRepository.removedProxyIds, [
profile2.proxyConnectionId,
]);
});
test(
'deleteProfile stops runtime and preserves persisted references',
() async {
final profile = _profile(id: 'profile-1', name: 'First');
final client = _FakeSingboxProxyClient(
_state([_endpoint(profile.proxyConnectionId, port: 12080)]),
)..stateAfterStop = _state(const []);
final profilesRepository = _FakeProfilesRepository([profile]);
final containerRepository = _FakeContainerRepository();
final routingSettingsRepository = _FakeProxyRoutingSettingsRepository(
ProxyRoutingSettings(
regularTabsMode: ProxyRegularTabRoutingMode.all,
regularTabsProxyConnectionId: profile.proxyConnection,
privateTabsProxyConnectionId: profile.proxyConnection,
),
);
final container = _container(
client: client,
profilesRepository: profilesRepository,
containerRepository: containerRepository,
routingSettingsRepository: routingSettingsRepository,
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
await container
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.deleteProfile(profile.id);
expect(client.stopCalls, [profile.proxyConnectionId]);
expect(containerRepository.clearedProxyConnectionIds, isEmpty);
final routingSettings = routingSettingsRepository.peekSettings();
expect(routingSettings, isNotNull);
expect(routingSettings!.regularTabsMode, ProxyRegularTabRoutingMode.all);
expect(
routingSettings.regularTabsProxyConnectionId,
profile.proxyConnection,
);
expect(
routingSettings.privateTabsProxyConnectionId,
profile.proxyConnection,
);
expect(profilesRepository.deletedProfileIds, [profile.id]);
},
);
test('runtime stream sync removes stale Gecko proxy registrations', () async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
final client = _FakeSingboxProxyClient(
_state([
_endpoint(profile1.proxyConnectionId, port: 12080),
_endpoint(profile2.proxyConnectionId, port: 12081),
]),
);
final containerProxyRepository = _FakeContainerProxyRepository();
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
containerProxyRepository: containerProxyRepository,
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
client.emit(_state([_endpoint(profile2.proxyConnectionId, port: 12081)]));
await _drainSync();
expect(containerProxyRepository.removedProxyIds, [
profile1.proxyConnectionId,
]);
});
}
ProviderContainer _container({
required _FakeSingboxProxyClient client,
required _FakeProfilesRepository profilesRepository,
_FakeContainerProxyRepository? containerProxyRepository,
_FakeContainerRepository? containerRepository,
_FakeProxyRoutingSettingsRepository? routingSettingsRepository,
}) {
final container = ProviderContainer(
overrides: [
singboxProxyClientProvider.overrideWithValue(client),
singboxProxyProfilesRepositoryProvider.overrideWith(
() => profilesRepository,
),
singboxProxyCredentialsRepositoryProvider.overrideWith(
_FakeCredentialsRepository.new,
),
engineSettingsRepositoryProvider.overrideWith(
_FakeEngineSettingsRepository.new,
),
containerProxyRepositoryProvider.overrideWith(
() => containerProxyRepository ?? _FakeContainerProxyRepository(),
),
containerRepositoryProvider.overrideWith(
() => containerRepository ?? _FakeContainerRepository(),
),
proxyRoutingSettingsRepositoryProvider.overrideWith(
() =>
routingSettingsRepository ?? _FakeProxyRoutingSettingsRepository(),
),
],
);
// Activate the endpoint sync notifier so runtime state changes propagate
// into the fake container proxy repository. Production wires this in
// main.dart; tests have to ask for it explicitly.
container.read(singboxProxyEndpointSyncProvider);
return container;
}
/// Drains all currently-pending microtasks/futures so the endpoint-sync
/// `ref.listen` callback (chained off the runtime state stream listener)
/// has time to apply its registrations against the fake Gecko repo.
Future<void> _drainSync() => pumpEventQueue();
ProxyProfile _profile({required String id, required String name}) {
final createdAt = DateTime(2026);
return ProxyProfile(
id: id,
name: name,
type: SingboxProxyProfileType.customOutbound,
configJson: '{"type":"socks"}',
createdAt: createdAt,
updatedAt: createdAt,
);
}
SingboxProxyRuntimeState _state(List<SingboxProxyRuntimeEndpoint> endpoints) {
return SingboxProxyRuntimeState(
status: endpoints.isEmpty
? SingboxProxyRuntimeStatus.stopped
: SingboxProxyRuntimeStatus.running,
endpoints: endpoints,
);
}
SingboxProxyRuntimeEndpoint _endpoint(String profileId, {required int port}) {
return SingboxProxyRuntimeEndpoint(
profileId: profileId,
host: '127.0.0.1',
port: port,
username: 'user-$port',
password: 'pass-$port',
);
}
class _FakeSingboxProxyClient implements SingboxProxyClient {
final _stateController =
StreamController<SingboxProxyRuntimeState>.broadcast();
final startCalls = <List<SingboxProxyProfile>>[];
final stopCalls = <String>[];
SingboxProxyRuntimeState _currentState;
SingboxProxyRuntimeState? stateAfterStop;
_FakeSingboxProxyClient(this._currentState);
@override
Stream<SingboxProxyRuntimeState> get stateStream => _stateController.stream;
@override
Stream<SingboxProxyLogMessage> get logStream => const Stream.empty();
void emit(SingboxProxyRuntimeState state) {
_currentState = state;
_stateController.add(state);
}
@override
Future<SingboxProxyRuntimeState> getState() async => _currentState;
@override
Future<SingboxProxyRuntimeState> start(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
}) async {
startCalls.add(profiles);
return _currentState = _state(
profiles.indexed
.map((item) => _endpoint(item.$2.id, port: 12080 + item.$1))
.toList(),
);
}
@override
Future<void> stop(List<String> profileIds) async {
stopCalls.addAll(profileIds);
_currentState = stateAfterStop ?? _currentState;
}
@override
Future<void> stopAll() async {
_currentState = _state(const []);
}
@override
Future<String?> validateProfile(SingboxProxyProfile profile) async => null;
@override
Future<SingboxProxyConfigResult> buildConfig(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
}) async {
return SingboxProxyConfigResult(configJson: '{}', endpoints: const []);
}
@override
Future<void> dispose() => _stateController.close();
}
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
final List<ProxyProfile> profiles;
final deletedProfileIds = <String>[];
_FakeProfilesRepository(this.profiles);
@override
Future<List<ProxyProfile>> fetchProfiles() async => profiles;
@override
Future<void> deleteProfile(String profileId) async {
deletedProfileIds.add(profileId);
}
@override
Stream<List<ProxyProfile>> build() => Stream.value(profiles);
}
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
@override
Future<String?> readSecretJson(String profileId) async => null;
@override
void build() {}
}
class _FakeEngineSettingsRepository extends EngineSettingsRepository {
final settings = EngineSettings.withDefaults();
@override
Future<EngineSettings> fetchSettings() async => settings;
@override
Stream<EngineSettings> build() => Stream.value(settings);
}
class _FakeContainerProxyRepository extends ContainerProxyRepository {
final upsertedProxies = <GeckoProxySettings>[];
final removedProxyIds = <String>[];
@override
Future<void> upsertProxy(GeckoProxySettings proxy) async {
upsertedProxies.add(proxy);
}
@override
Future<void> removeProxy(String proxyId) async {
removedProxyIds.add(proxyId);
}
@override
void build() {}
}
class _FakeContainerRepository extends ContainerRepository {
final clearedProxyConnectionIds = <ProxyConnectionId>[];
@override
Future<void> clearProxyConnectionAssignments(
ProxyConnectionId proxyConnectionId,
) async {
clearedProxyConnectionIds.add(proxyConnectionId);
}
@override
void build() {}
}
class _FakeProxyRoutingSettingsRepository
extends ProxyRoutingSettingsRepository {
ProxyRoutingSettings? _settings;
_FakeProxyRoutingSettingsRepository([this._settings]);
ProxyRoutingSettings? peekSettings() => _settings;
@override
Stream<ProxyRoutingSettings> build() {
return Stream.value(_settings ?? ProxyRoutingSettings.withDefaults());
}
}
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
void main() {
test('mirrors browser DoH through running profiles automatically', () {
final config = buildDnsConfig(
overridesByProfileId: const {'profile-1': null},
runningProfileIds: const {'profile-1'},
browserDohUrl: 'https://dns.example/dns-query',
);
expect(config, isNotNull);
expect(config!.finalServerTag, 'browser-doh');
expect(config.servers, hasLength(2));
expect(config.servers.first.detourTag, isNull);
expect(config.servers.last.tag, 'browser-doh-profile-1');
expect(config.servers.last.detourTag, 'out-singbox_profile-1');
expect(config.servers.last.matchInbounds, ['in-singbox_profile-1']);
});
test('profile override replaces automatic browser DoH mirror', () {
final config = buildDnsConfig(
overridesByProfileId: {
'profile-1': ProxyDnsOverride(remoteServerAddress: '10.0.0.53'),
},
runningProfileIds: const {'profile-1'},
browserDohUrl: 'https://dns.example/dns-query',
);
expect(config, isNotNull);
expect(config!.servers.map((s) => s.tag), [
'browser-doh',
'override-profile-1',
]);
expect(config.servers.last.address, '10.0.0.53');
expect(config.servers.last.detourTag, 'out-singbox_profile-1');
expect(config.servers.last.matchInbounds, ['in-singbox_profile-1']);
});
test('returns null when there is no browser DoH or override', () {
final config = buildDnsConfig(
overridesByProfileId: const {'profile-1': null},
runningProfileIds: const {'profile-1'},
browserDohUrl: null,
);
expect(config, isNull);
});
test('auto domain strategy emits sing-box as-is strategy', () {
expect(ProxyDnsDomainStrategy.auto.singboxValue, isEmpty);
});
}
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
/// The Dart and Kotlin sides of the sing-box config builder must produce the
/// same `out-`/`in-` tags. These cases mirror `SingboxTagFormatTest.kt` —
/// both must update together if this format ever changes.
void main() {
group('singbox tag format', () {
test('outboundTag is prefixed and sanitized', () {
expect(singboxOutboundTag('singbox:foo-bar'), 'out-singbox_foo-bar');
});
test('inboundTag is prefixed and sanitized', () {
expect(singboxInboundTag('singbox:foo-bar'), 'in-singbox_foo-bar');
});
test('sanitizeTag preserves alphanumeric, dots, dashes, underscores', () {
expect(singboxSanitizeTag('Abc_123.x-Y'), 'Abc_123.x-Y');
});
test('sanitizeTag replaces spaces, slashes, colons', () {
expect(singboxSanitizeTag('a:b c/d\\e'), 'a_b_c_d_e');
});
});
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
void main() {
testWidgets(
'prompts and retries sing-box start when runtime provider is in error',
(tester) async {
final runtimeRepository = _ErrorRuntimeRepository();
final container = ContainerData(
id: 'container-1',
color: Colors.blue,
orderKey: 'a',
metadata: ContainerMetadata.withDefaults(
proxyConnectionId: const SingboxProxyConnectionId('profile-1'),
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
singboxProxyRuntimeRepositoryProvider.overrideWith(
() => runtimeRepository,
),
proxyConnectionOptionsProvider.overrideWith(
(ref) => [
ProxyConnectionOption(
id: const SingboxProxyConnectionId('profile-1'),
title: 'Mullvad',
subtitle: 'SOCKS',
),
],
),
],
child: MaterialApp(home: _EnsureProxyHarness(container: container)),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open container'));
await tester.pumpAndSettle();
expect(find.text('Start Proxy Connection?'), findsOneWidget);
expect(
find.textContaining('This container uses Mullvad'),
findsOneWidget,
);
await tester.tap(find.text('Start'));
await tester.pumpAndSettle();
expect(runtimeRepository.startedProfileIds, ['profile-1']);
expect(find.text('result:true'), findsOneWidget);
},
);
}
class _EnsureProxyHarness extends ConsumerStatefulWidget {
final ContainerData container;
const _EnsureProxyHarness({required this.container});
@override
ConsumerState<_EnsureProxyHarness> createState() =>
_EnsureProxyHarnessState();
}
class _EnsureProxyHarnessState extends ConsumerState<_EnsureProxyHarness> {
bool? _result;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Text('result:${_result ?? 'pending'}'),
TextButton(
onPressed: () async {
final result = await ensureProxyStartedForContainer(
context,
ref,
widget.container,
);
if (!mounted) return;
setState(() {
_result = result;
});
},
child: const Text('Open container'),
),
],
),
);
}
}
class _ErrorRuntimeRepository extends SingboxProxyRuntimeRepository {
final startedProfileIds = <String>[];
@override
Future<SingboxProxyRuntimeState> build() {
return Future<SingboxProxyRuntimeState>.error(StateError('runtime failed'));
}
@override
Future<SingboxProxyRuntimeState> startProfile(
String profileId, {
SingboxProxyRuntimeOptions? options,
}) async {
startedProfileIds.add(profileId);
return SingboxProxyRuntimeState(
status: SingboxProxyRuntimeStatus.running,
endpoints: [
SingboxProxyRuntimeEndpoint(
profileId: const SingboxProxyConnectionId('profile-1').encode(),
host: '127.0.0.1',
port: 1080,
username: '',
password: '',
),
],
);
}
}
@@ -0,0 +1,207 @@
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
void main() {
test('save validates and creates a seeded structured profile', () async {
final runtimeRepository = _FakeRuntimeRepository();
final profilesRepository = _FakeProfilesRepository();
final container = _container(
runtimeRepository: runtimeRepository,
profilesRepository: profilesRepository,
);
addTearDown(container.dispose);
final provider = proxyProfileDraftProvider(
seed: ProxyProfileSeed(
type: SingboxProxyProfileType.socks,
name: 'Imported SOCKS',
values: {
'server': 'proxy.example',
'server_port': '1080',
'username': 'alice',
'password': 'secret',
},
),
);
final outcome = await container.read(provider.notifier).save();
expect(outcome, isA<SaveSucceeded>());
expect(runtimeRepository.validatedProfiles, hasLength(1));
expect(profilesRepository.createdProfiles, hasLength(1));
expect(profilesRepository.createdProfiles.single.name, 'Imported SOCKS');
expect(
profilesRepository.createdProfiles.single.configJson,
contains('"server": "proxy.example"'),
);
expect(
profilesRepository.createdSecrets.single,
contains('"password": "secret"'),
);
});
test('editing writes null secret to clear secure storage', () async {
final credentialsRepository = _FakeCredentialsRepository(
secrets: {'profile-1': '{"password":"old-secret"}'},
);
final profilesRepository = _FakeProfilesRepository(
existingProfile: _profile(
id: 'profile-1',
name: 'SOCKS',
type: SingboxProxyProfileType.socks,
configJson:
'{"type":"socks","server":"proxy.example","server_port":1080}',
),
);
final container = _container(
profilesRepository: profilesRepository,
credentialsRepository: credentialsRepository,
);
addTearDown(container.dispose);
final provider = proxyProfileDraftProvider(profileId: 'profile-1');
final subscription = container.listen(
provider,
(_, _) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await _drainAsyncLoad();
container.read(provider.notifier).setFieldValue('password', '');
final outcome = await container.read(provider.notifier).save();
expect(outcome, isA<SaveSucceeded>());
expect(profilesRepository.updatedProfiles, hasLength(1));
expect(credentialsRepository.writtenSecrets, {'profile-1': null});
});
}
ProviderContainer _container({
_FakeRuntimeRepository? runtimeRepository,
_FakeProfilesRepository? profilesRepository,
_FakeCredentialsRepository? credentialsRepository,
}) {
return ProviderContainer(
overrides: [
singboxProxyRuntimeRepositoryProvider.overrideWith(
() => runtimeRepository ?? _FakeRuntimeRepository(),
),
singboxProxyProfilesRepositoryProvider.overrideWith(
() => profilesRepository ?? _FakeProfilesRepository(),
),
singboxProxyCredentialsRepositoryProvider.overrideWith(
() => credentialsRepository ?? _FakeCredentialsRepository(),
),
],
);
}
Future<void> _drainAsyncLoad() => pumpEventQueue();
ProxyProfile _profile({
required String id,
required String name,
required SingboxProxyProfileType type,
required String configJson,
}) {
final createdAt = DateTime(2026);
return ProxyProfile(
id: id,
name: name,
type: type,
configJson: configJson,
createdAt: createdAt,
updatedAt: createdAt,
);
}
class _FakeRuntimeRepository extends SingboxProxyRuntimeRepository {
final validatedProfiles = <ProxyProfile>[];
final validatedSecrets = <String?>[];
@override
Future<String?> validateProfileDraft(
ProxyProfile profile, {
String? secretJson,
}) async {
validatedProfiles.add(profile);
validatedSecrets.add(secretJson);
return null;
}
@override
Future<SingboxProxyRuntimeState> build() async {
return SingboxProxyRuntimeState(
status: SingboxProxyRuntimeStatus.stopped,
endpoints: const [],
);
}
}
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
final ProxyProfile? existingProfile;
final createdProfiles = <ProxyProfile>[];
final createdSecrets = <String?>[];
final updatedProfiles = <ProxyProfile>[];
_FakeProfilesRepository({this.existingProfile});
@override
Future<ProxyProfile?> findProfile(String id) async => existingProfile;
@override
Future<ProxyProfile> createProfile({
required String name,
required SingboxProxyProfileType type,
required String configJson,
String? secretJson,
String? dnsOverrideJson,
}) async {
final profile = _profile(
id: 'created-profile',
name: name,
type: type,
configJson: configJson,
);
createdProfiles.add(profile);
createdSecrets.add(secretJson);
return profile;
}
@override
Future<void> updateProfile(ProxyProfile profile, {String? secretJson}) async {
updatedProfiles.add(profile);
}
@override
Stream<List<ProxyProfile>> build() {
return Stream.value([if (existingProfile != null) existingProfile!]);
}
}
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
final Map<String, String?> secrets;
final writtenSecrets = <String, String?>{};
_FakeCredentialsRepository({this.secrets = const {}});
@override
Future<String?> readSecretJson(String profileId) async => secrets[profileId];
@override
Future<void> writeSecretJson(String profileId, String? secretJson) async {
writtenSecrets[profileId] = secretJson;
}
@override
void build() {}
}
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/presentation/screens/singbox_proxy_profile_editor.dart';
import 'package:weblibre/features/proxy/presentation/screens/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_tile.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
void main() {
testWidgets('deleting a running profile stops through runtime repository', (
tester,
) async {
final profile = _profile(id: 'profile-1', name: 'Mullvad');
final profilesRepository = _FakeProfilesRepository([profile]);
final runtimeRepository = _FakeRuntimeRepository(
SingboxProxyRuntimeState(
status: SingboxProxyRuntimeStatus.running,
endpoints: [
SingboxProxyRuntimeEndpoint(
profileId: SingboxProxyConnectionId(profile.id).encode(),
host: '127.0.0.1',
port: 12080,
username: 'user',
password: 'pass',
),
],
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
singboxProxyProfilesRepositoryProvider.overrideWith(
() => profilesRepository,
),
singboxProxyRuntimeRepositoryProvider.overrideWith(
() => runtimeRepository,
),
],
child: const MaterialApp(home: SingboxProxyProfilesScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('Mullvad'), findsOneWidget);
await tester.tap(
find.descendant(
of: find.byType(ProfileTile),
matching: find.byIcon(Icons.more_vert),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(find.text('Stop and Delete'), findsOneWidget);
await tester.tap(find.text('Stop and Delete'));
await tester.pumpAndSettle();
expect(runtimeRepository.deletedProfileIds, ['profile-1']);
});
testWidgets('proxy URI import populates structured editor fields', (
tester,
) async {
await _pumpEditor(
tester,
profilesRepository: _FakeProfilesRepository([]),
seed: ProxyProfileSeed(
type: SingboxProxyProfileType.vless,
name: 'Imported VLESS',
values: {
'server': 'vless.example.com',
'server_port': '443',
'uuid': '00000000-0000-0000-0000-000000000000',
'flow': 'xtls-rprx-vision',
'tls.server_name': 'front.example.com',
},
),
);
expect(find.text('Imported VLESS'), findsOneWidget);
expect(find.text('VLESS'), findsOneWidget);
expect(find.text('Connection'), findsWidgets);
expect(find.text('Credentials'), findsOneWidget);
expect(find.text('TLS'), findsOneWidget);
expect(_fieldText(tester, 'Server Address *'), 'vless.example.com');
expect(_fieldText(tester, 'Server Port *'), '443');
expect(
_fieldText(tester, 'UUID *'),
'00000000-0000-0000-0000-000000000000',
);
});
testWidgets('WireGuard config import populates WireGuard form fields', (
tester,
) async {
await _pumpEditor(
tester,
profilesRepository: _FakeProfilesRepository([]),
seed: ProxyProfileSeed(
type: SingboxProxyProfileType.wireguard,
values: {
'server': 'wg.example.com',
'server_port': '51820',
'local_address': '10.0.0.2/32',
'private_key': 'private-key',
'peer_public_key': 'peer-public-key',
},
),
);
expect(_fieldText(tester, 'Server Address *'), 'wg.example.com');
expect(_fieldText(tester, 'Server Port *'), '51820');
expect(_fieldText(tester, 'Local Address *'), '10.0.0.2/32');
expect(_fieldText(tester, 'Private Key *'), 'private-key');
});
}
Future<void> _pumpEditor(
WidgetTester tester, {
required _FakeProfilesRepository profilesRepository,
String? profileId,
ProxyProfileSeed? seed,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
singboxProxyProfilesRepositoryProvider.overrideWith(
() => profilesRepository,
),
singboxProxyCredentialsRepositoryProvider.overrideWith(
_FakeCredentialsRepository.new,
),
singboxProxyRuntimeRepositoryProvider.overrideWith(
() => _FakeRuntimeRepository(
SingboxProxyRuntimeState(
status: SingboxProxyRuntimeStatus.stopped,
endpoints: const [],
),
),
),
],
child: MaterialApp(
home: SingboxProxyProfileEditorScreen(profileId: profileId, seed: seed),
),
),
);
await tester.pumpAndSettle();
}
String _fieldText(WidgetTester tester, String labelText) {
final textField = tester.widget<TextField>(
find.ancestor(of: find.text(labelText), matching: find.byType(TextField)),
);
return textField.controller!.text;
}
ProxyProfile _profile({
required String id,
required String name,
SingboxProxyProfileType type = SingboxProxyProfileType.customOutbound,
String configJson = '{"type":"socks"}',
}) {
final createdAt = DateTime(2026);
return ProxyProfile(
id: id,
name: name,
type: type,
configJson: configJson,
createdAt: createdAt,
updatedAt: createdAt,
);
}
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
final List<ProxyProfile> profiles;
_FakeProfilesRepository(this.profiles);
@override
Stream<List<ProxyProfile>> build() => Stream.value(profiles);
}
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
@override
Future<String?> readSecretJson(String profileId) async => null;
@override
void build() {}
}
class _FakeRuntimeRepository extends SingboxProxyRuntimeRepository {
final SingboxProxyRuntimeState initialState;
final deletedProfileIds = <String>[];
_FakeRuntimeRepository(this.initialState);
@override
Future<SingboxProxyRuntimeState> build() async => initialState;
@override
Future<void> deleteProfile(String profileId) async {
deletedProfileIds.add(profileId);
}
}
@@ -24,7 +24,7 @@ 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:search_protocol/search_protocol.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';
@@ -125,23 +125,25 @@ void main() {
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(
'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();
@@ -174,9 +176,7 @@ void main() {
// 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',
),
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.
@@ -192,23 +192,25 @@ void main() {
});
group('retry route', () {
test('emits a RetryRequest on retryRequests stream and returns 204',
() async {
final port = await server.ensureStarted();
final retryFuture = server.retryRequests.first;
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 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');
});
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();
@@ -20,7 +20,7 @@
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:search_protocol/search_protocol.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';
@@ -79,14 +79,14 @@ void main() {
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 100));
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();
await tester.pump();
expect(opener.openedUris, [Uri.parse(url)]);
});
@@ -23,7 +23,9 @@ 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:search_protocol/search_protocol.dart';
import 'package:weblibre/features/search_credits/data/models/web_search_settings.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.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';
@@ -39,12 +41,15 @@ void main() {
ProviderScope(
overrides: [
...webSearchTestOverrides(),
metaSearchControllerProvider.overrideWithValue(
MetaSearchState(status: WebSearchStatus.ready),
),
watchCachedIconBytesProvider.overrideWith((ref, origin) {
return Stream.value(null);
}),
metaSearchControllerProvider.overrideWithValue(
const MetaSearchState(status: WebSearchStatus.ready),
),
webSearchSettingsControllerProvider.overrideWithValue(
WebSearchSettings.withDefaults(),
),
],
child: MaterialApp(
home: Scaffold(
@@ -67,7 +72,7 @@ void main() {
);
await tester.tap(find.text('LensAI result'));
await tester.pumpAndSettle();
await tester.pump();
expect(openedUris, [Uri.parse(url)]);
});
@@ -84,15 +89,18 @@ void main() {
ProviderScope(
overrides: [
...webSearchTestOverrides(),
watchCachedIconBytesProvider.overrideWith((ref, origin) {
return Stream.value(null);
}),
metaSearchControllerProvider.overrideWithValue(
MetaSearchState(
status: WebSearchStatus.ready,
imagesByUrl: {thumbnailUrl: Uint8List.fromList(pngBytes)},
),
),
watchCachedIconBytesProvider.overrideWith((ref, origin) {
return Stream.value(null);
}),
webSearchSettingsControllerProvider.overrideWithValue(
WebSearchSettings.withDefaults(),
),
],
child: MaterialApp(
home: Scaffold(
@@ -114,7 +122,7 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(Image), findsOneWidget);
},