11import asyncio
22import os
33import sys
4+ import types
45from contextlib import suppress
56from pathlib import Path
67
7- import nest_asyncio2
88import pytest
99from asyncssh import PermissionDenied
1010from herethere .everywhere import ConnectionConfig
11+ from herethere .everywhere .loop import run_sync
1112from herethere .there .client import Client
1213from herethere .there .commands import ContextObject , there_group
1314from kivy .config import Config
1415from kivy .core .window import Window
1516from main import PythonHereApp , run_ssh_server
1617
1718
19+ async def run_herethere_sync (awaitable ):
20+ """Run sync magic work while the in-process SSH server keeps its test loop.
21+
22+ Use this only for code paths that exercise herethere's synchronous magic
23+ bridge. Plain async client tests should await the client API directly.
24+ """
25+ return await asyncio .to_thread (run_sync , awaitable )
26+
27+
1828@pytest .fixture
19- def connection_config ():
29+ def connection_config (app_config ):
2030 return ConnectionConfig (
2131 host = "localhost" ,
22- port = 8022 ,
32+ port = app_config ,
2333 username = "here" ,
2434 password = "there" ,
2535 )
2636
2737
2838@pytest .fixture
29- def app_config ():
30- Config .read ("../tests/config.ini" )
39+ def app_config (unused_tcp_port ):
40+ Config .read (str (Path (__file__ ).with_name ("config.ini" )))
41+ Config .set ("pythonhere" , "port" , str (unused_tcp_port ))
42+ return unused_tcp_port
3143
3244
3345@pytest .fixture
3446async def app_instance (mocker , capfd , app_config , tmpdir ):
47+ original_cwd = Path .cwd ()
48+ os .chdir (Path (__file__ ).parents [1 ] / "pythonhere" )
3549 mocker .patch ("main.App.user_data_dir" , tmpdir )
50+ Window .size = (800 , 600 )
3651
3752 app = PythonHereApp ()
3853 app .init_asyncio_state ()
@@ -54,6 +69,7 @@ async def app_instance(mocker, capfd, app_config, tmpdir):
5469 raise result
5570 app .root .clear_widgets ()
5671 Window .children .clear ()
72+ os .chdir (original_cwd )
5773
5874
5975@pytest .fixture
@@ -66,11 +82,37 @@ async def there(app_instance, connection_config):
6682 finally :
6783 connection = client .connection .connection
6884 await client .disconnect ()
85+
6986 if connection is not None :
7087 with suppress (Exception ):
7188 await connection .wait_closed ()
7289
7390
91+ @pytest .fixture
92+ async def sync_there_client (app_instance , connection_config ):
93+ """Client connected on herethere's sync magic loop.
94+
95+ Use with command/magic helpers that call herethere.there.commands, because
96+ those commands call run_sync() internally and expect the client connection
97+ to belong to herethere's background magic loop.
98+ """
99+ client = Client ()
100+ await asyncio .wait_for (app_instance .ssh_server_started .wait (), 5 )
101+ await run_herethere_sync (client .connect (connection_config ))
102+ try :
103+ yield client
104+ finally :
105+ connection = client .connection .connection
106+ await run_herethere_sync (client .disconnect ())
107+
108+ async def wait_closed ():
109+ if connection is not None :
110+ await connection .wait_closed ()
111+
112+ with suppress (Exception ):
113+ await run_herethere_sync (wait_closed ())
114+
115+
74116@pytest .fixture
75117async def there_with_wrong_password (app_instance , connection_config ):
76118 client = Client ()
@@ -82,18 +124,21 @@ async def there_with_wrong_password(app_instance, connection_config):
82124
83125
84126@pytest .fixture
85- def nested_event_loop ( ):
86- nest_asyncio2 . apply ()
127+ async def call_there_group ( app_instance , sync_there_client ):
128+ """Call the synchronous %there command group from async tests.
87129
130+ The command itself is synchronous, so it is run in a worker thread. This
131+ leaves pytest's event loop free to service the in-process PythonHere SSH
132+ server which receives the command.
133+ """
88134
89- @pytest .fixture
90- async def call_there_group (nested_event_loop , app_instance , there ):
91- def _callable (args , code ):
92- there_group (
135+ async def _callable (args , code ):
136+ return await asyncio .to_thread (
137+ there_group ,
93138 args ,
94139 "test" ,
95140 standalone_mode = False ,
96- obj = ContextObject (client = there , code = code ),
141+ obj = ContextObject (client = sync_there_client , code = code ),
97142 )
98143
99144 return _callable
@@ -112,8 +157,106 @@ def preserve_cwd():
112157
113158@pytest .fixture
114159def mocked_android_modules (mocker ):
115- sys .modules ["jnius" ] = mocker .Mock ()
116- sys .modules ["android" ] = mocker .Mock ()
160+ """Install a small fake Android/Jnius surface for Android-only code paths.
161+
162+ Keep this fake narrow: add methods/constants here only when tests exercise
163+ the corresponding behavior in android_here or launcher_here.
164+ """
165+ activity = mocker .Mock ()
166+ context = mocker .Mock ()
167+ app_info = mocker .Mock (icon = 1 )
168+ manager = mocker .Mock ()
169+ manager .isRequestPinShortcutSupported .return_value = True
170+ context .getApplicationInfo .return_value = app_info
171+ activity .getApplicationContext .return_value = context
172+ activity .getSystemService .return_value = manager
173+
174+ class Context :
175+ SHORTCUT_SERVICE = "shortcut"
176+
177+ class Icon :
178+ createWithResource = mocker .Mock (return_value = mocker .Mock ())
179+
180+ class Intent :
181+ FLAG_ACTIVITY_NEW_TASK = 1
182+ FLAG_ACTIVITY_CLEAR_TASK = 2
183+ ACTION_MAIN = "android.intent.action.MAIN"
184+
185+ def __init__ (self , * args ):
186+ self .args = args
187+ self .data = None
188+ self .flags = None
189+ self .action = None
190+
191+ def setAction (self , action ):
192+ self .action = action
193+ return self
194+
195+ def setData (self , data ):
196+ self .data = data
197+ return self
198+
199+ def setFlags (self , flags ):
200+ self .flags = flags
201+ return self
202+
203+ def getData (self ):
204+ return self .data
205+
206+ class PythonActivity :
207+ mActivity = activity
208+
209+ class ShortcutInfoBuilder :
210+ def __init__ (self , * args ):
211+ self .args = args
212+
213+ def setShortLabel (self , label ):
214+ self .short_label = label
215+ return self
216+
217+ def setLongLabel (self , label ):
218+ self .long_label = label
219+ return self
220+
221+ def setIntent (self , intent ):
222+ self .intent = intent
223+ return self
224+
225+ def setIcon (self , icon ):
226+ self .icon = icon
227+ return self
228+
229+ def build (self ):
230+ return self
231+
232+ class System :
233+ exit = mocker .Mock ()
234+
235+ class Uri :
236+ @staticmethod
237+ def parse (value ):
238+ uri = mocker .Mock ()
239+ uri .toString .return_value = value
240+ return uri
241+
242+ classes = {
243+ "android.content.Context" : Context ,
244+ "android.graphics.drawable.Icon" : Icon ,
245+ "android.content.Intent" : Intent ,
246+ "org.kivy.android.PythonActivity" : PythonActivity ,
247+ "android.content.pm.ShortcutInfo$Builder" : ShortcutInfoBuilder ,
248+ "java.lang.System" : System ,
249+ "android.net.Uri" : Uri ,
250+ }
251+
252+ def autoclass (name ):
253+ return classes [name ]
254+
255+ sys .modules ["jnius" ] = types .SimpleNamespace (
256+ autoclass = autoclass ,
257+ cast = mocker .Mock (side_effect = lambda _class_name , obj : obj ),
258+ )
259+ sys .modules ["android" ] = types .SimpleNamespace (activity = mocker .Mock ())
117260
118261
119262@pytest .fixture
0 commit comments