or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

content.mdcore-framework.mdevents.mdindex.mdstyling.mdtesting.mdwidgets.md

testing.mddocs/

0

# Testing and Development

1

2

Programmatic testing framework with the Pilot class for automated UI testing, command palette system for built-in development tools, logging utilities for debugging applications, and background task management.

3

4

## Capabilities

5

6

### Programmatic Testing with Pilot

7

8

The Pilot class enables automated testing of Textual applications by simulating user interactions and verifying application state.

9

10

```python { .api }

11

class Pilot:

12

"""Programmatic controller for testing Textual applications."""

13

14

def __init__(self, app: App):

15

"""

16

Initialize pilot for an application.

17

18

Parameters:

19

- app: Textual application to control

20

"""

21

22

async def press(self, *keys: str) -> None:

23

"""

24

Simulate key presses.

25

26

Parameters:

27

- *keys: Key names to press (e.g., "enter", "ctrl+c", "a")

28

"""

29

30

async def click(

31

self,

32

selector: str | None = None,

33

*,

34

offset: tuple[int, int] = (0, 0)

35

) -> None:

36

"""

37

Simulate mouse click.

38

39

Parameters:

40

- selector: CSS selector for target widget (None for current focus)

41

- offset: X,Y offset from widget origin

42

"""

43

44

async def hover(

45

self,

46

selector: str | None = None,

47

*,

48

offset: tuple[int, int] = (0, 0)

49

) -> None:

50

"""

51

Simulate mouse hover.

52

53

Parameters:

54

- selector: CSS selector for target widget

55

- offset: X,Y offset from widget origin

56

"""

57

58

async def scroll_down(self, selector: str | None = None) -> None:

59

"""

60

Simulate scroll down.

61

62

Parameters:

63

- selector: CSS selector for target widget

64

"""

65

66

async def scroll_up(self, selector: str | None = None) -> None:

67

"""

68

Simulate scroll up.

69

70

Parameters:

71

- selector: CSS selector for target widget

72

"""

73

74

async def wait_for_screen(self, screen: type[Screen] | str | None = None) -> None:

75

"""

76

Wait for a specific screen to be active.

77

78

Parameters:

79

- screen: Screen class, name, or None for any screen change

80

"""

81

82

async def wait_for_animation(self) -> None:

83

"""Wait for all animations to complete."""

84

85

async def pause(self, delay: float = 0.1) -> None:

86

"""

87

Pause execution.

88

89

Parameters:

90

- delay: Pause duration in seconds

91

"""

92

93

async def exit(self, result: Any = None) -> None:

94

"""

95

Exit the application.

96

97

Parameters:

98

- result: Exit result value

99

"""

100

101

# Properties

102

app: App # The controlled application

103

104

class OutOfBounds(Exception):

105

"""Raised when pilot operation is out of bounds."""

106

107

class WaitForScreenTimeout(Exception):

108

"""Raised when waiting for screen times out."""

109

```

110

111

### Command Palette System

112

113

Built-in command palette for development tools and application commands.

114

115

```python { .api }

116

class CommandPalette(Widget):

117

"""Built-in command palette widget."""

118

119

class Opened(Message):

120

"""Sent when command palette opens."""

121

122

class Closed(Message):

123

"""Sent when command palette closes."""

124

125

class OptionSelected(Message):

126

"""Sent when command is selected."""

127

def __init__(self, option: Hit): ...

128

129

def __init__(self, **kwargs):

130

"""Initialize command palette."""

131

132

def search(self, query: str) -> None:

133

"""

134

Search for commands.

135

136

Parameters:

137

- query: Search query string

138

"""

139

140

class Provider:

141

"""Base class for command providers."""

142

143

async def search(self, query: str) -> Iterable[Hit]:

144

"""

145

Search for commands matching query.

146

147

Parameters:

148

- query: Search query string

149

150

Returns:

151

Iterable of matching command hits

152

"""

153

154

async def discover(self) -> Iterable[Hit]:

155

"""

156

Discover all available commands.

157

158

Returns:

159

Iterable of all command hits

160

"""

161

162

class Hit:

163

"""Command search result."""

164

165

def __init__(

166

self,

167

match_score: int,

168

renderable: RenderableType,

169

*,

170

command: Callable | None = None,

171

help_text: str | None = None,

172

id: str | None = None

173

):

174

"""

175

Initialize command hit.

176

177

Parameters:

178

- match_score: Relevance score for search

179

- renderable: Display representation

180

- command: Callable to execute

181

- help_text: Help description

182

- id: Unique identifier

183

"""

184

185

# Properties

186

match_score: int

187

renderable: RenderableType

188

command: Callable | None

189

help_text: str | None

190

id: str | None

191

192

CommandList = list[Hit]

193

"""Type alias for list of command hits."""

194

```

