Skip to content

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 full LoginHandler state machine
  • Character creation tests (tests/e2e/test_character.py) — the full CharacterHandler state 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, say visibility
  • 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) — registers classic_rpg_connection_handler on the server, which runs LoginHandler → CharacterHandler → enter world → main command loop.
  • GameEngine.__init__() accepts optional settings, document_store — defaults to InMemoryDocumentStore when 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 pytest11 entry point for teltest.pytest_plugin — this auto-activates the plugin when installed

4.1.2 UV Workspace Integration

  • [ ] Add "packages/teltest" to tool.uv.workspace.members in root pyproject.toml:
    [tool.uv.workspace]
    members = [
        "packages/maid-engine",
        "packages/maid-stdlib",
        "packages/maid-classic-rpg",
        "packages/maid-registry",
        "packages/maid-tutorial-world",
        "packages/teltest",
    ]
    
  • [ ] Add teltest = { workspace = true } to [tool.uv.sources] in root pyproject.toml
  • [ ] Add "teltest" to [dependency-groups] dev in root pyproject.toml
  • [ ] Add "teltest" to known-first-party in [tool.ruff.lint.isort] in root pyproject.toml
  • [ ] Run uv sync and 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__.py with 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.md with the following sections:
  • Header — "TelTest — E2E Testing Framework for MUDs"
  • Overview — Async telnet client + pytest fixtures for full-stack MUD testing
  • Installationpip install teltest (standalone) or workspace (uv sync)
  • Quick Start — minimal example with MUDClient context manager:
    import pytest
    from teltest import MUDClient
    
    @pytest.mark.e2e
    async def test_login(raw_client: MUDClient) -> None:
        await raw_client.expect("Welcome")
        await raw_client.send("connect user pass")
        await raw_client.expect("Entering World")
    
  • 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()
  • Fixturesmud_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/ -v passes (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.py with 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 GameEngine from maid_engine.core.engine
  • Import InMemoryDocumentStore from maid_engine.storage.document_store
  • Import StdlibContentPack from maid_stdlib
  • Import ClassicRPGContentPack from maid_classic_rpg
  • Import get_settings from maid_engine.config.settings
  • Allocate ephemeral port: sock = socket.socket(); sock.bind(('', 0)); port = sock.getsockname()[1]; sock.close()
  • Create settings with overrides:
    settings = get_settings()
    settings.telnet.port = port
    settings.telnet.host = "127.0.0.1"
    settings.game.tick_rate = 10.0  # Fast ticks for tests
    
  • Create InMemoryDocumentStore() and pass to GameEngine(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, register atexit handler 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 TimeoutError with 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() — check engine._state is not STOPPED or ERROR
  • [ ] wait_ticks(n) — subscribe to tick event, wait for N occurrences via asyncio.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 in try/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 MUDClient and connect
  • [ ] Use MaidAuthDriver to login and enter world:
    auth = MaidAuthDriver()
    char_name = f"Hero{uuid4().hex[:6].capitalize()}"
    await auth.login_and_enter_world(
        client, registered_account.username, registered_account.password,
        character_name=char_name, create_if_missing=True,
    )
    
  • [ ] Wait for initial prompt (the connection handler does an auto-look on 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_suffix capitalized
  • [ ] Yield client
  • [ ] Teardown: quit + disconnect

4.2.2 Fixture Verification

  • [ ] Write a minimal smoke test tests/e2e/test_smoke.py that 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.py after 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 LoginHandler state machine at maid_classic_rpg/auth/login_handler.py. All tests use raw_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" → send registered_account.username
  • Expect "That username is already taken"

  • [ ] test_register_invalid_email(raw_client: MUDClient, unique_name: str) -> None:

  • Send "R" → send unique_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" → send unique_name → send f"{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" → send registered_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() raises ConnectionClosed)

  • [ ] 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" → send registered_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 CharacterHandler state machine at maid_classic_rpg/auth/character_handler.py. All tests use raw_client and drive through login first via MaidAuthDriver.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_name in 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" via MaidAuthDriver (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 False from CharacterHandler.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_server directly and create multiple clients via logged_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:
    async with logged_in_client(mud_server, "Alice") as alice, \
               logged_in_client(mud_server, "Bob") as bob:
        # Alice should see Bob in the room
        await alice.send("look")
        await alice.expect("Bob")
    
        # Bob should see Alice in the room
        await bob.send("look")
        await bob.expect("Alice")
    
  • 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:

    async with logged_in_client(mud_server, "Talker") as talker, \
               logged_in_client(mud_server, "Listener") as listener:
        await talker.send("say Hello from Talker!")
        # Listener should see the message
        await listener.expect("Talker")
        await listener.expect("Hello from Talker")
    

  • [ ] 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.py is present:
  • [ ] Create tests/e2e/test_scripts.py:
    """Run YAML test scripts from tests/e2e/scripts/.
    
    The teltest pytest plugin auto-discovers .yaml files and generates
    parametrized test cases from them.
    """
    # Auto-collected by teltest pytest plugin
    

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 processes
  • TELTEST_TIMEOUT_MULTIPLIER: "2.0" — shared CI runners have variable CPU
  • -x flag — 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 (requires pytest-timeout)
  • MAID_PERSISTENCE__ENABLED: "false" — no background save scheduler in tests
  • MAID_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 e2e marker to [tool.pytest.ini_options] markers in root pyproject.toml:
    markers = [
        "benchmark: Performance benchmark tests (run with: pytest -m benchmark)",
        "e2e: End-to-end tests requiring a running MAID server (run with: pytest -m e2e)",
    ]
    
  • [ ] Add tests/e2e to testpaths in root pyproject.toml:
    testpaths = [
        "packages/maid-engine/tests",
        "packages/maid-stdlib/tests",
        "packages/maid-classic-rpg/tests",
        "packages/maid-registry/tests",
        "packages/maid-tutorial-world/tests",
        "tests/e2e",
    ]
    
  • [ ] Ensure addopts excludes e2e from default test runs to prevent accidental server startup:
    addopts = "--import-mode=importlib -m 'not benchmark and not e2e'"
    
  • [ ] Add tests/e2e to coverage source omissions (E2E tests aren't measured for coverage):
    [tool.coverage.run]
    omit = [
        "*/tests/*",
        "*/__pycache__/*",
        "tests/e2e/*",
    ]
    

4.5.3 Timeout Configuration

  • [ ] TelTest pytest plugin reads TELTEST_TIMEOUT_MULTIPLIER env 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 are 10s / 30s / 20s
  • [ ] Locally: multiplier defaults to 1.0
  • [ ] Document in tests/e2e/README.md (optional):
    # Run locally (default timeouts)
    uv run pytest tests/e2e/ -m e2e -v
    
    # Run with CI-like timeouts
    TELTEST_TIMEOUT_MULTIPLIER=2.0 uv run pytest tests/e2e/ -m e2e -v
    
    # Run with extra-generous timeouts (debugging)
    TELTEST_TIMEOUT_MULTIPLIER=5.0 uv run pytest tests/e2e/ -m e2e -v -s
    

4.5.4 Dependency on pytest-timeout

  • [ ] Add pytest-timeout>=2.2.0 to [dependency-groups] dev in root pyproject.toml (if not already present)
  • [ ] This provides --timeout=N for per-test hard timeouts in CI

4.5.5 Updating ci-success Job

  • [ ] Update .github/workflows/ci.yml ci-success job to NOT depend on e2e — the E2E workflow is separate and optional for branch protection initially
  • [ ] Alternatively, add e2e as 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.yml runs
  • [ ] 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