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.
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/PKG-INFO +2 -2
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/pyproject.toml +2 -2
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/__init__.py +20 -0
- hvbrowser-0.2.4/src/hvbrowser/account_context.py +523 -0
- hvbrowser-0.2.4/tests/test_account_context.py +236 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/LICENSE +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/README.md +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/scripts/live_readonly_smoke.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/equipment_repair.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/hv.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/live_probe.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/lottery.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/maintenance_navigation.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/market.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/monster_lab.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/player.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/py.typed +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/realm.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/runtime.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/session.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/src/hvbrowser/urls.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_architecture.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_equipment_repair.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_hv.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_live_probe.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_lottery.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_maintenance_navigation.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_market.py +0 -0
- {hvbrowser-0.2.2 → hvbrowser-0.2.4}/tests/test_monster_lab.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|