195

196

### Logging System

197

198

Comprehensive logging utilities for debugging and development.

199

200

```python { .api }

201

def log(*args, **kwargs) -> None:

202

"""

203

Global logging function.

204

205

Parameters:

206

- *args: Values to log

207

- **kwargs: Keyword arguments to log

208

209

Note:

210

Logs to the currently active app or debug output if no app.

211

"""

212

213

class Logger:

214

"""Logger class that logs to the Textual console."""

215

216

def __init__(

217

self,

218

log_callable: LogCallable | None,

219

group: LogGroup = LogGroup.INFO,

220

verbosity: LogVerbosity = LogVerbosity.NORMAL,

221

app: App | None = None,

222

):

223

"""

224

Initialize logger.

225

226

Parameters:

227

- log_callable: Function to handle log calls

228

- group: Log group classification

229

- verbosity: Logging verbosity level

230

- app: Associated application

231

"""

232

233

def __call__(self, *args: object, **kwargs) -> None:

234

"""Log values and keyword arguments."""

235

236

def verbosity(self, verbose: bool) -> Logger:

237

"""

238

Get logger with different verbosity.

239

240

Parameters:

241

- verbose: True for high verbosity, False for normal

242

243

Returns:

244

New logger instance

245

"""

246

247

# Property loggers for different categories

248

@property

249

def verbose(self) -> Logger:

250

"""High verbosity logger."""

251

252

@property

253

def event(self) -> Logger:

254

"""Event logging."""

255

256

@property

257

def debug(self) -> Logger:

258

"""Debug message logging."""

259

260

@property

261

def info(self) -> Logger:

262

"""Information logging."""

263

264

@property

265

def warning(self) -> Logger:

266

"""Warning message logging."""

267

268

@property

269

def error(self) -> Logger:

270

"""Error message logging."""

271

272

@property

273

def system(self) -> Logger:

274

"""System information logging."""

275

276

@property

277

def logging(self) -> Logger:

278

"""Standard library logging integration."""

279

280

@property

281

def worker(self) -> Logger:

282

"""Worker/background task logging."""

283

284

class LogGroup(Enum):

285

"""Log message categories."""

286

INFO = "INFO"

287

DEBUG = "DEBUG"

288

WARNING = "WARNING"

289

ERROR = "ERROR"

290

EVENT = "EVENT"

291

SYSTEM = "SYSTEM"

292

LOGGING = "LOGGING"

293

WORKER = "WORKER"

294

295

class LogVerbosity(Enum):

296

"""Log verbosity levels."""

297

NORMAL = "NORMAL"

298

HIGH = "HIGH"

299

300

class LoggerError(Exception):

301

"""Raised when logger operation fails."""

302

```

303

304

### Background Task Management

305

306

System for managing background tasks and workers in Textual applications.

307

308

