hvbrowser 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/PKG-INFO +2 -2
  2. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/pyproject.toml +2 -2
  3. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/__init__.py +20 -0
  4. hvbrowser-0.2.4/src/hvbrowser/account_context.py +523 -0
  5. hvbrowser-0.2.4/tests/test_account_context.py +236 -0
  6. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/LICENSE +0 -0
  7. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/README.md +0 -0
  8. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/scripts/live_readonly_smoke.py +0 -0
  9. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/equipment_repair.py +0 -0
  10. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/hv.py +0 -0
  11. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/live_probe.py +0 -0
  12. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/lottery.py +0 -0
  13. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/maintenance_navigation.py +0 -0
  14. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/market.py +0 -0
  15. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/monster_lab.py +0 -0
  16. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/player.py +0 -0
  17. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/py.typed +0 -0
  18. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/realm.py +0 -0
  19. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/runtime.py +0 -0
  20. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/session.py +0 -0
  21. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/urls.py +0 -0
  22. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_architecture.py +0 -0
  23. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_equipment_repair.py +0 -0
  24. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_hv.py +0 -0
  25. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_live_probe.py +0 -0
  26. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_lottery.py +0 -0
  27. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_maintenance_navigation.py +0 -0
  28. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_market.py +0 -0
  29. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_monster_lab.py +0 -0
  30. {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_player.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hvbrowser
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Browser automation APIs for HentaiVerse.
5
5
  Project-URL: Homepage, https://github.com/Kuan-Lun/hvbrowser
6
6
  Project-URL: Tracker, https://github.com/Kuan-Lun/hvbrowser/issues
@@ -13,7 +13,7 @@ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or l
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Requires-Python: <3.15,>=3.14
16
- Requires-Dist: hbrowser<0.37,>=0.36.4
16
+ Requires-Dist: hbrowser<0.37,>=0.36.6
17
17
  Provides-Extra: dev
18
18
  Requires-Dist: black>=26.5.1; extra == 'dev'
19
19
  Requires-Dist: mypy>=2.3.0; extra == 'dev'
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hvbrowser"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Browser automation APIs for HentaiVerse."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Kuan-Lun Wang" }]
11
11
  license = "AGPL-3.0-or-later"
12
12
  license-files = ["LICENSE"]
13
13
  requires-python = ">=3.14,<3.15"
14
- dependencies = ["hbrowser>=0.36.4,<0.37"]
14
+ dependencies = ["hbrowser>=0.36.6,<0.37"]
15
15
 
16
16
  classifiers = [
17
17
  "Development Status :: 3 - Alpha",
@@ -1,5 +1,16 @@
1
1
  """Public HentaiVerse browser composition API."""
2
2
 
3
+ from .account_context import (
4
+ AccountContextError,
5
+ AccountContextState,
6
+ AccountContextStateError,
7
+ HentaiVerseAccountContext,
8
+ RealmBindingViolationError,
9
+ RealmBoundHVDriver,
10
+ RealmRuntime,
11
+ RealmTabBindingError,
12
+ realm_root_url,
13
+ )
3
14
  from .equipment_repair import (
4
15
  EquipmentRepairClient,
5
16
  EquipmentRepairOutcome,
@@ -61,6 +72,9 @@ from .session import HentaiVerseSession
61
72
  from .urls import HENTAIVERSE_ISEKAI_ROOT_URL, HENTAIVERSE_ROOT_URL
62
73
 
63
74
  __all__ = [
75
+ "AccountContextError",
76
+ "AccountContextState",
77
+ "AccountContextStateError",
64
78
  "EquipmentRepairClient",
65
79
  "EquipmentRepairOutcome",
66
80
  "EquipmentRepairPageError",
@@ -71,6 +85,7 @@ __all__ = [
71
85
  "HENTAIVERSE_ISEKAI_ROOT_URL",
72
86
  "HENTAIVERSE_ROOT_URL",
73
87
  "HVDriver",
88
+ "HentaiVerseAccountContext",
74
89
  "HentaiVerseSession",
75
90
  "LOTTERY_TICKET_PRICE_GP",
76
91
  "LotteryClient",
@@ -105,11 +120,16 @@ __all__ = [
105
120
  "PlayerSnapshot",
106
121
  "PlayerStateChangedError",
107
122
  "Realm",
123
+ "RealmBindingViolationError",
124
+ "RealmBoundHVDriver",
108
125
  "RealmDetectionError",
109
126
  "RealmNavigator",
127
+ "RealmRuntime",
128
+ "RealmTabBindingError",
110
129
  "StaminaRecoveryError",
111
130
  "StaminaRecoveryOutcome",
112
131
  "StaminaRecoveryReport",
113
132
  "classify_maintenance_navigation_blocker",
114
133
  "realm_from_url",
134
+ "realm_root_url",
115
135
  ]
@@ -0,0 +1,523 @@
1
+ """One authenticated browser context with realm-bound HentaiVerse runtimes.
2
+
3
+ The account context owns the browser lifecycle. Persistent and Isekai each
4
+ receive one immutable browser target and one driver that can never be switched
5
+ to the other target. Higher layers retain campaign and scheduling policy; this
6
+ module only establishes and enforces the browser/realm ownership boundary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Awaitable, Callable
13
+ from enum import StrEnum
14
+ from functools import partial
15
+ from typing import Any, Self, cast
16
+
17
+ from hbrowser.gallery.browser import (
18
+ BrowserCloser,
19
+ BrowserFactory,
20
+ BrowserOwner,
21
+ TabFactory,
22
+ TabHandle,
23
+ TabNavigator,
24
+ TabTransport,
25
+ TargetIdGetter,
26
+ create_browser,
27
+ stop_browser,
28
+ )
29
+
30
+ from .hv import HVDriver
31
+ from .realm import Realm, RealmDetectionError, realm_from_url
32
+ from .urls import HENTAIVERSE_ISEKAI_ROOT_URL, HENTAIVERSE_ROOT_URL
33
+
34
+ type AccountAuthenticator = Callable[[RealmBoundHVDriver], Awaitable[None]]
35
+ type BoundDriverFactory[TabT] = Callable[
36
+ [Realm, TabTransport[TabT]], RealmBoundHVDriver
37
+ ]
38
+ type CurrentUrlReader[TabT] = Callable[[TabT], Awaitable[object]]
39
+
40
+
41
+ class AccountContextError(RuntimeError):
42
+ """Base error for account-context lifecycle and binding failures."""
43
+
44
+
45
+ class AccountContextStateError(AccountContextError):
46
+ """An account-context operation is invalid in the current state."""
47
+
48
+
49
+ class RealmTabBindingError(AccountContextError):
50
+ """A realm driver or operation attempted to use a different browser tab."""
51
+
52
+
53
+ class RealmBindingViolationError(AccountContextError):
54
+ """A completed realm operation left its tab in another realm."""
55
+
56
+
57
+ class AccountContextState(StrEnum):
58
+ """Lifecycle state of :class:`HentaiVerseAccountContext`."""
59
+
60
+ NEW = "new"
61
+ OPEN = "open"
62
+ CLOSED = "closed"
63
+
64
+
65
+ def realm_root_url(realm: Realm) -> str:
66
+ """Return the canonical home URL for ``realm``."""
67
+
68
+ if not isinstance(realm, Realm):
69
+ raise TypeError("realm must be a Realm")
70
+ if realm is Realm.ISEKAI:
71
+ return HENTAIVERSE_ISEKAI_ROOT_URL
72
+ return HENTAIVERSE_ROOT_URL
73
+
74
+
75
+ async def _read_zendriver_url(tab: Any) -> object:
76
+ return await tab.evaluate("window.location.href")
77
+
78
+
79
+ async def _default_authenticator(driver: RealmBoundHVDriver) -> None:
80
+ await driver.login()
81
+
82
+
83
+ class RealmBoundHVDriver(HVDriver):
84
+ """An :class:`HVDriver` permanently attached to one owner-managed target.
85
+
86
+ The browser is externally owned by :class:`BrowserOwner`. This driver must
87
+ therefore never launch or close the browser. Its inherited HentaiVerse
88
+ methods remain available for existing hvbrowser and hvbattle clients, while
89
+ the ``page`` property rejects replacement after the initial binding.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ realm: Realm,
95
+ transport: TabTransport[Any],
96
+ *args: Any,
97
+ **kwargs: Any,
98
+ ) -> None:
99
+ if not isinstance(realm, Realm):
100
+ raise TypeError("realm must be a Realm")
101
+ if transport.role != realm.value:
102
+ raise RealmTabBindingError(
103
+ f"tab role {transport.role!r} does not match realm {realm.value!r}"
104
+ )
105
+
106
+ self._realm_page: Any = None
107
+ self._realm_page_frozen = False
108
+ self._realm = realm
109
+ self._transport = transport
110
+ self._bound = False
111
+ super().__init__(*args, **kwargs)
112
+
113
+ @property
114
+ def page(self) -> Any:
115
+ """The fixed raw tab used by inherited browser clients."""
116
+
117
+ return self._realm_page
118
+
119
+ @page.setter
120
+ def page(self, value: Any) -> None:
121
+ current = getattr(self, "_realm_page", None)
122
+ if getattr(self, "_realm_page_frozen", False) and value is not current:
123
+ raise RealmTabBindingError(
124
+ f"cannot replace the {self._realm.value} realm tab"
125
+ )
126
+ self._realm_page = value
127
+
128
+ @property
129
+ def realm(self) -> Realm:
130
+ return self._realm
131
+
132
+ @property
133
+ def transport(self) -> TabTransport[Any]:
134
+ return self._transport
135
+
136
+ @property
137
+ def tab_handle(self) -> TabHandle:
138
+ return self._transport.handle
139
+
140
+ @property
141
+ def is_bound(self) -> bool:
142
+ return self._bound
143
+
144
+ async def bind(self, browser: Any) -> Self:
145
+ """Bind once to this driver's transport without taking ownership."""
146
+
147
+ if self._bound:
148
+ raise RealmTabBindingError(f"{self._realm.value} driver is already bound")
149
+
150
+ async def bind_tab(tab: Any) -> None:
151
+ self.bind_existing_browser(browser, tab, owns_browser=False)
152
+ self._realm_page_frozen = True
153
+ self._bound = True
154
+
155
+ await self._transport.execute(bind_tab)
156
+ return self
157
+
158
+ async def _init_browser(self) -> None:
159
+ raise AccountContextStateError(
160
+ "a realm-bound driver cannot launch a browser; start its account "
161
+ "context instead"
162
+ )
163
+
164
+
165
+ class RealmRuntime[TabT]:
166
+ """Execute work on one fixed realm tab and enforce realm identity.
167
+
168
+ Calls to :meth:`execute` hold both the runtime operation lock and the
169
+ underlying target lock. Existing clients may use the supplied bound driver
170
+ inside the callback without consulting a mutable current-page pointer.
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ *,
176
+ realm: Realm,
177
+ transport: TabTransport[TabT],
178
+ driver: RealmBoundHVDriver,
179
+ current_url_reader: CurrentUrlReader[TabT],
180
+ ) -> None:
181
+ if transport.role != realm.value:
182
+ raise RealmTabBindingError(
183
+ f"tab role {transport.role!r} does not match realm {realm.value!r}"
184
+ )
185
+ if driver.realm is not realm or driver.transport is not transport:
186
+ raise RealmTabBindingError(
187
+ "realm driver and runtime must share one realm transport"
188
+ )
189
+ if not driver.is_bound:
190
+ raise RealmTabBindingError("realm driver must be bound before use")
191
+
192
+ self._realm = realm
193
+ self._transport = transport
194
+ self._driver = driver
195
+ self._current_url_reader = current_url_reader
196
+ self._operation_lock = asyncio.Lock()
197
+
198
+ @property
199
+ def realm(self) -> Realm:
200
+ return self._realm
201
+
202
+ @property
203
+ def transport(self) -> TabTransport[TabT]:
204
+ return self._transport
205
+
206
+ @property
207
+ def tab_handle(self) -> TabHandle:
208
+ return self._transport.handle
209
+
210
+ @property
211
+ def driver(self) -> RealmBoundHVDriver:
212
+ """The immutable-page driver used by operations in this runtime."""
213
+
214
+ return self._driver
215
+
216
+ async def _read_current_url(self) -> object:
217
+ return await self._transport.execute(self._current_url_reader)
218
+
219
+ async def current_realm(self) -> Realm:
220
+ """Inspect the realm currently displayed by the fixed tab."""
221
+
222
+ return realm_from_url(await self._read_current_url())
223
+
224
+ async def _assert_current_realm(self) -> None:
225
+ current = await self.current_realm()
226
+ if current is not self._realm:
227
+ raise RealmTabBindingError(
228
+ f"{self._realm.value} runtime observed the {current.value} realm"
229
+ )
230
+
231
+ async def _navigate_home(self) -> None:
232
+ await self._transport.navigate(realm_root_url(self._realm))
233
+ await self._assert_current_realm()
234
+
235
+ async def ensure_realm(self) -> None:
236
+ """Restore this tab to its bound realm if it drifted elsewhere."""
237
+
238
+ async with self._operation_lock:
239
+ try:
240
+ await self._assert_current_realm()
241
+ except RealmDetectionError, RealmTabBindingError:
242
+ await self._navigate_home()
243
+
244
+ async def _ensure_before_operation(self) -> None:
245
+ try:
246
+ await self._assert_current_realm()
247
+ except RealmDetectionError, RealmTabBindingError:
248
+ await self._navigate_home()
249
+
250
+ async def _restore_after_operation(self) -> None:
251
+ try:
252
+ await self._assert_current_realm()
253
+ except (RealmDetectionError, RealmTabBindingError) as drift_error:
254
+ try:
255
+ await self._navigate_home()
256
+ except BaseException as restore_error:
257
+ drift_error.add_note(
258
+ "restoring the bound realm also failed: "
259
+ f"{type(restore_error).__name__}"
260
+ )
261
+ raise RealmBindingViolationError(
262
+ f"operation left the {self._realm.value} realm tab outside its "
263
+ "binding"
264
+ ) from drift_error
265
+
266
+ async def execute[ResultT](
267
+ self,
268
+ operation: Callable[[RealmBoundHVDriver], Awaitable[ResultT]],
269
+ ) -> ResultT:
270
+ """Run one operation against the fixed driver and verify its realm."""
271
+
272
+ async with self._operation_lock:
273
+ await self._ensure_before_operation()
274
+
275
+ async def run(_: TabT) -> ResultT:
276
+ return await operation(self._driver)
277
+
278
+ try:
279
+ result = await self._transport.execute(run)
280
+ except BaseException as operation_error:
281
+ try:
282
+ await self._restore_after_operation()
283
+ except RealmBindingViolationError as binding_error:
284
+ operation_error.add_note(
285
+ "the realm operation failed after leaving its bound realm; "
286
+ f"the tab was restored ({type(binding_error).__name__})"
287
+ )
288
+ raise
289
+
290
+ await self._restore_after_operation()
291
+ return result
292
+
293
+ async def navigate(self, url: str) -> None:
294
+ """Navigate within this runtime's realm, rejecting cross-realm URLs."""
295
+
296
+ try:
297
+ destination = realm_from_url(url)
298
+ except RealmDetectionError as error:
299
+ raise RealmTabBindingError(
300
+ f"{self._realm.value} runtime rejected an untrusted destination"
301
+ ) from error
302
+ if destination is not self._realm:
303
+ raise RealmTabBindingError(
304
+ f"{self._realm.value} runtime rejected a cross-realm destination"
305
+ )
306
+
307
+ async with self._operation_lock:
308
+ await self._ensure_before_operation()
309
+ await self._transport.navigate(url)
310
+ await self._restore_after_operation()
311
+
312
+ async def go_home(self) -> None:
313
+ """Navigate the fixed tab to its canonical realm root."""
314
+
315
+ await self.navigate(realm_root_url(self._realm))
316
+
317
+ async def _authenticate(
318
+ self,
319
+ authenticator: AccountAuthenticator,
320
+ ) -> None:
321
+ """Run the one account login before normal realm checks are enabled."""
322
+
323
+ async with self._operation_lock:
324
+
325
+ async def authenticate(_: TabT) -> None:
326
+ await authenticator(self._driver)
327
+
328
+ await self._transport.execute(authenticate)
329
+ await self._assert_current_realm()
330
+
331
+ async def _establish(self) -> None:
332
+ async with self._operation_lock:
333
+ await self._navigate_home()
334
+
335
+
336
+ class HentaiVerseAccountContext[BrowserT, TabT]:
337
+ """Own one authenticated browser and two fixed HentaiVerse realm tabs."""
338
+
339
+ def __init__(
340
+ self,
341
+ *,
342
+ headless: bool = True,
343
+ authenticator: AccountAuthenticator | None = None,
344
+ browser_factory: BrowserFactory[BrowserT, TabT] | None = None,
345
+ browser_closer: BrowserCloser[BrowserT] | None = None,
346
+ tab_factory: TabFactory[BrowserT, TabT] | None = None,
347
+ tab_navigator: TabNavigator[TabT] | None = None,
348
+ target_id_getter: TargetIdGetter[TabT] | None = None,
349
+ current_url_reader: CurrentUrlReader[TabT] | None = None,
350
+ driver_factory: BoundDriverFactory[TabT] | None = None,
351
+ owner_id: str | None = None,
352
+ ) -> None:
353
+ default_browser_factory = partial(create_browser, headless=headless)
354
+ resolved_browser_factory = browser_factory or cast(
355
+ BrowserFactory[BrowserT, TabT],
356
+ default_browser_factory,
357
+ )
358
+ self._browser: BrowserT | None = None
359
+
360
+ async def capture_browser() -> tuple[BrowserT, TabT]:
361
+ browser, tab = await resolved_browser_factory()
362
+ self._browser = browser
363
+ return browser, tab
364
+
365
+ self._owner = BrowserOwner(
366
+ headless=headless,
367
+ main_tab_role=Realm.PERSISTENT.value,
368
+ owner_id=owner_id,
369
+ browser_factory=capture_browser,
370
+ browser_closer=browser_closer
371
+ or cast(BrowserCloser[BrowserT], stop_browser),
372
+ tab_factory=tab_factory,
373
+ tab_navigator=tab_navigator,
374
+ target_id_getter=target_id_getter,
375
+ )
376
+ self._authenticator = authenticator or _default_authenticator
377
+ self._current_url_reader = current_url_reader or cast(
378
+ CurrentUrlReader[TabT],
379
+ _read_zendriver_url,
380
+ )
381
+ self._driver_factory = driver_factory or cast(
382
+ BoundDriverFactory[TabT],
383
+ lambda realm, transport: RealmBoundHVDriver(
384
+ realm,
385
+ transport,
386
+ headless=headless,
387
+ ),
388
+ )
389
+ self._persistent: RealmRuntime[TabT] | None = None
390
+ self._isekai: RealmRuntime[TabT] | None = None
391
+ self._state = AccountContextState.NEW
392
+ self._lifecycle_lock = asyncio.Lock()
393
+
394
+ @property
395
+ def state(self) -> AccountContextState:
396
+ return self._state
397
+
398
+ @property
399
+ def owner(self) -> BrowserOwner[BrowserT, TabT]:
400
+ return self._owner
401
+
402
+ def _require_open(self) -> None:
403
+ if self._state is not AccountContextState.OPEN:
404
+ raise AccountContextStateError(
405
+ f"account context is not open (state: {self._state.value})"
406
+ )
407
+
408
+ @property
409
+ def persistent(self) -> RealmRuntime[TabT]:
410
+ self._require_open()
411
+ assert self._persistent is not None
412
+ return self._persistent
413
+
414
+ @property
415
+ def isekai(self) -> RealmRuntime[TabT]:
416
+ self._require_open()
417
+ assert self._isekai is not None
418
+ return self._isekai
419
+
420
+ def runtime(self, realm: Realm) -> RealmRuntime[TabT]:
421
+ """Return the fixed runtime for ``realm``."""
422
+
423
+ if not isinstance(realm, Realm):
424
+ raise TypeError("realm must be a Realm")
425
+ if realm is Realm.ISEKAI:
426
+ return self.isekai
427
+ return self.persistent
428
+
429
+ async def _bind_runtime(
430
+ self,
431
+ realm: Realm,
432
+ transport: TabTransport[TabT],
433
+ ) -> RealmRuntime[TabT]:
434
+ browser = self._browser
435
+ if browser is None:
436
+ raise AccountContextStateError("started browser owner has no browser")
437
+ driver = self._driver_factory(realm, transport)
438
+ if not isinstance(driver, RealmBoundHVDriver):
439
+ raise TypeError("driver_factory must return RealmBoundHVDriver")
440
+ await driver.bind(browser)
441
+ return RealmRuntime(
442
+ realm=realm,
443
+ transport=transport,
444
+ driver=driver,
445
+ current_url_reader=self._current_url_reader,
446
+ )
447
+
448
+ async def start(self) -> Self:
449
+ """Start, authenticate once, and establish both realm tabs."""
450
+
451
+ async with self._lifecycle_lock:
452
+ if self._state is AccountContextState.OPEN:
453
+ return self
454
+ if self._state is not AccountContextState.NEW:
455
+ raise AccountContextStateError(
456
+ f"cannot start account context in {self._state.value!r} state"
457
+ )
458
+
459
+ try:
460
+ await self._owner.start()
461
+ persistent = await self._bind_runtime(
462
+ Realm.PERSISTENT,
463
+ self._owner.main_tab,
464
+ )
465
+ await persistent._authenticate(self._authenticator)
466
+
467
+ isekai_transport = await self._owner.open_tab(Realm.ISEKAI.value)
468
+ isekai = await self._bind_runtime(Realm.ISEKAI, isekai_transport)
469
+ await isekai._establish()
470
+ except BaseException as startup_error:
471
+ try:
472
+ await self._owner.close()
473
+ except BaseException as cleanup_error:
474
+ startup_error.add_note(
475
+ "account browser cleanup also failed: "
476
+ f"{type(cleanup_error).__name__}"
477
+ )
478
+ self._state = AccountContextState.CLOSED
479
+ raise
480
+
481
+ self._persistent = persistent
482
+ self._isekai = isekai
483
+ self._state = AccountContextState.OPEN
484
+ return self
485
+
486
+ async def close(self) -> None:
487
+ """Close the single browser owner; bound drivers never close it."""
488
+
489
+ async with self._lifecycle_lock:
490
+ if self._state is AccountContextState.CLOSED:
491
+ return
492
+ try:
493
+ await self._owner.close()
494
+ finally:
495
+ self._state = AccountContextState.CLOSED
496
+
497
+ async def __aenter__(self) -> Self:
498
+ return await self.start()
499
+
500
+ async def __aexit__(
501
+ self,
502
+ exc_type: Any,
503
+ exc_value: Any,
504
+ traceback: Any,
505
+ ) -> None:
506
+ del exc_type, exc_value, traceback
507
+ await self.close()
508
+
509
+
510
+ __all__ = [
511
+ "AccountAuthenticator",
512
+ "AccountContextError",
513
+ "AccountContextState",
514
+ "AccountContextStateError",
515
+ "BoundDriverFactory",
516
+ "CurrentUrlReader",
517
+ "HentaiVerseAccountContext",
518
+ "RealmBindingViolationError",
519
+ "RealmBoundHVDriver",
520
+ "RealmRuntime",
521
+ "RealmTabBindingError",
522
+ "realm_root_url",
523
+ ]
@@ -0,0 +1,236 @@
1
+ import unittest
2
+ from collections.abc import Awaitable, Callable
3
+ from dataclasses import dataclass, field
4
+ from unittest.mock import AsyncMock
5
+
6
+ from hvbrowser.account_context import (
7
+ AccountContextState,
8
+ AccountContextStateError,
9
+ HentaiVerseAccountContext,
10
+ RealmBindingViolationError,
11
+ RealmBoundHVDriver,
12
+ RealmTabBindingError,
13
+ )
14
+ from hvbrowser.realm import Realm
15
+ from hvbrowser.urls import HENTAIVERSE_ISEKAI_ROOT_URL, HENTAIVERSE_ROOT_URL
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class _FakeTab:
20
+ target_id: str
21
+ url: str = "about:blank"
22
+ navigations: list[str] = field(default_factory=list)
23
+
24
+ async def evaluate(self, script: str) -> str:
25
+ if script != "window.location.href":
26
+ raise AssertionError(f"unexpected script: {script}")
27
+ return self.url
28
+
29
+ async def get(self, url: str) -> None:
30
+ self.url = url
31
+ self.navigations.append(url)
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class _FakeBrowser:
36
+ current_tab: _FakeTab
37
+
38
+
39
+ class AccountContextTests(unittest.IsolatedAsyncioTestCase):
40
+ def _context(
41
+ self,
42
+ *,
43
+ authenticator: Callable[[RealmBoundHVDriver], Awaitable[None]] | None = None,
44
+ ) -> tuple[
45
+ HentaiVerseAccountContext[_FakeBrowser, _FakeTab],
46
+ _FakeBrowser,
47
+ _FakeTab,
48
+ _FakeTab,
49
+ AsyncMock,
50
+ list[str],
51
+ ]:
52
+ persistent_tab = _FakeTab("persistent-target")
53
+ isekai_tab = _FakeTab("isekai-target")
54
+ browser = _FakeBrowser(persistent_tab)
55
+ browser_closer = AsyncMock()
56
+ authentication_targets: list[str] = []
57
+
58
+ async def create_browser() -> tuple[_FakeBrowser, _FakeTab]:
59
+ return browser, persistent_tab
60
+
61
+ async def open_tab(_: _FakeBrowser) -> _FakeTab:
62
+ return isekai_tab
63
+
64
+ async def navigate(tab: _FakeTab, url: str) -> None:
65
+ await tab.get(url)
66
+
67
+ async def authenticate(driver: RealmBoundHVDriver) -> None:
68
+ authentication_targets.append(driver.tab_handle.target_id)
69
+ await driver.page.get(HENTAIVERSE_ROOT_URL)
70
+
71
+ context = HentaiVerseAccountContext(
72
+ owner_id="account-test",
73
+ authenticator=authenticator or authenticate,
74
+ browser_factory=create_browser,
75
+ browser_closer=browser_closer,
76
+ tab_factory=open_tab,
77
+ tab_navigator=navigate,
78
+ target_id_getter=lambda tab: tab.target_id,
79
+ )
80
+ return (
81
+ context,
82
+ browser,
83
+ persistent_tab,
84
+ isekai_tab,
85
+ browser_closer,
86
+ authentication_targets,
87
+ )
88
+
89
+ async def test_one_login_establishes_two_immutable_realm_targets(self) -> None:
90
+ (
91
+ context,
92
+ _,
93
+ persistent_tab,
94
+ isekai_tab,
95
+ browser_closer,
96
+ authentication_targets,
97
+ ) = self._context()
98
+
99
+ async with context:
100
+ self.assertEqual(context.state, AccountContextState.OPEN)
101
+ self.assertEqual(authentication_targets, ["persistent-target"])
102
+ self.assertEqual(context.persistent.realm, Realm.PERSISTENT)
103
+ self.assertEqual(context.isekai.realm, Realm.ISEKAI)
104
+ self.assertEqual(
105
+ context.persistent.tab_handle.target_id,
106
+ "persistent-target",
107
+ )
108
+ self.assertEqual(context.isekai.tab_handle.target_id, "isekai-target")
109
+ self.assertEqual(
110
+ context.persistent.tab_handle.owner_id,
111
+ context.isekai.tab_handle.owner_id,
112
+ )
113
+ self.assertIs(context.persistent.driver.page, persistent_tab)
114
+ self.assertIs(context.isekai.driver.page, isekai_tab)
115
+ self.assertFalse(context.persistent.driver.owns_browser)
116
+ self.assertFalse(context.isekai.driver.owns_browser)
117
+ self.assertEqual(persistent_tab.url, HENTAIVERSE_ROOT_URL)
118
+ self.assertEqual(isekai_tab.url, HENTAIVERSE_ISEKAI_ROOT_URL)
119
+
120
+ self.assertEqual(context.state, AccountContextState.CLOSED)
121
+ browser_closer.assert_awaited_once()
122
+
123
+ async def test_runtime_does_not_follow_browser_current_tab(self) -> None:
124
+ context, browser, persistent_tab, isekai_tab, _, _ = self._context()
125
+ await context.start()
126
+ self.addAsyncCleanup(context.close)
127
+ browser.current_tab = isekai_tab
128
+
129
+ persistent_seen = await context.persistent.execute(
130
+ lambda driver: _return(driver.page.target_id)
131
+ )
132
+ browser.current_tab = persistent_tab
133
+ isekai_seen = await context.isekai.execute(
134
+ lambda driver: _return(driver.page.target_id)
135
+ )
136
+
137
+ self.assertEqual(persistent_seen, "persistent-target")
138
+ self.assertEqual(isekai_seen, "isekai-target")
139
+
140
+ async def test_runtime_repairs_drift_before_running_operation(self) -> None:
141
+ context, _, persistent_tab, _, _, _ = self._context()
142
+ await context.start()
143
+ self.addAsyncCleanup(context.close)
144
+ persistent_tab.url = HENTAIVERSE_ISEKAI_ROOT_URL
145
+
146
+ seen_url = await context.persistent.execute(
147
+ lambda driver: driver.page.evaluate("window.location.href")
148
+ )
149
+
150
+ self.assertEqual(seen_url, HENTAIVERSE_ROOT_URL)
151
+ self.assertEqual(persistent_tab.url, HENTAIVERSE_ROOT_URL)
152
+ self.assertEqual(persistent_tab.navigations[-1], HENTAIVERSE_ROOT_URL)
153
+
154
+ async def test_runtime_restores_and_reports_post_operation_drift(self) -> None:
155
+ context, _, persistent_tab, _, _, _ = self._context()
156
+ await context.start()
157
+ self.addAsyncCleanup(context.close)
158
+
159
+ async def cross_realm(driver: RealmBoundHVDriver) -> None:
160
+ await driver.page.get(HENTAIVERSE_ISEKAI_ROOT_URL)
161
+
162
+ with self.assertRaises(RealmBindingViolationError):
163
+ await context.persistent.execute(cross_realm)
164
+
165
+ self.assertEqual(persistent_tab.url, HENTAIVERSE_ROOT_URL)
166
+
167
+ async def test_failed_operation_still_restores_bound_realm(self) -> None:
168
+ context, _, _, isekai_tab, _, _ = self._context()
169
+ await context.start()
170
+ self.addAsyncCleanup(context.close)
171
+
172
+ async def fail_after_drift(driver: RealmBoundHVDriver) -> None:
173
+ await driver.page.get("https://example.test/outside")
174
+ raise LookupError("operation failed")
175
+
176
+ with self.assertRaisesRegex(LookupError, "operation failed"):
177
+ await context.isekai.execute(fail_after_drift)
178
+
179
+ self.assertEqual(isekai_tab.url, HENTAIVERSE_ISEKAI_ROOT_URL)
180
+
181
+ async def test_navigation_rejects_cross_realm_before_touching_tab(self) -> None:
182
+ context, _, _, isekai_tab, _, _ = self._context()
183
+ await context.start()
184
+ self.addAsyncCleanup(context.close)
185
+ previous_navigations = list(isekai_tab.navigations)
186
+
187
+ with self.assertRaisesRegex(RealmTabBindingError, "cross-realm"):
188
+ await context.isekai.navigate(HENTAIVERSE_ROOT_URL)
189
+
190
+ self.assertEqual(isekai_tab.navigations, previous_navigations)
191
+
192
+ async def test_bound_driver_rejects_page_replacement(self) -> None:
193
+ context, _, _, _, _, _ = self._context()
194
+ await context.start()
195
+ self.addAsyncCleanup(context.close)
196
+
197
+ with self.assertRaisesRegex(RealmTabBindingError, "cannot replace"):
198
+ context.persistent.driver.page = _FakeTab("replacement")
199
+
200
+ async def test_authentication_failure_closes_the_owned_browser(self) -> None:
201
+ async def fail_authentication(_: RealmBoundHVDriver) -> None:
202
+ raise PermissionError("login failed")
203
+
204
+ context, _, _, _, browser_closer, _ = self._context(
205
+ authenticator=fail_authentication
206
+ )
207
+
208
+ with self.assertRaisesRegex(PermissionError, "login failed"):
209
+ await context.start()
210
+
211
+ self.assertEqual(context.state, AccountContextState.CLOSED)
212
+ browser_closer.assert_awaited_once()
213
+ with self.assertRaises(AccountContextStateError):
214
+ await context.start()
215
+
216
+ async def test_close_is_idempotent_and_runtimes_require_open_context(self) -> None:
217
+ context, _, _, _, browser_closer, _ = self._context()
218
+
219
+ with self.assertRaises(AccountContextStateError):
220
+ _ = context.persistent
221
+
222
+ await context.start()
223
+ await context.close()
224
+ await context.close()
225
+
226
+ browser_closer.assert_awaited_once()
227
+ with self.assertRaises(AccountContextStateError):
228
+ _ = context.isekai
229
+
230
+
231
+ async def _return[ResultT](value: ResultT) -> ResultT:
232
+ return value
233
+
234
+
235
+ if __name__ == "__main__":
236
+ unittest.main()
File without changes
File without changes
File without changes
File without changes