diff --git a/tests/README.md b/tests/README.md index 2b0082b..f8710c6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -20,6 +20,7 @@ clients, or real proxy cores. | SOCKS and SIP002 Shadowsocks codecs and generated round trips | `test_socks_uri.py`, `test_shadowsocks_uri.py` | | Subscription workflow, timers, stale requests, and reconciliation | `test_subscription_manager.py`, `test_subscription_sync.py` | | External process launch, output, shutdown, threads, TUN metadata | `test_external_core.py` | +| Backend structured-editor observational load and unknown-value preservation | `test_backend_editor_contract.py` | | Xray/Hysteria2 native-TUN ownership and proxy-only stripping | `test_native_tun_semantics.py` | | Rolling metrics, stable buckets, lazy rendering, and hover | `test_metrics_behavior.py` | | Proxy-only endpoint discovery, caching, and presentation | `test_endpoint_info.py` | @@ -75,7 +76,7 @@ Then run the desired test tier. python -m unittest discover -s tests -v # Regular logic, persistence, plugin, controller, codec, and UI regressions -python -m unittest tests.test_interface tests.test_models_and_services tests.test_architecture_refactors tests.test_plugin_architecture tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_socks_uri tests.test_shadowsocks_uri tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_ui_behavior -v +python -m unittest tests.test_interface tests.test_models_and_services tests.test_architecture_refactors tests.test_plugin_architecture tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_ui_behavior -v # Direct Qt/process integration and destruction/lifetime checks python -m unittest tests.test_external_core tests.test_qt_lifetime -v @@ -84,7 +85,7 @@ python -m unittest tests.test_external_core tests.test_qt_lifetime -v python -m unittest tests.test_qt_stress tests.test_process_stress -v # Shared-state order-independence spot check -python -m unittest tests.test_ui_behavior tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_shadowsocks_uri tests.test_socks_uri tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_plugin_architecture tests.test_architecture_refactors tests.test_models_and_services tests.test_interface -v +python -m unittest tests.test_ui_behavior tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_plugin_architecture tests.test_architecture_refactors tests.test_models_and_services tests.test_interface -v python -m unittest discover -s tests -v ``` diff --git a/tests/test_backend_editor_contract.py b/tests/test_backend_editor_contract.py new file mode 100644 index 0000000..26cd904 --- /dev/null +++ b/tests/test_backend_editor_contract.py @@ -0,0 +1,166 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Protect observational loading and lossless backend editor round trips.""" + +from __future__ import annotations + +from Furious.Backends.Configuration import ConfigXray +from Furious.Backends.Hysteria1.Editor import GuiHy1ItemBasicProtocol +from Furious.Backends.Xray.TlsEditor import GuiVTLSQGroupBox +from Furious.Backends.Xray.TransportEditor import GuiVTransportQGroupBox + +from tests.support import application, collectAtBoundary + +import copy +import unittest + + +class BackendEditorContractTest(unittest.TestCase): + """Verify mature editors preserve values they do not yet understand.""" + + @classmethod + def setUpClass(cls): + """Create the process-wide Qt application used by real editor widgets.""" + application() + + def tearDown(self): + """Process deferred Qt deletion between real-widget tests.""" + collectAtBoundary() + + @staticmethod + def xrayConfiguration(streamSettings: dict) -> ConfigXray: + """Return a minimal Xray document with one tagged proxy outbound.""" + return ConfigXray( + { + 'outbounds': [ + { + 'tag': 'proxy', + 'protocol': 'vless', + 'streamSettings': copy.deepcopy(streamSettings), + } + ] + } + ) + + def testHysteria1UnknownProtocolIsVisibleAndPreserved(self): + """Round-trip a future Hysteria 1 protocol through the shared combo.""" + binding = GuiHy1ItemBasicProtocol(title='Protocol', translatable=False) + configuration = {'protocol': 'future-protocol'} + original = copy.deepcopy(configuration) + optionCount = binding._input.count() + + binding.factoryToInput(configuration) + + self.assertEqual(binding.text(), 'future-protocol') + self.assertEqual(binding._input.count(), optionCount) + self.assertEqual(binding._input.findText('future-protocol'), -1) + self.assertEqual(configuration, original) + self.assertFalse(binding.inputToFactory(configuration)) + self.assertEqual(configuration, original) + + for widget in binding.widgets(): + widget.deleteLater() + + def testXrayUnknownTransportIsVisibleAndPreserved(self): + """Keep an unsupported transport and its settings untouched.""" + configuration = self.xrayConfiguration( + { + 'network': 'future-transport', + 'futureTransportSettings': { + 'token': 'preserve', + 'futureField': 7, + }, + } + ) + original = copy.deepcopy(configuration) + group = GuiVTransportQGroupBox() + + group.factoryToInput(configuration) + + self.assertEqual(configuration, original) + self.assertEqual( + group.page(group.currentIndex()).networkText(), 'future-transport' + ) + self.assertFalse(group.inputToFactory(configuration)) + self.assertEqual(configuration, original) + + group.deleteLater() + + def testXrayTransportAliasIsNormalizedWhileLoading(self): + """Normalize legacy upstream aliases while preserving their settings.""" + configuration = self.xrayConfiguration( + { + 'network': 'http', + 'httpSettings': { + 'host': ['example.com'], + 'path': '/future-safe', + 'futureField': {'preserve': True}, + }, + } + ) + group = GuiVTransportQGroupBox() + + group.factoryToInput(configuration) + + self.assertEqual( + configuration.proxyStreamSettingsObject['network'], + 'h2', + ) + self.assertEqual( + configuration.proxyStreamSettingsObject['httpSettings'], + { + 'host': ['example.com'], + 'path': '/future-safe', + 'futureField': {'preserve': True}, + }, + ) + self.assertEqual(group.page(group.currentIndex()).networkText(), 'h2') + self.assertFalse(group.inputToFactory(configuration)) + + group.deleteLater() + + def testXrayUnknownSecurityIsVisibleAndPreserved(self): + """Keep unsupported TLS modes and sibling settings untouched.""" + configuration = self.xrayConfiguration( + { + 'network': 'tcp', + 'security': 'future-security', + 'future-securitySettings': { + 'certificate': 'preserve', + 'futureField': True, + }, + } + ) + original = copy.deepcopy(configuration) + group = GuiVTLSQGroupBox() + + group.factoryToInput(configuration) + + self.assertEqual(configuration, original) + self.assertEqual( + group.page(group.currentIndex())._containers[0].text(), + 'future-security', + ) + self.assertFalse(group.inputToFactory(configuration)) + self.assertEqual(configuration, original) + + group.deleteLater() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_hysteria2_compatibility.py b/tests/test_hysteria2_compatibility.py index 244c5df..7464e96 100644 --- a/tests/test_hysteria2_compatibility.py +++ b/tests/test_hysteria2_compatibility.py @@ -478,10 +478,113 @@ class Hysteria2CompatibilityTest(unittest.TestCase): self.assertTrue(editor.inputToFactory(profile)) self.assertEqual(profile.connection['realm']['ipMode'], 'v6') self.assertEqual(profile.connection['obfs']['type'], 'salamander') - self.assertEqual( - profile.connection['obfs']['future-obfs'], - {'token': 'discard'}, + self.assertNotIn('future-obfs', profile.connection['obfs']) + + editor.close() + + def testUnknownCompactEditorValuesRoundTripUntouched(self): + """Display and preserve future enum and tagged-union values verbatim.""" + profile = self.profile( + { + 'server': 'example.com:443', + 'auth': 'secret', + 'realm': { + 'ipMode': 'future-mode', + 'futureRealmField': {'preserve': True}, + }, + 'congestion': { + 'type': 'future-congestion', + 'bbrProfile': 'future-profile', + 'futureCongestionField': 7, + }, + 'obfs': { + 'type': 'future-obfs', + 'future-obfs': { + 'token': 'preserve', + 'futureOption': True, + }, + }, + } ) + original = copy.deepcopy(profile.connection) + editor = Hysteria2Editor() + + editor.factoryToInput(profile) + + self.assertEqual(profile.connection, original) + self.assertEqual( + self.binding(editor, ('realm', 'ipMode')).text(), + 'future-mode', + ) + self.assertEqual( + editor.basicGroup._containers[3].bindings[0].text(), + 'future-congestion', + ) + self.assertEqual( + editor.basicGroup._containers[3].bindings[1].text(), + 'future-profile', + ) + self.assertEqual( + editor.advancedGroup.obfsItem.page( + editor.advancedGroup.obfsItem.currentIndex() + ).obfsTypeText(), + 'future-obfs', + ) + self.assertFalse(editor.inputToFactory(profile)) + self.assertEqual(profile.connection, original) + + editor.close() + + def testClearingCongestionFieldPreservesUnknownSiblings(self): + """Remove only the represented congestion leaf when clearing it.""" + profile = self.profile( + { + 'server': 'example.com:443', + 'auth': 'secret', + 'congestion': { + 'type': 'bbr', + 'bbrProfile': 'fast', + 'futureCongestionField': {'preserve': True}, + }, + } + ) + editor = Hysteria2Editor() + editor.factoryToInput(profile) + + editor.basicGroup._containers[3].bindings[0].setText('') + + self.assertTrue(editor.inputToFactory(profile)) + self.assertNotIn('type', profile.connection['congestion']) + self.assertEqual(profile.connection['congestion']['bbrProfile'], 'fast') + self.assertEqual( + profile.connection['congestion']['futureCongestionField'], + {'preserve': True}, + ) + + editor.close() + + def testImplicitGeckoPacketDefaultsRemainAbsent(self): + """Do not materialize effective Gecko defaults during an untouched save.""" + profile = self.profile( + { + 'server': 'example.com:443', + 'auth': 'secret', + 'obfs': { + 'type': 'gecko', + 'gecko': { + 'password': 'secret', + 'futureGeckoField': {'preserve': True}, + }, + }, + } + ) + original = copy.deepcopy(profile.connection) + editor = Hysteria2Editor() + + editor.factoryToInput(profile) + + self.assertFalse(editor.inputToFactory(profile)) + self.assertEqual(profile.connection, original) editor.close() diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 7682cfe..97e4b47 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -98,6 +98,9 @@ class EditorMappingTest(unittest.TestCase): 'environment': {'TOKEN': 'one=two', 'UNICODE': '测试'}, 'useApplicationTun2socks': True, 'tunRemoteAddress': '2001:db8::42', + 'futureExternalCoreField': { + 'nested': ['preserve', 7], + }, } ) profile = ServerProfile.fromConfiguration( @@ -107,8 +110,11 @@ class EditorMappingTest(unittest.TestCase): with isolatedSettings(): editor = ExternalCoreEditor() + original = copy.deepcopy(profile.connection) editor.factoryToInput(profile) + self.assertEqual(profile.connection, original) + self.assertEqual( editor._argumentsInput.values(), ['--config', 'name with spaces.json'], @@ -148,6 +154,10 @@ class EditorMappingTest(unittest.TestCase): profile.connection.tunRemoteAddress(), 'server.example.com', ) + self.assertEqual( + profile.connection['futureExternalCoreField'], + {'nested': ['preserve', 7]}, + ) editor.close()