```python { .api }

309

class Worker:

310

"""Background worker for running tasks."""

311

312

def __init__(

313

self,

314

target: Callable,

315

*,

316

name: str | None = None,

317

group: str = "default",

318

description: str = "",

319

exit_on_error: bool = True,

320

start: bool = True

321

):

322

"""

323

Initialize worker.

324

325

Parameters:

326

- target: Function/coroutine to run

327

- name: Worker name

328

- group: Worker group name

329

- description: Worker description

330

- exit_on_error: Whether to exit app on worker error

331

- start: Whether to start immediately

332

"""

333

334

def start(self) -> None:

335

"""Start the worker."""

336

337

def cancel(self) -> None:

338

"""Cancel the worker."""

339

340

async def wait(self) -> Any:

341

"""Wait for worker completion and return result."""

342

343

# Properties

344

is_cancelled: bool # Whether worker is cancelled

345

is_finished: bool # Whether worker completed

346

state: WorkerState # Current worker state

347

result: Any # Worker result (if completed)

348

error: Exception | None # Worker error (if failed)

349

350

class WorkerState(Enum):

351

"""Worker execution states."""

352

PENDING = "PENDING"

353

RUNNING = "RUNNING"

354

CANCELLED = "CANCELLED"

355

ERROR = "ERROR"

356

SUCCESS = "SUCCESS"

357

358

class WorkerError(Exception):

359

"""Base worker error."""

360

361

class WorkerFailed(WorkerError):

362

"""Raised when worker fails."""

363

364

def work(exclusive: bool = False, thread: bool = False):

365

"""

366

Decorator for creating background workers.

367

368

Parameters:

369

- exclusive: Cancel other workers in same group when starting

370

- thread: Run in separate thread instead of async task

371

372

Usage:

373

@work

374

async def background_task(self):

375

# Background work here

376

pass

377

"""

378

```

379

380

### Timer System

381

382

Scheduling and timing utilities for delayed execution and periodic tasks.

383

384

```python { .api }

385

class Timer:

386

"""Scheduled callback timer."""

387

388

def __init__(

389

self,

390

name: str,

391

interval: float,

392

callback: TimerCallback,

393

*,

394

repeat: int | None = None,

395

pause: bool = False

396

):

397

"""

398

Initialize timer.

399

400

Parameters:

401

- name: Timer identifier

402

- interval: Callback interval in seconds

403

- callback: Function to call

404

- repeat: Number of times to repeat (None for infinite)

405

- pause: Start timer paused

406

"""

407

408

def start(self) -> None:

409

"""Start the timer."""

410

411

def stop(self) -> None:

412

"""Stop the timer."""

413

414

def pause(self) -> None:

415

"""Pause the timer."""

416

417

def resume(self) -> None:

418

"""Resume paused timer."""

419

420

def reset(self) -> None:

421

"""Reset timer to initial state."""

422

423

# Properties

424

time: float # Current time

425

next_fire: float # Time of next callback

426

is_active: bool # Whether timer is running

427

428

TimerCallback = Callable[[Timer], None]

429

"""Type alias for timer callback functions."""

430

```

431

432

### Development Utilities

433

434

```python { .api }

435

def get_app() -> App:

436

"""

437

Get the currently active Textual application.

438

439

Returns:

440

Active App instance

441

442

Raises:

443

RuntimeError: If no app is active

444

"""

445

446

def set_title(title: str) -> None:

447

"""

448

Set terminal window title.

449

450

Parameters:

451

- title: Window title text

452

"""

453

454

class DevConsole:

455

"""Development console for runtime debugging."""

456

457

def __init__(self, app: App):

458

"""

459

Initialize dev console.

460

461

Parameters:

462

- app: Application to debug

463

"""

464

465

def run_code(self, code: str) -> Any:

466

"""

467

Execute Python code in app context.

468

469

Parameters:

470

- code: Python code to execute

471

472

Returns:

473

Execution result

474

"""

475

476

def inspect_widget(self, selector: str) -> dict:

477

"""

478

Inspect widget properties.

479

480

Parameters:

481

- selector: CSS selector for widget

482

483

Returns:

484

Widget property dictionary

485

"""

486

```

487

488

## Usage Examples

489

490

### Basic Application Testing

491

492

