tgtest 0.1.0__py3-none-any.whl
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.
- tgtest/__init__.py +26 -0
- tgtest/__main__.py +6 -0
- tgtest/cli.py +104 -0
- tgtest/client.py +227 -0
- tgtest/config.py +50 -0
- tgtest/engine.py +75 -0
- tgtest/exceptions.py +23 -0
- tgtest/logger.py +41 -0
- tgtest/matchers.py +125 -0
- tgtest/proxy.py +92 -0
- tgtest/pytest_plugin.py +53 -0
- tgtest/scenario.py +135 -0
- tgtest-0.1.0.dist-info/METADATA +353 -0
- tgtest-0.1.0.dist-info/RECORD +17 -0
- tgtest-0.1.0.dist-info/WHEEL +4 -0
- tgtest-0.1.0.dist-info/entry_points.txt +3 -0
- tgtest-0.1.0.dist-info/licenses/LICENSE +21 -0
tgtest/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""tgtest - end-to-end testing platform for Telegram bots.
|
|
2
|
+
|
|
3
|
+
Acts as a real Telegram *user* (via Telethon/MTProto) to talk to your bots and
|
|
4
|
+
assert on their replies. Tests can be written either as declarative YAML
|
|
5
|
+
scenarios or as Python/pytest functions using the same client helpers.
|
|
6
|
+
"""
|
|
7
|
+
from .config import Settings
|
|
8
|
+
from .logger import configure_logger
|
|
9
|
+
from .client import BotTester, ReplyMatchError
|
|
10
|
+
from .scenario import Scenario, load_scenario, load_scenarios
|
|
11
|
+
from .engine import run_scenario
|
|
12
|
+
from .exceptions import TgTestError, StepError, ScenarioError
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Settings",
|
|
16
|
+
"configure_logger",
|
|
17
|
+
"BotTester",
|
|
18
|
+
"ReplyMatchError",
|
|
19
|
+
"Scenario",
|
|
20
|
+
"load_scenario",
|
|
21
|
+
"load_scenarios",
|
|
22
|
+
"run_scenario",
|
|
23
|
+
"TgTestError",
|
|
24
|
+
"StepError",
|
|
25
|
+
"ScenarioError",
|
|
26
|
+
]
|
tgtest/__main__.py
ADDED
tgtest/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Command-line runner: `python -m tgtest run scenarios/`.
|
|
2
|
+
|
|
3
|
+
Discovers YAML scenarios, runs them against the configured bot, and prints a
|
|
4
|
+
per-scenario PASS/FAIL summary. Exit code is non-zero if any scenario fails so
|
|
5
|
+
it slots into CI.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from .config import Settings
|
|
15
|
+
from .logger import configure_logger
|
|
16
|
+
from .client import BotTester
|
|
17
|
+
from .scenario import load_scenarios, Scenario
|
|
18
|
+
from .engine import run_scenario
|
|
19
|
+
from .exceptions import StepError, ScenarioError
|
|
20
|
+
|
|
21
|
+
GREEN, RED, DIM, BOLD, RESET = "\033[32m", "\033[31m", "\033[2m", "\033[1m", "\033[0m"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _c(text: str, color: str, use_color: bool) -> str:
|
|
25
|
+
return f"{color}{text}{RESET}" if use_color else text
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _run_all(scenarios: list[Scenario], config: Settings, use_color: bool) -> int:
|
|
29
|
+
logger = configure_logger(config)
|
|
30
|
+
passed = failed = 0
|
|
31
|
+
async with BotTester.create(config) as tester:
|
|
32
|
+
for sc in scenarios:
|
|
33
|
+
logger.info("Running scenario: %s", sc.name)
|
|
34
|
+
label = f"{sc.name}" + (f" [{sc.source}]" if sc.source else "")
|
|
35
|
+
start = time.monotonic()
|
|
36
|
+
try:
|
|
37
|
+
await run_scenario(tester, sc)
|
|
38
|
+
except StepError as exc:
|
|
39
|
+
failed += 1
|
|
40
|
+
dur = time.monotonic() - start
|
|
41
|
+
print(f"{_c('FAIL', RED, use_color)} {label} ({dur:.1f}s)")
|
|
42
|
+
loc = f"step {exc.step_index}"
|
|
43
|
+
if exc.step_desc:
|
|
44
|
+
loc += f" — {exc.step_desc}"
|
|
45
|
+
print(_c(f" {loc}", DIM, use_color))
|
|
46
|
+
for line in str(exc).splitlines():
|
|
47
|
+
print(_c(f" {line}", RED, use_color))
|
|
48
|
+
logger.error("FAIL %s at step %s: %s", sc.name, exc.step_index, exc)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
failed += 1
|
|
51
|
+
dur = time.monotonic() - start
|
|
52
|
+
print(f"{_c('ERROR', RED, use_color)} {label} ({dur:.1f}s)")
|
|
53
|
+
print(_c(f" {type(exc).__name__}: {exc}", RED, use_color))
|
|
54
|
+
logger.exception("ERROR %s: %s", sc.name, exc)
|
|
55
|
+
else:
|
|
56
|
+
passed += 1
|
|
57
|
+
dur = time.monotonic() - start
|
|
58
|
+
print(f"{_c('PASS', GREEN, use_color)} {label} ({dur:.1f}s)")
|
|
59
|
+
logger.info("PASS %s (%.1fs)", sc.name, dur)
|
|
60
|
+
|
|
61
|
+
total = passed + failed
|
|
62
|
+
summary = f"\n{passed}/{total} passed"
|
|
63
|
+
print(_c(summary, GREEN if not failed else RED, use_color))
|
|
64
|
+
return 1 if failed else 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: list[str] | None = None) -> int:
|
|
68
|
+
parser = argparse.ArgumentParser(
|
|
69
|
+
prog="tgtest", description="Telegram bot E2E test runner"
|
|
70
|
+
)
|
|
71
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
72
|
+
|
|
73
|
+
run = sub.add_parser("run", help="run YAML scenarios")
|
|
74
|
+
run.add_argument("paths", nargs="+", help="scenario files, dirs, or globs")
|
|
75
|
+
run.add_argument("--bot", help="override bot for all scenarios")
|
|
76
|
+
run.add_argument("--env", help="path to a .env file")
|
|
77
|
+
run.add_argument("--no-color", action="store_true", help="disable colored output")
|
|
78
|
+
|
|
79
|
+
args = parser.parse_args(argv)
|
|
80
|
+
|
|
81
|
+
if args.cmd == "run":
|
|
82
|
+
try:
|
|
83
|
+
config = Settings.load(env_file=args.env)
|
|
84
|
+
except RuntimeError as exc:
|
|
85
|
+
print(f"config error: {exc}", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
try:
|
|
88
|
+
scenarios = load_scenarios(args.paths)
|
|
89
|
+
except ScenarioError as exc:
|
|
90
|
+
print(f"scenario error: {exc}", file=sys.stderr)
|
|
91
|
+
return 2
|
|
92
|
+
if args.bot:
|
|
93
|
+
for sc in scenarios:
|
|
94
|
+
sc.bot = args.bot
|
|
95
|
+
if not scenarios:
|
|
96
|
+
print("no scenarios found", file=sys.stderr)
|
|
97
|
+
return 2
|
|
98
|
+
use_color = not args.no_color and sys.stdout.isatty()
|
|
99
|
+
return asyncio.run(_run_all(scenarios, config, use_color))
|
|
100
|
+
return 2
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
sys.exit(main())
|
tgtest/client.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""BotTester - the user-facing client for talking to a bot and asserting replies.
|
|
2
|
+
|
|
3
|
+
Wraps Telethon's `client.conversation()` context, which gives ordered,
|
|
4
|
+
timeout-aware access to a bot's replies (including edits and button clicks).
|
|
5
|
+
This is the single object both the YAML engine and Python/pytest tests drive.
|
|
6
|
+
|
|
7
|
+
Typical Python usage:
|
|
8
|
+
|
|
9
|
+
async with BotTester.create(config) as tester:
|
|
10
|
+
async with tester.conversation("@my_bot") as chat:
|
|
11
|
+
await chat.send("/start")
|
|
12
|
+
await chat.expect(contains="Welcome", buttons=["Settings"])
|
|
13
|
+
await chat.click("Settings")
|
|
14
|
+
await chat.expect_edit(contains="Settings menu")
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from contextlib import asynccontextmanager
|
|
20
|
+
|
|
21
|
+
from telethon import TelegramClient
|
|
22
|
+
from telethon.errors import TimeoutError as TelethonTimeout
|
|
23
|
+
|
|
24
|
+
from .config import Settings
|
|
25
|
+
from .matchers import Matcher, button_texts
|
|
26
|
+
from .proxy import ProxyConfig, parse_proxy
|
|
27
|
+
|
|
28
|
+
# Re-export so tests can `from tgtest import ReplyMatchError`.
|
|
29
|
+
ReplyMatchError = AssertionError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _proxy_kwargs(proxy: ProxyConfig | None) -> dict:
|
|
33
|
+
"""Translate a ProxyConfig into TelegramClient keyword arguments."""
|
|
34
|
+
if proxy is None:
|
|
35
|
+
return {}
|
|
36
|
+
if proxy.kind == "mtproxy":
|
|
37
|
+
# MTProxy needs a dedicated connection class; proxy is (host, port, secret).
|
|
38
|
+
from telethon import connection
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"connection": connection.ConnectionTcpMTProxyRandomizedIntermediate,
|
|
42
|
+
"proxy": (proxy.host, proxy.port, proxy.secret),
|
|
43
|
+
}
|
|
44
|
+
# python-socks tuple: (kind, host, port, rdns, username, password).
|
|
45
|
+
return {
|
|
46
|
+
"proxy": (
|
|
47
|
+
proxy.kind,
|
|
48
|
+
proxy.host,
|
|
49
|
+
proxy.port,
|
|
50
|
+
proxy.rdns,
|
|
51
|
+
proxy.username,
|
|
52
|
+
proxy.password,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_client(config: Settings) -> TelegramClient:
|
|
58
|
+
"""Create a (not-yet-connected) TelegramClient, applying any proxy config.
|
|
59
|
+
|
|
60
|
+
Shared by BotTester (test runs) and login.py (first-time auth) so both honor
|
|
61
|
+
TG_PROXY identically.
|
|
62
|
+
"""
|
|
63
|
+
proxy = parse_proxy(config.proxy)
|
|
64
|
+
return TelegramClient(
|
|
65
|
+
config.session, config.api_id, config.api_hash, **_proxy_kwargs(proxy)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Chat:
|
|
70
|
+
"""A live conversation with one bot. Tracks the 'current' message so that
|
|
71
|
+
`click`/`expect_buttons`/`expect_edit` operate on the most recent reply."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, conv, bot, default_timeout: float):
|
|
74
|
+
self._conv = conv
|
|
75
|
+
self._bot = bot
|
|
76
|
+
self._default_timeout = default_timeout
|
|
77
|
+
self.last = None # most recent Message we received
|
|
78
|
+
|
|
79
|
+
# --- sending --------------------------------------------------------
|
|
80
|
+
async def send(self, text: str):
|
|
81
|
+
"""Send a plain text message to the bot."""
|
|
82
|
+
return await self._conv.send_message(text)
|
|
83
|
+
|
|
84
|
+
async def command(self, cmd: str):
|
|
85
|
+
"""Send a bot command, prepending '/' if the caller omitted it."""
|
|
86
|
+
if not cmd.startswith("/"):
|
|
87
|
+
cmd = "/" + cmd
|
|
88
|
+
return await self._conv.send_message(cmd)
|
|
89
|
+
|
|
90
|
+
# --- receiving ------------------------------------------------------
|
|
91
|
+
async def get_reply(self, timeout: float | None = None):
|
|
92
|
+
"""Wait for and return the next reply message from the bot."""
|
|
93
|
+
try:
|
|
94
|
+
self.last = await self._conv.get_response(
|
|
95
|
+
timeout=timeout if timeout is not None else self._default_timeout
|
|
96
|
+
)
|
|
97
|
+
except (asyncio.TimeoutError, TelethonTimeout):
|
|
98
|
+
wait = timeout or self._default_timeout
|
|
99
|
+
raise AssertionError(
|
|
100
|
+
f"timed out after {wait}s waiting for a reply"
|
|
101
|
+
) from None
|
|
102
|
+
return self.last
|
|
103
|
+
|
|
104
|
+
async def expect(self, timeout: float | None = None, **spec):
|
|
105
|
+
"""Wait for the next reply and assert it matches the given clauses.
|
|
106
|
+
|
|
107
|
+
Clauses are the same keys as a YAML `expect` block (equals, contains,
|
|
108
|
+
regex, buttons, ...). Returns the matched Message.
|
|
109
|
+
"""
|
|
110
|
+
message = await self.get_reply(timeout=timeout)
|
|
111
|
+
self._assert(Matcher.from_spec(spec), message)
|
|
112
|
+
return message
|
|
113
|
+
|
|
114
|
+
async def expect_edit(self, timeout: float | None = None, **spec):
|
|
115
|
+
"""Wait for the *current* message to be edited and assert on it.
|
|
116
|
+
|
|
117
|
+
Bots commonly edit a message in place after an inline-button click.
|
|
118
|
+
"""
|
|
119
|
+
if self.last is None:
|
|
120
|
+
raise AssertionError("expect_edit called before any reply was received")
|
|
121
|
+
try:
|
|
122
|
+
self.last = await self._conv.get_edit(
|
|
123
|
+
self.last,
|
|
124
|
+
timeout=timeout if timeout is not None else self._default_timeout,
|
|
125
|
+
)
|
|
126
|
+
except (asyncio.TimeoutError, TelethonTimeout):
|
|
127
|
+
wait = timeout or self._default_timeout
|
|
128
|
+
raise AssertionError(
|
|
129
|
+
f"timed out after {wait}s waiting for an edit"
|
|
130
|
+
) from None
|
|
131
|
+
self._assert(Matcher.from_spec(spec), self.last)
|
|
132
|
+
return self.last
|
|
133
|
+
|
|
134
|
+
async def expect_no_reply(self, within: float = 2.0):
|
|
135
|
+
"""Assert the bot sends nothing within `within` seconds."""
|
|
136
|
+
try:
|
|
137
|
+
msg = await self._conv.get_response(timeout=within)
|
|
138
|
+
except (asyncio.TimeoutError, TelethonTimeout):
|
|
139
|
+
return # success: nothing arrived
|
|
140
|
+
raise AssertionError(
|
|
141
|
+
f"expected no reply within {within}s but got: {msg.text!r}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def expect_buttons(self, *labels: str, exact: bool = False):
|
|
145
|
+
"""Assert the current message exposes the given inline/reply buttons."""
|
|
146
|
+
if self.last is None:
|
|
147
|
+
raise AssertionError("expect_buttons called before any reply was received")
|
|
148
|
+
actual = button_texts(self.last)
|
|
149
|
+
if exact:
|
|
150
|
+
if actual != list(labels):
|
|
151
|
+
raise AssertionError(
|
|
152
|
+
f"buttons differ\n expected: {list(labels)}\n actual: {actual}"
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
missing = [b for b in labels if b not in actual]
|
|
156
|
+
if missing:
|
|
157
|
+
raise AssertionError(
|
|
158
|
+
f"missing buttons {missing}\n actual buttons: {actual}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# --- interacting ----------------------------------------------------
|
|
162
|
+
async def click(self, text: str | None = None, *, index: int | None = None,
|
|
163
|
+
data: str | None = None):
|
|
164
|
+
"""Click an inline button on the current message.
|
|
165
|
+
|
|
166
|
+
Identify the button by visible `text`, by 0-based `index`, or by raw
|
|
167
|
+
callback `data`.
|
|
168
|
+
"""
|
|
169
|
+
if self.last is None:
|
|
170
|
+
raise AssertionError("click called before any reply was received")
|
|
171
|
+
if text is not None:
|
|
172
|
+
return await self.last.click(text=text)
|
|
173
|
+
if data is not None:
|
|
174
|
+
payload = data.encode() if isinstance(data, str) else data
|
|
175
|
+
return await self.last.click(data=payload)
|
|
176
|
+
if index is not None:
|
|
177
|
+
return await self.last.click(index)
|
|
178
|
+
raise ValueError("click requires one of: text, index, data")
|
|
179
|
+
|
|
180
|
+
# --- internal -------------------------------------------------------
|
|
181
|
+
def _assert(self, matcher: Matcher, message):
|
|
182
|
+
reason = matcher.check(message)
|
|
183
|
+
if reason is not None:
|
|
184
|
+
raise AssertionError(f"{matcher.describe()} failed:\n {reason}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class BotTester:
|
|
188
|
+
"""Owns the Telethon client connection. Hands out per-bot `_Chat` objects."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, client: TelegramClient, config: Settings):
|
|
191
|
+
self._client = client
|
|
192
|
+
self._config = config
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
@asynccontextmanager
|
|
196
|
+
async def create(cls, config: Settings):
|
|
197
|
+
"""Connect (using an existing session) and yield a ready BotTester.
|
|
198
|
+
|
|
199
|
+
The session must already be authorized; run `python login.py` once to
|
|
200
|
+
create it. We deliberately do NOT prompt for a login code here so that
|
|
201
|
+
test runs never block on interactive input.
|
|
202
|
+
"""
|
|
203
|
+
client = build_client(config)
|
|
204
|
+
await client.connect()
|
|
205
|
+
if not await client.is_user_authorized():
|
|
206
|
+
await client.disconnect()
|
|
207
|
+
raise RuntimeError(
|
|
208
|
+
f"Session {config.session!r} is not authorized. "
|
|
209
|
+
"Run `python login.py` once to log in."
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
yield cls(client, config)
|
|
213
|
+
finally:
|
|
214
|
+
await client.disconnect()
|
|
215
|
+
|
|
216
|
+
@asynccontextmanager
|
|
217
|
+
async def conversation(self, bot: str | None = None, timeout: float | None = None):
|
|
218
|
+
"""Open a conversation with `bot` (defaults to TG_DEFAULT_BOT)."""
|
|
219
|
+
target = bot or self._config.default_bot
|
|
220
|
+
if not target:
|
|
221
|
+
raise ValueError("no bot specified and TG_DEFAULT_BOT is not set")
|
|
222
|
+
entity = await self._client.get_entity(target)
|
|
223
|
+
conv_timeout = timeout if timeout is not None else self._config.timeout
|
|
224
|
+
async with self._client.conversation(
|
|
225
|
+
entity, timeout=conv_timeout, total_timeout=None
|
|
226
|
+
) as conv:
|
|
227
|
+
yield _Chat(conv, entity, conv_timeout)
|
tgtest/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Application settings, loaded from the environment via pydantic-settings.
|
|
2
|
+
|
|
3
|
+
Mirrors the project template's `Settings(BaseSettings)` pattern. All tgtest
|
|
4
|
+
variables share the ``TG_`` prefix (e.g. ``TG_API_ID`` -> ``api_id``), so both
|
|
5
|
+
the CLI runner and the pytest plugin get identical, validated config.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Settings(BaseSettings):
|
|
14
|
+
"""tgtest settings loaded from environment / ``.env`` (prefix ``TG_``)."""
|
|
15
|
+
|
|
16
|
+
# Telegram user-account credentials (api_id/api_hash from my.telegram.org).
|
|
17
|
+
api_id: int
|
|
18
|
+
api_hash: str
|
|
19
|
+
# Telethon session file; created once by `python login.py`.
|
|
20
|
+
session: str = "tgtest.session"
|
|
21
|
+
# Phone of the test user account, used only for first-time login.
|
|
22
|
+
phone: str | None = None
|
|
23
|
+
# Bot used when a scenario / test does not name one explicitly.
|
|
24
|
+
default_bot: str | None = None
|
|
25
|
+
# Optional proxy URL, e.g. socks5://user:pass@host:1080 or mtproxy://SECRET@host:443.
|
|
26
|
+
proxy: str | None = None
|
|
27
|
+
# Default per-step reply timeout, in seconds.
|
|
28
|
+
timeout: float = 15.0
|
|
29
|
+
# Logging.
|
|
30
|
+
app_name: str = "tgtest"
|
|
31
|
+
log_level: str = "INFO"
|
|
32
|
+
|
|
33
|
+
model_config = SettingsConfigDict(
|
|
34
|
+
env_prefix="TG_",
|
|
35
|
+
env_file=".env",
|
|
36
|
+
env_file_encoding="utf-8",
|
|
37
|
+
extra="ignore",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def load(cls, env_file: str | None = None) -> "Settings":
|
|
42
|
+
"""Load settings, raising a friendly error if credentials are missing."""
|
|
43
|
+
try:
|
|
44
|
+
return cls(_env_file=env_file) if env_file else cls()
|
|
45
|
+
except ValidationError as exc:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
"Invalid tgtest config (see .env.example). "
|
|
48
|
+
"TG_API_ID and TG_API_HASH are required; get them from "
|
|
49
|
+
f"https://my.telegram.org.\n{exc}"
|
|
50
|
+
) from exc
|
tgtest/engine.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Execute a parsed Scenario against a live BotTester conversation.
|
|
2
|
+
|
|
3
|
+
The engine maps each YAML step to a call on the `_Chat` helper. All assertion
|
|
4
|
+
failures and timeouts are wrapped in StepError with the offending step's index
|
|
5
|
+
and description, so the runner can pinpoint failures.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
from .client import BotTester
|
|
12
|
+
from .matchers import Matcher
|
|
13
|
+
from .scenario import Scenario, Step
|
|
14
|
+
from .exceptions import StepError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _run_step(chat, step: Step):
|
|
18
|
+
action, value, opts = step.action, step.value, step.options
|
|
19
|
+
timeout = float(opts["timeout"]) if "timeout" in opts else None
|
|
20
|
+
|
|
21
|
+
if action == "send":
|
|
22
|
+
await chat.send(str(value))
|
|
23
|
+
elif action == "command":
|
|
24
|
+
await chat.command(str(value))
|
|
25
|
+
elif action == "sleep":
|
|
26
|
+
await asyncio.sleep(float(value))
|
|
27
|
+
elif action == "expect":
|
|
28
|
+
message = await chat.get_reply(timeout=timeout)
|
|
29
|
+
reason = Matcher.from_spec(value).check(message)
|
|
30
|
+
if reason:
|
|
31
|
+
raise AssertionError(reason)
|
|
32
|
+
elif action == "expect_edit":
|
|
33
|
+
await chat.expect_edit(timeout=timeout, **(_as_spec(value)))
|
|
34
|
+
elif action == "expect_no_reply":
|
|
35
|
+
within = float(value) if value is not None else float(opts.get("within", 2.0))
|
|
36
|
+
await chat.expect_no_reply(within=within)
|
|
37
|
+
elif action == "expect_buttons":
|
|
38
|
+
labels = value if isinstance(value, list) else [value]
|
|
39
|
+
chat.expect_buttons(*labels, exact=bool(opts.get("exact", False)))
|
|
40
|
+
elif action == "click":
|
|
41
|
+
await chat.click(
|
|
42
|
+
text=value if isinstance(value, str) else None,
|
|
43
|
+
index=opts.get("index"),
|
|
44
|
+
data=opts.get("data"),
|
|
45
|
+
)
|
|
46
|
+
else: # pragma: no cover - parser guarantees valid actions
|
|
47
|
+
raise StepError(f"unknown action {action!r}", step_index=step.index)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _as_spec(value):
|
|
51
|
+
"""expect_edit accepts the same shorthand as expect (string or dict)."""
|
|
52
|
+
if value is None:
|
|
53
|
+
return {}
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return {"equals": value}
|
|
56
|
+
return dict(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def run_scenario(tester: BotTester, scenario: Scenario):
|
|
60
|
+
"""Run every step of a scenario. Raises StepError on the first failure."""
|
|
61
|
+
async with tester.conversation(scenario.bot, timeout=scenario.timeout) as chat:
|
|
62
|
+
for step in scenario.steps:
|
|
63
|
+
try:
|
|
64
|
+
await _run_step(chat, step)
|
|
65
|
+
except AssertionError as exc:
|
|
66
|
+
raise StepError(
|
|
67
|
+
str(exc), step_index=step.index, step_desc=step.describe()
|
|
68
|
+
) from exc
|
|
69
|
+
except StepError:
|
|
70
|
+
raise
|
|
71
|
+
except Exception as exc: # surface unexpected errors with step context
|
|
72
|
+
raise StepError(
|
|
73
|
+
f"{type(exc).__name__}: {exc}",
|
|
74
|
+
step_index=step.index, step_desc=step.describe(),
|
|
75
|
+
) from exc
|
tgtest/exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Exception hierarchy for tgtest."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TgTestError(Exception):
|
|
5
|
+
"""Base class for all tgtest errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScenarioError(TgTestError):
|
|
9
|
+
"""Raised when a YAML scenario is malformed or cannot be parsed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StepError(TgTestError):
|
|
13
|
+
"""Raised when a scenario step fails (assertion failed or timed out).
|
|
14
|
+
|
|
15
|
+
Carries the step index and a human-readable description so the runner can
|
|
16
|
+
report exactly which step in which scenario broke.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, step_index: int | None = None,
|
|
20
|
+
step_desc: str | None = None):
|
|
21
|
+
self.step_index = step_index
|
|
22
|
+
self.step_desc = step_desc
|
|
23
|
+
super().__init__(message)
|
tgtest/logger.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Logging setup, modeled on the project template's rotating-file logger."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from logging.handlers import RotatingFileHandler
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tgtest.config import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def configure_logger(settings: Settings) -> logging.Logger:
|
|
12
|
+
"""Configure and return the application logger.
|
|
13
|
+
|
|
14
|
+
Writes rotating log files under ``logs/`` and, at DEBUG level, also echoes
|
|
15
|
+
to the console so test runs can be traced live.
|
|
16
|
+
"""
|
|
17
|
+
log_dir = Path("logs")
|
|
18
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(settings.app_name)
|
|
21
|
+
logger.setLevel(settings.log_level.upper())
|
|
22
|
+
logger.propagate = False
|
|
23
|
+
|
|
24
|
+
formatter = logging.Formatter(
|
|
25
|
+
"%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
26
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
file_handler = RotatingFileHandler(
|
|
30
|
+
log_dir / "tgtest.log",
|
|
31
|
+
maxBytes=5 * 1024 * 1024,
|
|
32
|
+
backupCount=3,
|
|
33
|
+
encoding="utf-8",
|
|
34
|
+
)
|
|
35
|
+
file_handler.setFormatter(formatter)
|
|
36
|
+
file_handler.setLevel(settings.log_level.upper())
|
|
37
|
+
|
|
38
|
+
if not logger.handlers:
|
|
39
|
+
logger.addHandler(file_handler)
|
|
40
|
+
|
|
41
|
+
return logger
|
tgtest/matchers.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Text and button matchers shared by the engine and the Python helper API.
|
|
2
|
+
|
|
3
|
+
A matcher is built from a dict (the body of an `expect` step) and applied to a
|
|
4
|
+
Telethon Message. It returns None on success or a human-readable failure
|
|
5
|
+
reason string. Keeping failures as strings (rather than raising) lets the
|
|
6
|
+
caller assemble a single rich StepError with full context.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
# Keys in an expect block that describe the message *text*.
|
|
14
|
+
_TEXT_KEYS = ("equals", "contains", "icontains", "regex", "iregex", "not_contains")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _message_text(message) -> str:
|
|
18
|
+
"""Best-effort textual content of a message (text, or media caption)."""
|
|
19
|
+
return (getattr(message, "text", None) or getattr(message, "message", None) or "")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def button_texts(message) -> list[str]:
|
|
23
|
+
"""Flatten an inline/reply keyboard into a list of button label strings."""
|
|
24
|
+
labels: list[str] = []
|
|
25
|
+
buttons = getattr(message, "buttons", None)
|
|
26
|
+
if not buttons:
|
|
27
|
+
return labels
|
|
28
|
+
for row in buttons:
|
|
29
|
+
for btn in row:
|
|
30
|
+
text = getattr(btn, "text", None)
|
|
31
|
+
if text is not None:
|
|
32
|
+
labels.append(text)
|
|
33
|
+
return labels
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Matcher:
|
|
38
|
+
equals: str | None = None
|
|
39
|
+
contains: str | None = None
|
|
40
|
+
icontains: str | None = None
|
|
41
|
+
not_contains: str | None = None
|
|
42
|
+
regex: str | None = None
|
|
43
|
+
iregex: str | None = None
|
|
44
|
+
buttons: list[str] | None = None # buttons that must all be present
|
|
45
|
+
buttons_exact: list[str] | None = None # full keyboard must equal this set/order
|
|
46
|
+
has_buttons: bool | None = None # assert presence/absence of any keyboard
|
|
47
|
+
_raw: dict = field(default_factory=dict, repr=False)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_spec(cls, spec) -> "Matcher":
|
|
51
|
+
"""Build a Matcher from a YAML `expect` value.
|
|
52
|
+
|
|
53
|
+
Accepts a plain string (shorthand for `equals`) or a dict of keys.
|
|
54
|
+
"""
|
|
55
|
+
if spec is None:
|
|
56
|
+
return cls(_raw={})
|
|
57
|
+
if isinstance(spec, str):
|
|
58
|
+
return cls(equals=spec, _raw={"equals": spec})
|
|
59
|
+
if not isinstance(spec, dict):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"expect must be a string or mapping, got {type(spec).__name__}"
|
|
62
|
+
)
|
|
63
|
+
known = {
|
|
64
|
+
"equals", "contains", "icontains", "not_contains", "regex", "iregex",
|
|
65
|
+
"buttons", "buttons_exact", "has_buttons",
|
|
66
|
+
}
|
|
67
|
+
unknown = set(spec) - known
|
|
68
|
+
if unknown:
|
|
69
|
+
raise ValueError(f"unknown expect keys: {', '.join(sorted(unknown))}")
|
|
70
|
+
return cls(
|
|
71
|
+
equals=spec.get("equals"),
|
|
72
|
+
contains=spec.get("contains"),
|
|
73
|
+
icontains=spec.get("icontains"),
|
|
74
|
+
not_contains=spec.get("not_contains"),
|
|
75
|
+
regex=spec.get("regex"),
|
|
76
|
+
iregex=spec.get("iregex"),
|
|
77
|
+
buttons=spec.get("buttons"),
|
|
78
|
+
buttons_exact=spec.get("buttons_exact"),
|
|
79
|
+
has_buttons=spec.get("has_buttons"),
|
|
80
|
+
_raw=dict(spec),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def check(self, message) -> str | None:
|
|
84
|
+
"""Return None if the message satisfies every clause, else a reason."""
|
|
85
|
+
return self._check_text(_message_text(message)) or self._check_buttons(
|
|
86
|
+
button_texts(message)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _check_text(self, text: str) -> str | None:
|
|
90
|
+
if self.equals is not None and text != self.equals:
|
|
91
|
+
return f"text != equals\n expected: {self.equals!r}\n actual: {text!r}"
|
|
92
|
+
if self.contains is not None and self.contains not in text:
|
|
93
|
+
return f"text does not contain {self.contains!r}\n actual: {text!r}"
|
|
94
|
+
if self.icontains is not None and self.icontains.lower() not in text.lower():
|
|
95
|
+
return f"text does not contain (ci) {self.icontains!r}\n actual: {text!r}"
|
|
96
|
+
if self.not_contains is not None and self.not_contains in text:
|
|
97
|
+
return (
|
|
98
|
+
f"text unexpectedly contains {self.not_contains!r}\n actual: {text!r}"
|
|
99
|
+
)
|
|
100
|
+
if self.regex is not None and not re.search(self.regex, text):
|
|
101
|
+
return f"text does not match regex {self.regex!r}\n actual: {text!r}"
|
|
102
|
+
if self.iregex is not None and not re.search(self.iregex, text, re.IGNORECASE):
|
|
103
|
+
return f"text does not match regex (ci) {self.iregex!r}\n actual: {text!r}"
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _check_buttons(self, actual: list[str]) -> str | None:
|
|
107
|
+
if self.has_buttons is not None and bool(actual) != self.has_buttons:
|
|
108
|
+
return (
|
|
109
|
+
f"has_buttons expected {self.has_buttons}, "
|
|
110
|
+
f"got {bool(actual)} (buttons={actual})"
|
|
111
|
+
)
|
|
112
|
+
if self.buttons is not None:
|
|
113
|
+
missing = [b for b in self.buttons if b not in actual]
|
|
114
|
+
if missing:
|
|
115
|
+
return f"missing buttons {missing}\n actual buttons: {actual}"
|
|
116
|
+
if self.buttons_exact is not None and actual != list(self.buttons_exact):
|
|
117
|
+
return (
|
|
118
|
+
f"buttons differ\n expected: {list(self.buttons_exact)}\n"
|
|
119
|
+
f" actual: {actual}"
|
|
120
|
+
)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def describe(self) -> str:
|
|
124
|
+
parts = [f"{k}={v!r}" for k, v in self._raw.items()]
|
|
125
|
+
return "expect(" + ", ".join(parts) + ")" if parts else "expect(any reply)"
|