Phase 4: MAID E2E Tests & CI Integration — Implementation Plan¶
Design Document: docs/designs/teltest/README.md
Priority: P1 — Critical for regression safety
Estimated Duration: 3 weeks across 4 sections
Prerequisites: TelTest Phases 1–3 (MUDClient, Script DSL, Multi-client helpers)
Summary¶
This plan implements the MAID-specific E2E test suite, YAML smoke test scripts, CI integration, and TelTest package setup. It covers:
- MAID E2E test fixtures (
tests/e2e/conftest.py) — server lifecycle, client provisioning, account management, world isolation,MaidAuthDriver - Login & registration tests (
tests/e2e/test_login.py) — the fullLoginHandlerstate machine - Character creation tests (
tests/e2e/test_character.py) — the fullCharacterHandlerstate machine - Gameplay command tests (
tests/e2e/test_gameplay.py) —look,move,inventory,get,drop,say - Multi-client tests (
tests/e2e/test_multiplayer.py) — two clients in same room,sayvisibility - YAML smoke scripts (
tests/e2e/scripts/) — declarative smoke tests for CI gate - CI workflow (
.github/workflows/e2e.yml) — separate from unit tests, timeout-multiplied, fail-fast - Package setup (
packages/teltest/pyproject.toml) — standalone package with zero MAID deps
Key MAID-specific knowledge used:
LoginHandler(packages/maid-classic-rpg/src/maid_classic_rpg/auth/login_handler.py) — states: WELCOME → LOGIN_USERNAME → LOGIN_PASSWORD → LOGGED_IN, or WELCOME → REGISTER_USERNAME → REGISTER_EMAIL → REGISTER_PASSWORD → REGISTER_CONFIRM → LOGGED_IN. Max 3 login attempts. Password echo off/on via IAC WILL/WONT ECHO.CharacterHandler(packages/maid-classic-rpg/src/maid_classic_rpg/auth/character_handler.py) — states: SELECT → CREATE_NAME → CREATE_RACE → CREATE_CLASS → CREATE_GENDER → CREATE_CONFIRM → SELECTED.MAX_CHARACTERS_PER_ACCOUNT = 5. Races/classes are 1-indexed (1–10). Gender is 1–3 (Male/Female/Neutral).ClassicRPGContentPack.on_load()(packages/maid-classic-rpg/src/maid_classic_rpg/pack.py:295-402) — registersclassic_rpg_connection_handleron the server, which runs LoginHandler → CharacterHandler → enter world → main command loop.GameEngine.__init__()accepts optionalsettings,document_store— defaults toInMemoryDocumentStorewhen no store provided.- Main loop:
quit/exit→"Goodbye!"→ disconnect.
4.1 TelTest Package Setup¶
Priority: P1 | Dependencies: none (parallel with other sections)
4.1.1 Package Metadata — packages/teltest/pyproject.toml¶
- [ ] Create
packages/teltest/pyproject.toml:[project] name = "teltest" version = "0.1.0" description = "TelTest — E2E testing framework for MUD engines" readme = "README.md" requires-python = ">=3.12" license = {text = "MIT"} authors = [ {name = "MAID Development Team"} ] keywords = ["mud", "telnet", "e2e", "testing", "pytest"] classifiers = [ "Development Status :: 3 - Alpha", "Framework :: Pytest", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Testing", "Topic :: Games/Entertainment :: Multi-User Dungeons (MUD)", ] dependencies = [ "pytest>=8.0", "pyyaml>=6.0", "pydantic>=2.0", ] [project.entry-points.pytest11] teltest = "teltest.pytest_plugin" [project.scripts] teltest = "teltest.cli:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/teltest"] [tool.hatch.build.targets.sdist] include = ["src"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] - [ ] Verify zero MAID-specific dependencies — only
pytest,pyyaml,pydantic - [ ] Register
pytest11entry point forteltest.pytest_plugin— this auto-activates the plugin when installed
4.1.2 UV Workspace Integration¶
- [ ] Add
"packages/teltest"totool.uv.workspace.membersin rootpyproject.toml: - [ ] Add
teltest = { workspace = true }to[tool.uv.sources]in rootpyproject.toml - [ ] Add
"teltest"to[dependency-groups] devin rootpyproject.toml - [ ] Add
"teltest"toknown-first-partyin[tool.ruff.lint.isort]in rootpyproject.toml - [ ] Run
uv syncand verify teltest is installed in the workspace - [ ] Run
uv run python -c "import teltest"to verify importability
4.1.3 Package Directory Structure¶
- [ ] Create directory tree:
packages/teltest/ ├── pyproject.toml ├── README.md ├── src/ │ └── teltest/ │ ├── __init__.py │ ├── client.py # MUDClient (Phase 1) │ ├── protocol.py # TelnetProtocol (Phase 1) │ ├── ansi.py # ANSI stripper (Phase 1) │ ├── buffer.py # OutputBuffer (Phase 1) │ ├── expect.py # Match, ExpectTimeout, etc. (Phase 1) │ ├── gmcp.py # GMCP helpers (Phase 3) │ ├── recorder.py # Session recording (Phase 2) │ ├── script/ │ │ ├── __init__.py │ │ ├── schema.py # YAML script Pydantic models (Phase 2) │ │ ├── runner.py # Script execution engine (Phase 2) │ │ └── converter.py # Recording → script (Phase 2) │ ├── cli.py # CLI entry point (Phase 2) │ └── pytest_plugin.py # Fixtures, markers, CLI opts (Phase 1) └── tests/ ├── __init__.py ├── conftest.py ├── test_client.py ├── test_protocol.py ├── test_ansi.py ├── test_buffer.py ├── test_expect.py └── test_script_runner.py - [ ] Create
packages/teltest/src/teltest/__init__.pywith public API exports:"""TelTest — E2E testing framework for MUD engines.""" from teltest.client import MUDClient from teltest.expect import ( ConnectionClosed, ExpectTimeout, Match, TranscriptEntry, UnexpectedMatch, ) __all__ = [ "MUDClient", "Match", "TranscriptEntry", "ExpectTimeout", "ConnectionClosed", "UnexpectedMatch", ] __version__ = "0.1.0"
4.1.4 Package README — packages/teltest/README.md¶
- [ ] Create
packages/teltest/README.mdwith the following sections: - Header — "TelTest — E2E Testing Framework for MUDs"
- Overview — Async telnet client + pytest fixtures for full-stack MUD testing
- Installation —
pip install teltest(standalone) or workspace (uv sync) - Quick Start — minimal example with
MUDClientcontext manager: - Key Features — bullet list: async-native, cursor-based expect, ANSI stripping, IAC negotiation, GMCP support, YAML script DSL, session recording
- MUDClient API — summary table of
connect(),disconnect(),send(),expect(),expect_prompt(),expect_sequence(),expect_any(),expect_not() - Fixtures —
mud_server,mud_client,raw_client,registered_account - AuthDriver Protocol — how to implement for your MUD engine
- YAML Script DSL — example smoke test script
- Configuration — environment variables table (
TELTEST_HOST,TELTEST_PORT,TELTEST_TIMEOUT,TELTEST_TIMEOUT_MULTIPLIER,TELTEST_STRIP_ANSI) - CI Integration — sample GitHub Actions snippet
- License — MIT
4.1.5 Verification¶
- [ ] Run
uv sync --dev— teltest resolves and installs - [ ] Run
uv build packages/teltest— package builds cleanly - [ ] Verify
uv run pytest packages/teltest/tests/ -vpasses (or has no tests to collect initially)
4.2 MAID E2E Test Fixtures¶
Priority: P0 | Dependencies: TelTest Phase 1 (MUDClient, fixtures, pytest plugin)
4.2.1 Conftest — tests/e2e/conftest.py¶
- [ ] Create
tests/e2e/__init__.py(empty) - [ ] Create
tests/e2e/conftest.pywith the following fixtures:
mud_server (session-scoped)¶
@pytest.fixture(scope="session")
async def mud_server() -> AsyncGenerator[MUDServer, None]:
"""Start a MAID GameEngine with in-memory storage on an ephemeral port.
Loads StdlibContentPack and ClassicRPGContentPack.
Waits for the telnet listener to accept connections before yielding.
Shuts down cleanly on teardown.
"""
- [ ] Implementation details:
- Import
GameEnginefrommaid_engine.core.engine - Import
InMemoryDocumentStorefrommaid_engine.storage.document_store - Import
StdlibContentPackfrommaid_stdlib - Import
ClassicRPGContentPackfrommaid_classic_rpg - Import
get_settingsfrommaid_engine.config.settings - Allocate ephemeral port:
sock = socket.socket(); sock.bind(('', 0)); port = sock.getsockname()[1]; sock.close() - Create settings with overrides:
- Create
InMemoryDocumentStore()and pass toGameEngine(settings=settings, document_store=store) - Load content packs:
engine.load_content_pack(StdlibContentPack()),engine.load_content_pack(ClassicRPGContentPack()) - Start engine in background task:
engine_task = asyncio.create_task(engine.start()) - Wait for readiness via
_wait_for_ready("127.0.0.1", port, timeout=15.0)(TCP probe retry loop) - Yield
MUDServer(host="127.0.0.1", port=port, engine=engine) - On teardown:
await engine.stop(), cancel engine task, registeratexithandler as safety net
_wait_for_ready() helper¶
async def _wait_for_ready(host: str, port: int, timeout: float = 15.0) -> None:
"""Retry TCP connect until server accepts connections."""
- [ ] Exponential backoff: initial 0.1s, max 1.0s, total timeout 15s
- [ ] Raise
TimeoutErrorwith descriptive message on failure
MUDServer dataclass¶
@dataclass
class MUDServer:
host: str
port: int
engine: GameEngine
def is_alive(self) -> bool: ...
async def wait_ticks(self, n: int = 1) -> None: ...
async def snapshot_world(self) -> WorldSnapshot: ...
async def restore_world(self, snapshot: WorldSnapshot) -> None: ...
- [ ]
is_alive()— checkengine._stateis notSTOPPEDorERROR - [ ]
wait_ticks(n)— subscribe to tick event, wait for N occurrences viaasyncio.Event - [ ]
snapshot_world()/restore_world()— deep copy entity registry, room index, document store collections; restore by clearing and re-populating
WorldSnapshot dataclass¶
@dataclass
class WorldSnapshot:
entities: dict[UUID, Any] # Serialized entity data
room_index: dict[UUID, set[UUID]] # room_id → entity_ids
collections: dict[str, dict[UUID, Any]] # doc store state
_isolate_world (function-scoped autouse)¶
@pytest.fixture(autouse=True)
async def _isolate_world(mud_server: MUDServer) -> AsyncGenerator[None, None]:
"""Snapshot and restore world state around each test."""
snapshot = await mud_server.snapshot_world()
yield
await mud_server.restore_world(snapshot)
- [ ] Guarantees no state bleed between tests
- [ ] Accounts persist (session-scoped) — only world entities/items/rooms are restored
raw_client (function-scoped)¶
@pytest.fixture
async def raw_client(mud_server: MUDServer) -> AsyncGenerator[MUDClient, None]:
"""Connected MUDClient with no login — for testing login flow itself."""
- [ ] Skip if
not mud_server.is_alive()—pytest.skip("Server crashed") - [ ] Create
MUDClient(mud_server.host, mud_server.port, strip_ansi=True) - [ ]
await client.connect() - [ ] Yield client
- [ ] Teardown:
await client.disconnect()(wrapped intry/except)
registered_account (session-scoped)¶
@dataclass
class AccountInfo:
username: str
password: str
email: str
@pytest.fixture(scope="session")
async def registered_account(mud_server: MUDServer) -> AccountInfo:
"""Register a test account via the telnet login flow. Created once per session."""
- [ ] Generate unique credentials:
username=f"e2etest_{uuid4().hex[:8]}",password="TestPass123",email=f"{username}@test.local" - [ ] Connect a temporary
MUDClient, drive through registration flow:await client.expect("Please select an option") await client.send("R") await client.expect("Choose a username") await client.send(username) await client.expect("Email address") await client.send(email) await client.expect("Choose a password") await client.send(password) await client.expect("Confirm password") await client.send(password) await client.expect("Account created") - [ ] Disconnect temporary client
- [ ] Return
AccountInfo(username=username, password=password, email=email)
MaidAuthDriver class¶
class MaidAuthDriver:
"""AuthDriver implementation for MAID Classic RPG login flow.
Drives the LoginHandler and CharacterHandler state machines via
MUDClient send/expect calls. Follows the flow in
packages/maid-classic-rpg/src/maid_classic_rpg/auth/.
"""
async def register(
self, client: MUDClient, username: str, password: str, email: str
) -> None: ...
async def login(
self, client: MUDClient, username: str, password: str
) -> None: ...
async def select_character(
self, client: MUDClient, character_name: str
) -> None: ...
async def create_character(
self,
client: MUDClient,
name: str,
race_index: int = 1,
class_index: int = 1,
gender_index: int = 3,
) -> None: ...
async def login_and_enter_world(
self,
client: MUDClient,
username: str,
password: str,
character_name: str | None = None,
create_if_missing: bool = True,
) -> None: ...
- [ ]
register()— drive WELCOME → R → username → email → password → confirm → "Account created" - [ ]
login()— drive WELCOME → L → username → password → "Welcome back" - [ ]
select_character(name)— at Character Selection menu, select by number or send "1" if only one exists - [ ]
create_character(name, race_index, class_index, gender_index):await client.expect("Character Selection") await client.send("C") await client.expect("Enter character name") await client.send(name) await client.expect("Select your race") await client.send(str(race_index)) await client.expect("Select your class") await client.send(str(class_index)) await client.expect("Select your gender") await client.send(str(gender_index)) await client.expect("Character Summary") await client.send("Y") await client.expect("Welcome to the world") - [ ]
login_and_enter_world()— composite: login → create or select character → wait for"[Entering World...]"
mud_client (function-scoped)¶
@pytest.fixture
async def mud_client(
mud_server: MUDServer,
registered_account: AccountInfo,
) -> AsyncGenerator[MUDClient, None]:
"""Logged-in MUDClient ready to send game commands."""
- [ ] Skip if
not mud_server.is_alive() - [ ] Create
MUDClientand connect - [ ] Use
MaidAuthDriverto login and enter world: - [ ] Wait for initial prompt (the connection handler does an auto-
lookon enter) - [ ] Yield client
- [ ] Teardown: send
"quit", wait for"Goodbye"(best-effort), disconnect
unique_name (function-scoped)¶
@pytest.fixture
def unique_name() -> str:
"""Generate a unique character/account name per test."""
return f"Test{uuid4().hex[:8].capitalize()}"
logged_in_client context manager¶
@asynccontextmanager
async def logged_in_client(
mud_server: MUDServer,
name_suffix: str,
registered_account: AccountInfo | None = None,
) -> AsyncGenerator[MUDClient, None]:
"""Create a fresh registered+logged-in client for multi-client tests."""
- [ ] If no
registered_account, register a new unique account via the flow - [ ] Login and create a character named with the
name_suffixcapitalized - [ ] Yield client
- [ ] Teardown: quit + disconnect
4.2.2 Fixture Verification¶
- [ ] Write a minimal smoke test
tests/e2e/test_smoke.pythat uses each fixture:@pytest.mark.e2e async def test_raw_client_connects(raw_client: MUDClient) -> None: await raw_client.expect("Please select an option") @pytest.mark.e2e async def test_mud_client_in_world(mud_client: MUDClient) -> None: await mud_client.send("look") await mud_client.expect_any(["exits", "Exits", "you see", "You see"]) - [ ] Run
uv run pytest tests/e2e/test_smoke.py -v -m e2e— both pass - [ ] Delete
test_smoke.pyafter verification (covered by real tests below)
4.3 MAID E2E Test Suite¶
Priority: P0 | Dependencies: 4.2
4.3.1 Login Tests — tests/e2e/test_login.py¶
Tests the
LoginHandlerstate machine atmaid_classic_rpg/auth/login_handler.py. All tests useraw_client(bare connection, no pre-login).
"""E2E tests for the MAID login and registration flow.
Tests drive the LoginHandler state machine:
WELCOME → LOGIN_USERNAME → LOGIN_PASSWORD → LOGGED_IN
WELCOME → REGISTER_USERNAME → REGISTER_EMAIL → REGISTER_PASSWORD → REGISTER_CONFIRM → LOGGED_IN
Reference: packages/maid-classic-rpg/src/maid_classic_rpg/auth/login_handler.py
"""
Registration Flow¶
- [ ]
test_register_new_account(raw_client: MUDClient, unique_name: str) -> None: - Expect
"Please select an option"with substrings"[L]ogin","[R]egister","[Q]uit" - Send
"R" - Expect
"Choose a username"(confirms transition to REGISTER_USERNAME state) - Send
unique_name - Expect
"Email address"(confirms transition to REGISTER_EMAIL state) - Send
f"{unique_name}@test.local" - Expect
"Choose a password"(confirms transition to REGISTER_PASSWORD state) - Send
"TestPass123" - Expect
"Confirm password"(confirms transition to REGISTER_CONFIRM state) - Send
"TestPass123" - Expect
"Account created successfully" - Expect
f"Welcome, {unique_name}!" -
Assert
"You are now logged in"appears -
[ ]
test_register_short_username(raw_client: MUDClient) -> None: - Send
"R"→ expect"Choose a username"→ send"ab"(2 chars, min is 3) - Expect
"Username must be at least 3 characters" -
Expect
"Choose a username"(stays in REGISTER_USERNAME state) -
[ ]
test_register_username_starts_with_number(raw_client: MUDClient) -> None: - Send
"R"→ expect"Choose a username"→ send"1badname" -
Expect
"Username must start with a letter" -
[ ]
test_register_username_special_chars(raw_client: MUDClient) -> None: - Send
"R"→ expect"Choose a username"→ send"bad@name" -
Expect
"Username can only contain letters, numbers, and underscores" -
[ ]
test_register_duplicate_username(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Send
"R"→ sendregistered_account.username -
Expect
"That username is already taken" -
[ ]
test_register_invalid_email(raw_client: MUDClient, unique_name: str) -> None: - Send
"R"→ sendunique_name→ expect"Email address"→ send"not-an-email" -
Expect
"Please enter a valid email address" -
[ ]
test_register_short_password(raw_client: MUDClient, unique_name: str) -> None: - Send
"R"→ sendunique_name→ sendf"{unique_name}@test.local"→ expect"Choose a password"→ send"short" -
Expect
"Password must be at least 6 characters" -
[ ]
test_register_password_mismatch(raw_client: MUDClient, unique_name: str) -> None: - Complete registration up to confirm step → send different password
- Expect
"Passwords do not match" -
Expect
"Choose a password"(returns to REGISTER_PASSWORD state) -
[ ]
test_register_cancel_at_username(raw_client: MUDClient) -> None: - Send
"R"→ expect"Choose a username"→ send"cancel" - Expect
"Please select an option"(returns to WELCOME state)
Login Flow¶
- [ ]
test_login_existing_account(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Expect
"Please select an option" - Send
"L" - Expect
"Username"(confirms transition to LOGIN_USERNAME state) - Send
registered_account.username - Expect
"Password"(confirms transition to LOGIN_PASSWORD state) - Send
registered_account.password -
Expect
"Welcome back, {registered_account.username}!" -
[ ]
test_login_invalid_password(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Send
"L"→ sendregistered_account.username→ send"wrongpassword" - Expect
"Invalid username or password" -
Expect
"Attempts remaining: 2"(3 max, 1 used) -
[ ]
test_login_invalid_username(raw_client: MUDClient) -> None: - Send
"L"→ send"nonexistent_user_xyz"→ send"anypassword" -
Expect
"Invalid username or password"(no username disclosure) -
[ ]
test_login_max_attempts_disconnect(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Three failed login attempts with wrong password:
for attempt in range(3): await raw_client.send("L") await raw_client.expect("Username") await raw_client.send(registered_account.username) await raw_client.expect("Password") await raw_client.send("wrong_password") await raw_client.expect("Invalid username or password") if attempt < 2: await raw_client.expect(f"Attempts remaining: {2 - attempt}") - After 3rd attempt: expect
"Too many failed attempts. Disconnecting." -
Verify connection closes (next
expect()raisesConnectionClosed) -
[ ]
test_login_cancel_at_username(raw_client: MUDClient) -> None: - Send
"L"→ expect"Username"→ send"cancel" -
Expect
"Please select an option"(returns to WELCOME) -
[ ]
test_login_cancel_at_password(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Send
"L"→ sendregistered_account.username→ expect"Password"→ send"cancel" - Expect
"Please select an option"(returns to WELCOME)
Welcome Menu¶
- [ ]
test_quit_from_welcome(raw_client: MUDClient) -> None: - Expect
"Please select an option" - Send
"Q" -
Expect
"Goodbye"— connection should close -
[ ]
test_invalid_welcome_choice(raw_client: MUDClient) -> None: - Expect
"Please select an option" - Send
"X"(invalid) - Expect
"Invalid choice. Please enter L, R, or Q" - Expect
"Your choice"(re-prompts)
4.3.2 Character Tests — tests/e2e/test_character.py¶
Tests the
CharacterHandlerstate machine atmaid_classic_rpg/auth/character_handler.py. All tests useraw_clientand drive through login first viaMaidAuthDriver.login().
"""E2E tests for the MAID character creation and selection flow.
Tests drive the CharacterHandler state machine:
SELECT → CREATE_NAME → CREATE_RACE → CREATE_CLASS → CREATE_GENDER → CREATE_CONFIRM → SELECTED
SELECT → [number] → SELECTED (existing character)
Reference: packages/maid-classic-rpg/src/maid_classic_rpg/auth/character_handler.py
Races: Human, Elf, Dwarf, Halfling, Orc, Gnome, Half_elf, Half_orc, Troll, Goblin (1-10)
Classes: Warrior, Mage, Rogue, Cleric, Ranger, Paladin, Barbarian, Bard, Monk, Druid (1-10)
Genders: Male (1), Female (2), Neutral (3)
MAX_CHARACTERS_PER_ACCOUNT = 5
"""
Helper function (module-level):
async def _login(client: MUDClient, account: AccountInfo) -> None:
"""Drive through login to reach Character Selection."""
auth = MaidAuthDriver()
await auth.login(client, account.username, account.password)
Full Character Creation¶
- [ ]
test_create_character_full_flow(raw_client: MUDClient, registered_account: AccountInfo, unique_name: str) -> None: - Login via
_login(raw_client, registered_account) - Expect
"Character Selection"with"[C]reate a new character"and"[Q]uit" - Send
"C" - Expect
"Enter character name"(CREATE_NAME state) - Send
unique_name - Expect
"Select your race"with all 10 races listed (CREATE_RACE state) - Send
"1"(Human) - Expect
"Select your class"with all 10 classes listed (CREATE_CLASS state) - Send
"1"(Warrior) - Expect
"Select your gender"(CREATE_GENDER state) - Expect
"Male","Female","Neutral"in output - Send
"3"(Neutral) - Expect
"Character Summary"(CREATE_CONFIRM state) - Expect
unique_namein output (name displayed) - Expect
"human"(or"Human") in output - Expect
"warrior"(or"Warrior") in output - Expect
"neutral"(or"Neutral") in output - Expect
"Create this character?"with"[Y]es"and"[N]o" - Send
"Y" - Expect
f"Welcome to the world, {unique_name}!" -
Expect
"[Entering World...]" -
[ ]
test_create_character_elf_mage(raw_client: MUDClient, registered_account: AccountInfo, unique_name: str) -> None: - Login → C → name →
"2"(Elf) →"2"(Mage) →"2"(Female) → Y - Expect character summary contains
"elf"and"mage"and"female" - Expect
"Welcome to the world"
Race/Class Selection¶
- [ ]
test_create_character_all_races(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Login → C → expect
"Select your race" - Verify all 10 races appear in output:
"Human","Elf","Dwarf","Halfling","Orc","Gnome","Half_elf"(or"half_elf"),"Half_orc"(or"half_orc"),"Troll","Goblin" -
Send
"cancel"to return to selection -
[ ]
test_create_character_all_classes(raw_client: MUDClient, registered_account: AccountInfo, unique_name: str) -> None: - Login → C → name →
"1"(Human) → expect"Select your class" - Verify all 10 classes appear:
"Warrior","Mage","Rogue","Cleric","Ranger","Paladin","Barbarian","Bard","Monk","Druid" -
Send
"cancel"to return to selection -
[ ]
test_create_character_invalid_race(raw_client: MUDClient, registered_account: AccountInfo, unique_name: str) -> None: - Login → C → name → send
"99"(out of range) - Expect
"Invalid selection" -
Expect
"Select race"(re-prompts, stays in CREATE_RACE) -
[ ]
test_create_character_invalid_class(raw_client: MUDClient, registered_account: AccountInfo, unique_name: str) -> None: - Login → C → name →
"1"(race) → send"99"(out of range) - Expect
"Invalid selection" -
Expect
"Select class"(re-prompts) -
[ ]
test_create_character_invalid_gender(raw_client: MUDClient, registered_account: AccountInfo, unique_name: str) -> None: - Login → C → name →
"1"→"1"→ send"9"(out of range) - Expect
"Invalid selection" - Expect
"Select gender"(re-prompts)
Name Validation¶
- [ ]
test_create_character_short_name(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Login → C → send
"ab"(too short) -
Expect
"Name must be at least 3 characters" -
[ ]
test_create_character_long_name(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Login → C → send
"a" * 21(too long) -
Expect
"Name must be at most 20 characters" -
[ ]
test_create_character_name_starts_with_number(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Login → C → send
"1badname" -
Expect
"Name must start with a letter" -
[ ]
test_create_character_name_with_numbers(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Login → C → send
"test123" -
Expect
"Name can only contain letters" -
[ ]
test_create_character_duplicate_name(raw_client: MUDClient, registered_account: AccountInfo) -> None: - First: create a character with name
"Duptest"(full flow) - Then reconnect or re-enter creation: send
"Duptest"again - Expect
"That name is already taken"
Confirm / Cancel¶
- [ ]
test_create_character_decline_confirmation(raw_client: MUDClient, registered_account: AccountInfo, unique_name: str) -> None: - Login → C → name → race → class → gender → at confirmation, send
"N" - Expect
"Character Selection"(returns to SELECT state) -
Character should NOT be created
-
[ ]
test_create_character_cancel_at_name(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Login → C → expect
"Enter character name"→ send"cancel" -
Expect
"Character Selection"(returns to SELECT) -
[ ]
test_create_character_cancel_at_race(raw_client: MUDClient, registered_account: AccountInfo, unique_name: str) -> None: - Login → C → name → expect
"Select your race"→ send"cancel" - Expect
"Character Selection"(returns to SELECT)
Character Selection (Existing)¶
- [ ]
test_select_existing_character(raw_client: MUDClient, registered_account: AccountInfo) -> None: - First: create a character named
"Existing"viaMaidAuthDriver(full flow through enter-world, then quit) - Then: reconnect, login, expect
"Character Selection"with"Existing"listed - Send
"1"(select first character) - Expect
"Welcome back, Existing!" -
Expect
"[Entering World...]" -
[ ]
test_select_invalid_character_number(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Login → at Character Selection → send
"99"(out of range) -
Expect
"Invalid selection" -
[ ]
test_quit_from_character_selection(raw_client: MUDClient, registered_account: AccountInfo) -> None: - Login → expect
"Character Selection"→ send"Q" - Should return to login or disconnect (returns
FalsefromCharacterHandler.run())
Max Characters Limit¶
- [ ]
test_max_characters_limit(raw_client: MUDClient, mud_server: MUDServer) -> None: - Register a fresh account
- Create 5 characters (the max,
MAX_CHARACTERS_PER_ACCOUNT = 5) via repeated create flows - On 6th attempt: send
"C" - Expect
"You already have 5 characters (maximum)" - Note: this test may require a dedicated fresh account to avoid interference
4.3.3 Gameplay Tests — tests/e2e/test_gameplay.py¶
Tests use
mud_client(pre-logged-in, in the game world).
"""E2E tests for core MAID gameplay commands.
Tests verify the main command loop in pack.py:359-396:
receive input → parse command → execute → send output → prompt
Reference: packages/maid-classic-rpg/src/maid_classic_rpg/pack.py (connection handler)
Commands tested: look, move, inventory, get, drop, say
"""
Look Command¶
- [ ]
test_look_shows_room(mud_client: MUDClient) -> None: - Send
"look" await mud_client.expect_any(["exits", "Exits", "Exit"])— room must have at least one exit-
Verify output is non-empty (room has a description or name)
-
[ ]
test_look_at_self(mud_client: MUDClient) -> None: - Send
"look me"or"look self" - Expect any descriptive output (character name or description)
Move Command¶
- [ ]
test_move_to_adjacent_room(mud_client: MUDClient) -> None: - Send
"look"→ capture available exits from output - Parse an exit direction from the output (e.g.,
"north","south","east","west") - Send the direction
- Send
"look"— expect the new room is different (different description or name) -
Note: if no exits are available,
pytest.skip("Starting room has no exits") -
[ ]
test_move_invalid_direction(mud_client: MUDClient) -> None: - Send
"xyznodir"(nonsense direction) - Expect
"I don't understand that"or similar error — the command loop sends"I don't understand that."for unhandled commands
Inventory Command¶
- [ ]
test_inventory_command(mud_client: MUDClient) -> None: - Send
"inventory"(or"i") - Expect one of:
"You are carrying","Your inventory is empty","Inventory","carrying nothing"
Get/Drop Commands¶
- [ ]
test_get_nonexistent_item(mud_client: MUDClient) -> None: - Send
"get nonexistent_item_xyz" -
Expect error message (item not found)
-
[ ]
test_drop_nothing(mud_client: MUDClient) -> None: - Send
"drop nonexistent_item_xyz" - Expect error message (don't have that item)
Say Command¶
- [ ]
test_say_command(mud_client: MUDClient) -> None: - Send
"say Hello World" - Expect output confirming the say (e.g.,
"You say"or"say"or"Hello World")
Unknown Command¶
- [ ]
test_unknown_command(mud_client: MUDClient) -> None: - Send
"xyzunknowncommand" - Expect
"I don't understand that."(exact text from pack.py line 391)
Quit¶
- [ ]
test_quit_command(mud_client: MUDClient) -> None: - Send
"quit" -
Expect
"Goodbye!"(exact text from pack.py line 371) -
[ ]
test_exit_command(mud_client: MUDClient) -> None: - Send
"exit" - Expect
"Goodbye!"(pack.py line 370:line.lower() in ("quit", "exit"))
4.3.4 Multiplayer Tests — tests/e2e/test_multiplayer.py¶
Tests use
mud_serverdirectly and create multiple clients vialogged_in_client().
"""E2E tests for multi-client interactions.
Tests verify that multiple players connected simultaneously can:
- See each other in the same room
- See each other's communication (say command)
These tests exercise the full network stack with concurrent connections.
"""
- [ ]
test_two_players_see_each_other(mud_server: MUDServer, registered_account: AccountInfo) -> None: - Create two logged-in clients (Alice and Bob) in the same starting room:
-
Note: both clients start in the default starting room
-
[ ]
test_say_visible_to_other_player(mud_server: MUDServer, registered_account: AccountInfo) -> None: -
Create two logged-in clients:
-
[ ]
test_player_disconnect_not_visible(mud_server: MUDServer, registered_account: AccountInfo) -> None: - Create two clients, disconnect one, verify the other no longer sees them on
look - Note: may require a tick or brief wait for the session cleanup to propagate
4.4 YAML Smoke Test Scripts¶
Priority: P1 | Dependencies: TelTest Phase 2 (Script DSL runner)
4.4.1 tests/e2e/scripts/smoke_login.yaml¶
- [ ] Create
tests/e2e/scripts/smoke_login.yaml:name: "Smoke: Register → Create Character → Enter World → Quit" description: > Full new-player journey. Registers an account, creates a Human Warrior, enters the game world, and gracefully quits. tags: [smoke, login, character-creation] timeout: 10.0 setup: account: username: "smokelogin" password: "SmokePass123" register: true steps: - note: "Registration complete, now at Character Selection" - expect: "Character Selection" - send: "C" expect: "Enter character name" - send: "Smokechar" expect: "Select your race" - send: "1" expect: "Select your class" - send: "1" expect: "Select your gender" - send: "3" expect: "Character Summary" expect_all: - "Smokechar" - "human" - "warrior" - send: "Y" expect: "Welcome to the world" - expect: "Entering World" - send: "quit" expect: "Goodbye" teardown: - send: "quit"
4.4.2 tests/e2e/scripts/smoke_navigation.yaml¶
- [ ] Create
tests/e2e/scripts/smoke_navigation.yaml:name: "Smoke: Look → Move → Look" description: > Verify basic navigation works. Logs in, looks around, moves in any available direction, and looks again. tags: [smoke, navigation, gameplay] timeout: 10.0 setup: account: username: "smokenav" password: "SmokePass123" register: true character: name: "Navigator" race: "human" class: "warrior" steps: - send: "look" expect_any: - "exits" - "Exits" - "Exit" - send: "inventory" expect_any: - "carrying" - "inventory" - "Inventory" - "empty" - send: "quit" expect: "Goodbye" teardown: - send: "quit"
4.4.3 Script Runner Integration¶
- [ ] Ensure
tests/e2e/scripts/directory is configured as the script search directory - [ ] Verify scripts parse without errors:
uv run teltest validate tests/e2e/scripts/(once CLI exists) - [ ] Verify the pytest script runner collects YAML scripts as test items when
test_scripts.pyis present: - [ ] Create
tests/e2e/test_scripts.py:
4.5 CI Integration¶
Priority: P1 | Dependencies: 4.3, 4.4
4.5.1 GitHub Actions Workflow — .github/workflows/e2e.yml¶
-
[ ] Create
.github/workflows/e2e.yml:name: E2E Tests on: push: branches: [main, v3] pull_request: branches: [main, v3] concurrency: group: e2e-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: e2e: name: E2E Tests runs-on: ubuntu-latest timeout-minutes: 10 env: MAID_DEBUG: "true" TELTEST_TIMEOUT_MULTIPLIER: "2.0" # Disable AI providers for E2E (no API keys in CI) MAID_AI__DEFAULT_PROVIDER: "none" # Use in-memory storage MAID_PERSISTENCE__ENABLED: "false" steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v4 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: Set up Python run: uv python install 3.12 - name: Install dependencies run: uv sync --dev - name: Run E2E tests run: | uv run pytest tests/e2e/ \ -m e2e \ -x \ --timeout=120 \ -v \ --tb=long - name: Upload transcripts on failure if: failure() uses: actions/upload-artifact@v4 with: name: teltest-transcripts path: tests/e2e/recordings/ retention-days: 7 if-no-files-found: ignore -
[ ] Key design decisions documented:
timeout-minutes: 10— hard cap to prevent zombie processesTELTEST_TIMEOUT_MULTIPLIER: "2.0"— shared CI runners have variable CPU-xflag — fail fast, since server crash makes subsequent tests pointless-m e2e— only run E2E marked tests, not unit tests--timeout=120— per-test timeout safety net (requirespytest-timeout)MAID_PERSISTENCE__ENABLED: "false"— no background save scheduler in testsMAID_AI__DEFAULT_PROVIDER: "none"— no API keys needed- Separate from unit CI workflow (
.github/workflows/ci.yml) — E2E failures don't block unit test reporting concurrency.group— cancel stale E2E runs on new pushes
4.5.2 pytest Marker Configuration — pyproject.toml¶
- [ ] Add
e2emarker to[tool.pytest.ini_options]markers in rootpyproject.toml: - [ ] Add
tests/e2etotestpathsin rootpyproject.toml: - [ ] Ensure
addoptsexcludese2efrom default test runs to prevent accidental server startup: - [ ] Add
tests/e2eto coverage source omissions (E2E tests aren't measured for coverage):
4.5.3 Timeout Configuration¶
- [ ] TelTest pytest plugin reads
TELTEST_TIMEOUT_MULTIPLIERenv var and scales all default timeouts: - Default timeout:
5.0s× multiplier - Server startup timeout:
15.0s× multiplier - Connection timeout:
10.0s× multiplier - [ ] In CI: multiplier is
2.0, so effective timeouts are10s / 30s / 20s - [ ] Locally: multiplier defaults to
1.0 - [ ] Document in
tests/e2e/README.md(optional):
4.5.4 Dependency on pytest-timeout¶
- [ ] Add
pytest-timeout>=2.2.0to[dependency-groups] devin rootpyproject.toml(if not already present) - [ ] This provides
--timeout=Nfor per-test hard timeouts in CI
4.5.5 Updating ci-success Job¶
- [ ] Update
.github/workflows/ci.ymlci-successjob to NOT depend one2e— the E2E workflow is separate and optional for branch protection initially - [ ] Alternatively, add
e2eas a separate required status check in GitHub repo settings once stable
4.6 Verification & Acceptance Criteria¶
Priority: P0 | Dependencies: 4.1–4.5
4.6.1 Local Verification¶
- [ ]
uv sync --dev— all packages resolve including teltest - [ ]
uv run pytest packages/teltest/tests/ -v— TelTest own tests pass - [ ]
uv run pytest tests/e2e/ -m e2e -v— all E2E tests pass locally - [ ]
uv run pytest tests/e2e/ -m e2e -v --timeout=60— tests complete within timeout - [ ]
uv run pytest packages/ -m 'not benchmark and not e2e'— existing unit tests still pass (no regressions) - [ ]
uv run ruff check tests/e2e/— no lint errors - [ ]
uv run ruff check packages/teltest/— no lint errors
4.6.2 CI Verification¶
- [ ] Push branch and verify
.github/workflows/e2e.ymlruns - [ ] Verify E2E tests pass in CI with
TELTEST_TIMEOUT_MULTIPLIER=2.0 - [ ] Verify no port conflicts — ephemeral port allocation works
- [ ] Verify transcript upload on failure — trigger a deliberate failure and check artifacts
- [ ] Verify existing CI workflow (
.github/workflows/ci.yml) is unaffected
4.6.3 Success Criteria Mapping¶
| Design SC | Criterion | Test(s) |
|---|---|---|
| SC-1 | Full login→play→quit tested | test_register_new_account, smoke_login.yaml |
| SC-2 | Character creation tested | test_create_character_full_flow, test_create_character_elf_mage, test_create_character_all_races, test_create_character_all_classes |
| SC-3 | Core gameplay loop tested | test_look_shows_room, test_move_to_adjacent_room, test_inventory_command, test_say_command, smoke_navigation.yaml |
| SC-4 | Tests run in CI < 60s, no port conflicts | .github/workflows/e2e.yml with ephemeral ports and timeout-minutes: 10 |
| SC-5 | Non-programmers author tests | smoke_login.yaml, smoke_navigation.yaml — YAML DSL |
| SC-6 | Core MUDClient is portable | packages/teltest/ has zero MAID deps; MAID-specific code lives in tests/e2e/conftest.py |
File Summary¶
| File | Description | Section |
|---|---|---|
packages/teltest/pyproject.toml |
Package metadata, deps (pytest, pyyaml, pydantic) | 4.1.1 |
packages/teltest/README.md |
Standalone package documentation | 4.1.4 |
packages/teltest/src/teltest/__init__.py |
Public API exports | 4.1.3 |
pyproject.toml (root) |
UV workspace, markers, testpaths updates | 4.1.2, 4.5.2 |
tests/e2e/__init__.py |
Package marker | 4.2.1 |
tests/e2e/conftest.py |
All fixtures: mud_server, raw_client, mud_client, registered_account, MaidAuthDriver, _isolate_world, logged_in_client, unique_name | 4.2.1 |
tests/e2e/test_login.py |
14 tests: registration, login, welcome menu | 4.3.1 |
tests/e2e/test_character.py |
17 tests: creation, selection, validation, max limit | 4.3.2 |
tests/e2e/test_gameplay.py |
9 tests: look, move, inventory, get, drop, say, quit | 4.3.3 |
tests/e2e/test_multiplayer.py |
3 tests: visibility, say, disconnect | 4.3.4 |
tests/e2e/test_scripts.py |
YAML script runner collector | 4.4.3 |
tests/e2e/scripts/smoke_login.yaml |
Smoke: register → create → enter → quit | 4.4.1 |
tests/e2e/scripts/smoke_navigation.yaml |
Smoke: look → inventory → quit | 4.4.2 |
.github/workflows/e2e.yml |
CI workflow: separate E2E job | 4.5.1 |
Total test count: 43 Python tests + 2 YAML scripts = 45 test cases