```python

493

import pytest

494

from textual.app import App

495

from textual.widgets import Button, Input

496

497

class TestApp(App):

498

def compose(self):

499

yield Input(placeholder="Enter text", id="input")

500

yield Button("Submit", id="submit")

501

yield Button("Clear", id="clear")

502

503

def on_button_pressed(self, event: Button.Pressed):

504

if event.button.id == "submit":

505

input_widget = self.query_one("#input", Input)

506

self.log(f"Submitted: {input_widget.value}")

507

elif event.button.id == "clear":

508

input_widget = self.query_one("#input", Input)

509

input_widget.value = ""

510

511

@pytest.mark.asyncio

512

async def test_app_interaction():

513

"""Test basic app interaction with Pilot."""

514

app = TestApp()

515

516

async with app.run_test() as pilot:

517

# Type in input field

518

await pilot.click("#input")

519

await pilot.press("h", "e", "l", "l", "o")

520

521

# Verify input value

522

input_widget = app.query_one("#input", Input)

523

assert input_widget.value == "hello"

524

525

# Click submit button

526

await pilot.click("#submit")

527

528

# Click clear button and verify input is cleared

529

await pilot.click("#clear")

530

assert input_widget.value == ""

531

532

@pytest.mark.asyncio

533

async def test_keyboard_shortcuts():

534

"""Test keyboard shortcuts."""

535

app = TestApp()

536

537

async with app.run_test() as pilot:

538

# Enter text and use keyboard shortcut

539

await pilot.press("tab") # Focus input

540

await pilot.press("w", "o", "r", "l", "d")

541

await pilot.press("enter") # Should trigger submit

542

543

input_widget = app.query_one("#input", Input)

544

assert input_widget.value == "world"

545

```

546

547

### Screen Navigation Testing

548

549

```python

550

from textual.app import App

551

from textual.screen import Screen

552

from textual.widgets import Button, Static

553

554

class SecondScreen(Screen):

555

def compose(self):

556

yield Static("Second Screen")

557

yield Button("Go Back", id="back")

558

559

def on_button_pressed(self, event: Button.Pressed):

560

if event.button.id == "back":

561

self.dismiss("result from second screen")

562

563

class NavigationApp(App):

564

def compose(self):

565

yield Static("Main Screen")

566

yield Button("Go to Second", id="goto")

567

568

def on_button_pressed(self, event: Button.Pressed):

569

if event.button.id == "goto":

570

self.push_screen(SecondScreen())

571

572

@pytest.mark.asyncio

573

async def test_screen_navigation():

574

"""Test screen stack navigation."""

575

app = NavigationApp()

576

577

async with app.run_test() as pilot:

578

# Start on main screen

579

assert len(app.screen_stack) == 1

580

581

# Navigate to second screen

582

await pilot.click("#goto")

583

await pilot.wait_for_screen(SecondScreen)

584

585

assert len(app.screen_stack) == 2

586

assert isinstance(app.screen, SecondScreen)

587

588

# Go back to main screen

589

await pilot.click("#back")

590

591

# Should be back on main screen

592

assert len(app.screen_stack) == 1

593

```

594

595

### Custom Command Provider

596

597

```python

598

from textual.app import App

599

from textual.command import Provider, Hit

600

from textual.widgets import Static

601

602

class CustomCommands(Provider):

603

"""Custom command provider."""

604

605

async def search(self, query: str) -> list[Hit]:

606

"""Search for custom commands."""

607

commands = [

608

("hello", "Say hello", self.say_hello),

609

("goodbye", "Say goodbye", self.say_goodbye),

610

("time", "Show current time", self.show_time),

611

]

612

613

# Filter commands by query

614

matches = []

615

for name, description, callback in commands:

616

if query.lower() in name.lower():

617

score = 100 - len(name) # Shorter names score higher

618

hit = Hit(

619

score,

620

f"[bold]{name}[/bold] - {description}",

621

command=callback,

622

help_text=description

623

)

624

matches.append(hit)

625

626

return matches

627

628

async def say_hello(self) -> None:

629

"""Say hello command."""

630

app = self.app

631

status = app.query_one("#status", Static)

632

status.update("Hello from command palette!")

633

634

async def say_goodbye(self) -> None:

635

"""Say goodbye command."""

636

app = self.app

637

status = app.query_one("#status", Static)

638

status.update("Goodbye from command palette!")

639

640

async def show_time(self) -> None:

641

"""Show time command."""

642

import datetime

643

app = self.app

644

status = app.query_one("#status", Static)

645

current_time = datetime.datetime.now().strftime("%H:%M:%S")

646

status.update(f"Current time: {current_time}")

647

648

class CommandApp(App):

649

COMMANDS = {CustomCommands} # Register command provider

650

651

def compose(self):

652

yield Static("Press Ctrl+P to open command palette", id="status")

653

654

def on_mount(self):

655

# Command palette is automatically available with Ctrl+P

656

pass

657

```

658

659

### Background Worker Usage

660

661

```python

662

from textual.app import App

663

from textual.widgets import Button, ProgressBar, Static

664

from textual import work

665

import asyncio

666

667

class WorkerApp(App):

668

def compose(self):

669

yield Static("Click button to start background task")

670

yield Button("Start Task", id="start")

671

yield Button("Cancel Task", id="cancel")

672

yield ProgressBar(id="progress")

673

674

@work(exclusive=True) # Cancel previous workers

675

async def long_running_task(self):

676

"""Background task that updates progress."""

677

progress_bar = self.query_one("#progress", ProgressBar)

678

679

for i in range(100):

680

# Check if cancelled

681

if self.workers.get("long_running_task").is_cancelled:

682

break

683

684

# Update progress

685

progress_bar.progress = i + 1

686

687

# Simulate work

688

await asyncio.sleep(0.1)

689

690

# Task completed

691

self.log("Background task completed!")

692

693

def on_button_pressed(self, event: Button.Pressed):

694

if event.button.id == "start":

695

# Start background worker

696

self.long_running_task()

697

698

elif event.button.id == "cancel":

699

# Cancel running workers

700

worker = self.workers.get("long_running_task")

701

if worker and not worker.is_finished:

702

worker.cancel()

703

self.log("Task cancelled")

704

```

705

706

### Advanced Logging

707

708

```python

709

from textual.app import App

710

from textual.widgets import Button

711

from textual import log

712

713

class LoggingApp(App):

714

def compose(self):

715

yield Button("Info", id="info")

716

yield Button("Warning", id="warning")

717

yield Button("Error", id="error")

718

yield Button("Debug", id="debug")

719

720

def on_button_pressed(self, event: Button.Pressed):

721

button_id = event.button.id

722

723

if button_id == "info":

724

log.info("This is an info message")

725

726

elif button_id == "warning":

727

log.warning("This is a warning message")

728

729

elif button_id == "error":

730

log.error("This is an error message")

731

732

elif button_id == "debug":

733

log.debug("This is a debug message")

734

735

# Log with structured data

736

log("Button pressed", button=button_id, timestamp=time.time())

737

738

# Verbose logging

739

log.verbose("Detailed information about button press",

740

widget=event.button,

741

coordinates=(event.button.region.x, event.button.region.y))

742

```

743

744

### Timer-based Updates

745

746

```python

747

from textual.app import App

748

from textual.widgets import Static

749

from textual.timer import Timer

750

import datetime

751

752

class ClockApp(App):

753

def compose(self):

754

yield Static("", id="clock")

755

756

def on_mount(self):

757

# Update clock every second

758

self.set_interval(1.0, self.update_clock)

759

760

# Initial update

761

self.update_clock()

762

763

def update_clock(self):

764

"""Update the clock display."""

765

current_time = datetime.datetime.now().strftime("%H:%M:%S")

766

clock = self.query_one("#clock", Static)

767

clock.update(f"Current time: {current_time}")

768

```