Version 0.1: Protocol Foundation - Implementation Plan
November 22, 2025 · View on GitHub
Status: ✅ COMPLETE (2025-11-06)
Feature: JSON-RPC Protocol Client and Playwright Server Management
User Story: As a Rust developer, I want to launch the Playwright server and establish a JSON-RPC connection so that I can begin automating browsers.
Related ADR: ADR-0002: Initialization Flow
Approach: Vertical Slicing with Test-Driven Development (TDD)
Version 0.1 Completion Summary
Version 0.1 successfully delivered the complete protocol foundation for playwright-rust:
✅ All 5 slices completed:
- Slice 1: Server management (download, launch, lifecycle)
- Slice 2: Transport layer (stdio pipes, length-prefixed messages)
- Slice 3: Connection layer (JSON-RPC, request/response correlation)
- Slice 4: Object factory and channel owners
- Slice 5: Entry point (
Playwright::launch()and initialization flow)
✅ Key achievements:
- Successfully launches Playwright server and establishes stdio connection
- Implements complete JSON-RPC protocol with proper message framing
- Creates Playwright, BrowserType objects from server initialization
- Full test coverage with 54 passing tests
- Clean code: no clippy warnings, no unsafe code, full documentation
- Working example code demonstrating Version 0.1 functionality
✅ Next steps: Version 0.2 - Browser API (Browser, Context, Page lifecycle)
Implementation Strategy
This implementation follows vertical slicing - each slice delivers end-to-end testable functionality that brings us closer to launching a browser.
Architecture Reference: Based on research of playwright-python, playwright-java, and playwright-dotnet, all Microsoft Playwright bindings follow the same architecture:
- Transport Layer - Length-prefixed JSON messages over stdio pipes
- Connection Layer - JSON-RPC client with request/response correlation
- Driver Management - Download and launch Playwright Node.js server
- Object Factory - Instantiate typed objects from protocol messages
Key Design Principles:
- Match Microsoft's proven architecture exactly
- Use
tokiofor async runtime (Rust standard) - Follow protocol message format from
protocol.yml - Length-prefixed message framing (4 bytes little-endian + JSON)
- GUID-based object references
- Event-driven architecture for protocol events
Version 0.1 Scope:
This version establishes the protocol foundation (server management, transport, connection, object factory, and entry point). Version 0.1 ends when you can successfully launch the Playwright server and access BrowserType objects for Chromium, Firefox, and WebKit.
Note: Actual browser launching and cross-browser testing will be implemented in Version 0.2. However, the protocol foundation built in Version 0.1 is designed to support all three browsers from the start.
Vertical Slices
Slice 1: Walking Skeleton - Server Launch and Shutdown
Status: ✅ Complete (2025-11-05)
User Value: Can download Playwright server, launch it as a child process, and shut it down cleanly.
Acceptance Criteria:
- Playwright driver is downloaded during build via
build.rsfrom Azure CDN - Driver binaries are stored in
drivers/directory (gitignored) - Platform detection works correctly (macOS x86_64/ARM64, Linux x86_64/ARM64, Windows x86_64)
- Server process launches successfully via
node cli.js run-driver - Process environment includes
PW_LANG_NAME=rust,PW_LANG_NAME_VERSION, andPW_CLI_DISPLAY_VERSION - Server can be shut down gracefully without orphaning processes
- Errors are handled with helpful messages (server not found, launch failure, etc.)
- Fallback to
PLAYWRIGHT_DRIVER_PATHenvironment variable if set - Fallback to npm-installed Playwright for development use
Core Library Implementation (playwright-core):
- Create workspace structure:
crates/playwright-core/ - Add
Cargo.tomlwith dependencies:tokio = { version = "1", features = ["full"] }serde = { version = "1", features = ["derive"] }serde_json = "1"thiserror = "1"
- Define
src/error.rswithErrorenum:ServerNotFoundLaunchFailedConnectionFailedTransportErrorProtocolError
- Create
src/driver.rsmodule:get_driver_executable() -> Result<(PathBuf, PathBuf)>- Returns (node_path, cli_js_path)- Try in order:
- Bundled driver in
drivers/(from build.rs) PLAYWRIGHT_DRIVER_PATHenvironment variable- npm global installation (development fallback)
- npm local installation (development fallback)
- Bundled driver in
find_node_executable() -> Result<PathBuf>- Locate Node.js binary- Platform detection using
std::env::consts::{OS, ARCH}
- Create
src/server.rsmodule:struct PlaywrightServer- Wraps child processPlaywrightServer::launch() -> Result<Self>- Launch server process- Command:
node <driver_path>/package/cli.js run-driver - Set environment variables:
PW_LANG_NAME=rustPW_LANG_NAME_VERSION(fromCARGO_PKG_RUST_VERSION)PW_CLI_DISPLAY_VERSION(fromCARGO_PKG_VERSION)
- Stdio: stdin=piped, stdout=piped, stderr=inherit
- Command:
PlaywrightServer::shutdown(self) -> Result<()>- Graceful shutdownPlaywrightServer::kill(self) -> Result<()>- Force kill (timeout fallback)
- Export public API in
src/lib.rs
Core Library Unit Tests:
- Test
get_driver_executable()returns valid path - Test bundled driver detection
- Test
find_node_executable()locates Node.js - Test
PlaywrightServer::launch()spawns child process - Test
PlaywrightServer::shutdown()terminates process - Test
PlaywrightServer::kill()force kills process - Test error handling for driver not found
Build System:
- Create
build.rsscript inplaywright-core/:- Check if
drivers/directory exists in workspace root - If not, download Playwright driver from Azure CDN
- URL format:
https://playwright.azureedge.net/builds/driver/playwright-{version}-{platform}.zip - Platform mapping:
- macOS x86_64 →
mac - macOS ARM64 →
mac-arm64 - Linux x86_64 →
linux - Linux ARM64 →
linux-arm64 - Windows x86_64 →
win32_x64
- macOS x86_64 →
- Extract to
drivers/playwright-{version}-{platform}/ - Contains:
nodebinary andpackage/directory withcli.js - Set
PLAYWRIGHT_DRIVER_VERSIONenv var for runtime
- Check if
- Add build dependencies to
Cargo.toml:reqwest = { version = "0.12", features = ["blocking"] }zip = "2.1"
- Add
drivers/to.gitignore - Document build process in ADR and implementation plan
Documentation:
- Rustdoc for all public types and functions
- Example in doc comment showing server launch/shutdown
- Link to Playwright docs for driver management
- Document download strategy (build-time bundling matches official bindings)
Notes:
- Decision: Build-time download via
build.rs(matches Python/Java/.NET approach)- ✅ Matches official bindings - All three bundle drivers in packages
- ✅ Faster first run - No download delay when user runs code
- ✅ Offline-friendly - Works without network after initial build
- ✅ Simpler user experience - Just
cargo add playwright - ⚠️ Requires network during build - Acceptable, common in Rust (like
cccrate) - ⚠️ ~50MB download - Acceptable, same as other bindings
- Playwright version: Pin to specific version in
build.rs(e.g.,1.56.0)- Update version manually when updating crate
- Document version compatibility in README
- Platform support: Start with macOS (x86_64, ARM64) and Linux (x86_64, ARM64)
- Windows support in future release
- Cross-compilation considerations for CI/CD
- Reference implementations:
- Python:
setup.py(PlaywrightBDistWheelCommand) - Java:
driver-bundlemodule - .NET:
.csprojContent directives
- Python:
Slice 2: Stdio Transport - Send and Receive Messages
Status: ✅ Complete (2025-11-05)
User Value: Can send JSON-RPC messages to Playwright server and receive responses over stdio pipes.
Research Completed: Analyzed transport implementations in playwright-python, playwright-java, and playwright-dotnet (2025-11-05)
Acceptance Criteria:
- Messages are framed with 4-byte little-endian length prefix
- JSON messages are serialized and sent to server stdin
- Messages are read from server stdout with length prefix
- Reader loop runs in background task without blocking (via async task)
- Transport can be gracefully shut down (via drop or channel close)
- Transport errors are propagated correctly
Core Library Implementation (playwright-core):
- Create
src/transport.rsmodule:-
trait Transport- Abstract transport interfaceasync fn send(&mut self, message: JsonValue) -> Result<()>
-
struct PipeTransport- stdio pipe implementationstdin: ChildStdin- stdin pipestdout: ChildStdout- stdout pipemessage_tx: mpsc::UnboundedSender<JsonValue>- Message channel
-
PipeTransport::new(stdin, stdout) -> (Self, Receiver)- Constructor -
PipeTransport::send(message: JsonValue) -> Result<()>- Send implementation -
PipeTransport::run()- Async read loop (matches Python'srun()) - Graceful shutdown - Via dropping receiver channel (no explicit method needed)
-
- Implement length-prefixed framing:
- Write:
u32::to_le_bytes(len) + json_bytes - Read:
read_exact(4 bytes) -> u32::from_le_bytes -> read_exact(len)
- Write:
- Add message dispatch mechanism via
mpsc::unbounded_channel - User spawns tokio task for read loop (matches Python pattern)
Core Library Unit Tests:
- Test length prefix encoding (matches Python's little-endian format)
- Test message framing format (4-byte LE + JSON)
- Test send message with mock pipes
- Test multiple messages in sequence
- Test large messages (>32KB JSON, 100KB tested)
- Test malformed length prefix (error handling)
- Test broken pipe (server crash)
- Test graceful shutdown (no messages lost)
Integration Tests:
- Launch real Playwright server and create transport
- Verify transport works with real process stdio (not just mock pipes)
- Test transport handles server crash gracefully
- Verify server responds to protocol messages (completed in Slice 5 via initialization flow)
- Test concurrent message sending (basic coverage in Slice 5; advanced testing deferred to Version 0.2)
Integration Test Notes:
- Basic integration tests verify transport layer works with real Playwright server process
- Full protocol interaction testing completed in Slice 5 (initialization flow with real server)
- Advanced concurrent request testing deferred to Version 0.2 (requires browser launching)
- Transport reconnection deferred to Version 0.2+
Documentation:
- Rustdoc for
Transporttrait andPipeTransport - Document length-prefix framing protocol (in code comments)
- Example showing PipeTransport usage in rustdoc
- Link to Python's PipeTransport for reference architecture
Transport Implementation Research (2025-11-05):
Based on analysis of all three official bindings, the transport layer follows these patterns:
Message Framing (Identical across all bindings):
- 4-byte little-endian length prefix followed by JSON payload
- Python:
len(data).to_bytes(4, byteorder="little") - Java: Bit shifting
(v >>> 8) & 0xFFfor each byte - .NET: Byte masks
(len >> 8) & 0xFFfor encoding
Read Loop Patterns:
- Python: Async loop with
readexactly(4)for header, thenreadexactly(length)in 32KB chunks - Java: Blocking thread with
DataInputStream.readInt(), separate reader thread - .NET: Async
ReadAsync()with 1KB buffer, accumulate until message complete
Dispatch Mechanisms:
- Python: Direct callback
on_message(obj)- matches Rust async model best - Java: Blocking queue
incoming.put(message)- thread-based - .NET: Event
MessageReceived?.Invoke()- async/await based
Rust Implementation Strategy:
- Follow Python's async pattern (closest to tokio's model)
- Use
tokio::io::AsyncReadExt::read_exact()for framing - Direct callback via channels (matches Python's
on_message) - Single async task for read loop (not separate threads)
Key Code Pattern to Match:
# Python reference implementation
async def run(self):
while not self._stopped:
buffer = await self._proc.stdout.readexactly(4)
length = int.from_bytes(buffer, byteorder="little")
data = await self._proc.stdout.readexactly(length)
obj = json.loads(data)
self.on_message(obj)
Notes:
- Use
tokio::io::AsyncReadExtandAsyncWriteExtfor async I/O - Match Python's chunked reading for large messages (32KB buffer)
- Use
tokio::sync::mpscfor message dispatch (replaces Python's callback) - Ensure reader loop exits cleanly on shutdown (use cancellation token)
Lessons Learned (Post-Implementation 2025-11-05):
-
Generic Type Parameters Critical for Testing
- Made
PipeTransport<W, R>generic overAsyncWrite + AsyncRead - Allows unit tests to use
tokio::io::duplex()mock pipes - Production code uses
ChildStdinandChildStdoutfrom real process - Key insight: Don't hardcode process types - use generics for testability
- Made
-
Duplex Pipe Patterns for Bidirectional Testing
- Challenge: Single duplex pipe causes deadlocks when testing bidirectional I/O
- Solution: Use two separate duplex pipes:
- Pipe 1: Transport writes to
stdin_write, test reads fromstdin_read - Pipe 2: Test writes to
stdout_write, transport reads fromstdout_read
- Pipe 1: Transport writes to
- Pattern:
let (stdin_read, stdin_write) = tokio::io::duplex(1024); let (stdout_read, stdout_write) = tokio::io::duplex(1024); let (transport, rx) = PipeTransport::new(stdin_write, stdout_read);
-
Build Script Output Should Be Silent When Normal
- Initially:
cargo:warning=for "driver already exists" (shown every build) - Fixed: Only show warnings when actually downloading or on errors
- Rust convention: Quiet when everything is working correctly
- Initially:
-
Integration Tests Validate Real-World Behavior
- Unit tests with mocks verify framing logic
- Integration tests with real Playwright server verify:
- Process stdio works differently than mock duplex pipes
- Server communication patterns
- Error handling with real process crashes
- Both test types are essential - don't skip integration tests!
-
Test Hierarchy: Unit → Integration → E2E
- Unit tests (8): Message framing, encoding, error handling (mock pipes)
- Integration tests (3): Real server process, stdio communication, crash handling
- E2E tests (deferred to Slice 4): Actual browser launch with Chromium/Firefox/WebKit
- Clear separation of concerns at each test level
-
Documentation of Design Patterns
- Downcasting and RAII need explicit explanation for future implementers
- Don't assume developers know these patterns in Rust context
- Link implementation patterns to official bindings (Python/Java/.NET)
-
Shutdown via Channel Drop (No Explicit Method Needed)
- No explicit
shutdown()method implemented - Shutdown pattern: Drop the receiver (
rx) →send()inrun()loop fails → loop exits - Idiomatic Rust: Use RAII (resource cleanup on drop) instead of explicit methods
- Tested in
test_graceful_shutdown: Verify loop exits when channel is dropped - Simpler than Python's explicit
close()- Rust's ownership handles it automatically
- No explicit
Slice 3: Connection - JSON-RPC Request/Response Correlation
Status: ✅ Complete (2025-11-06)
User Value: Can send JSON-RPC requests to Playwright server and await responses, with proper error handling.
Acceptance Criteria:
- Each request has unique incrementing ID
- Responses are correlated with requests by ID
- Multiple concurrent requests are handled correctly
- Protocol events (no ID) are distinguished from responses
- Errors from server are propagated as Rust errors
- Timeout handling for requests that never receive response (Note: Implemented as channel closed error when response never arrives)
Core Library Implementation (playwright-core):
- Create
src/connection.rsmodule:struct Connection<W, R>- JSON-RPC client (generic over AsyncWrite/AsyncRead)transport: Arc<Mutex<PipeTransport<W, R>>>- Underlying transportlast_id: AtomicU32- Request ID countercallbacks: Arc<Mutex<HashMap<u32, oneshot::Sender<Result<JsonValue>>>>>- Pending requestsmessage_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<Value>>>>- Message receiver from transport
Connection::new(transport: PipeTransport<W, R>, message_rx) -> SelfConnection::send_message(guid: &str, method: &str, params: JsonValue) -> Result<JsonValue>Connection::dispatch(message: Message) -> Result<()>- Handle incoming messagesConnection::run()- Async message dispatch loop (spawns transport loop internally)
- Define protocol message types:
struct Request { id: u32, guid: String, method: String, params: JsonValue }struct Response { id: u32, result: Option<JsonValue>, error: Option<ErrorWrapper> }struct Event { guid: String, method: String, params: JsonValue }enum Message { Response(Response), Event(Event) }- Discriminated union using#[serde(untagged)]
- Implement request/response correlation:
- Generate unique ID for each request using
AtomicU32::fetch_add - Store
oneshot::Senderin callbacks map - On response, complete the sender and remove from map
- Generate unique ID for each request using
- Implement event dispatch (logs events for now, full dispatch in Slice 4)
Core Library Unit Tests:
- Test request ID increments correctly
- Test dispatch returns response for matching ID (test_dispatch_response_success)
- Test concurrent requests (test_concurrent_requests with 3 concurrent requests)
- Test response with error field (test_dispatch_response_error)
- Test dispatch routes responses correctly by ID
- Test dispatch handles events (test_message_deserialization_event)
- Test invalid ID error (test_dispatch_invalid_id)
- Test message deserialization (Response vs Event)
- Test error type parsing (TimeoutError, TargetClosedError, generic)
Integration Tests:
- Test connection lifecycle with real Playwright server (test_connection_lifecycle_with_real_server)
- Test error detection on server crash (test_connection_detects_server_crash_on_send)
- Test actual protocol messages with server (completed in Slice 5 via initialization flow)
- Test concurrent requests (basic coverage in Slice 5; advanced scenarios in Version 0.2)
Documentation:
- Rustdoc for
Connectionand all message types - Document JSON-RPC protocol format in code comments
- Examples showing request/response flow in rustdoc
- Links to official Playwright bindings for reference
Notes:
- ✅ Used
tokio::sync::oneshotfor request/response completion - ✅ Used
Arc<tokio::sync::Mutex<>>for thread-safe shared state (async-safe) - ✅ Timeout handling: Implemented via channel closed error when connection drops
- ✅ Event handling deferred to Slice 4 (currently logs events via tracing)
Lessons Learned (Post-Implementation 2025-11-06):
-
Async Mutex Required for Async Operations
- Initially used
std::sync::Mutexbut caused compile errors with.await - Solution: Use
tokio::sync::Mutexfor any locks held across await points std::sync::Mutexis fine for quick operations without awaits
- Initially used
-
Generic Type Parameters for Testability
- Made
Connection<W, R>generic overAsyncWrite + AsyncRead - Allows unit tests to use
tokio::io::duplex()mock pipes - Production code uses real
ChildStdinandChildStdout - Same pattern as PipeTransport
- Made
-
Untagged Enum for Protocol Message Discrimination
- Used
#[serde(untagged)]onenum Message { Response, Event } - Serde automatically distinguishes based on presence of
idfield - Cleaner than manual field checking
- Matches JSON-RPC protocol exactly
- Used
-
Connection Spawns Transport Loop Internally
Connection::run()spawns the transport read loop as a background task- Simplifies API - user only needs to spawn one loop, not two
- Transport loop reads from stdio and sends to channel
- Connection loop reads from channel and dispatches messages
-
Integration Tests with Real Server
- Basic lifecycle test: server launches, connection starts, no panics
- Error detection test: send after crash detects broken pipe fast (~150ms)
- Full protocol tests deferred to Slice 4 (need object initialization)
- Clear separation: unit tests for logic, integration tests for infrastructure
-
Error Propagation Through Layers
- Transport errors (broken pipe, read failures) →
Error::TransportError - Protocol errors (TimeoutError, TargetClosedError) → specific error variants
- Channel closed →
Error::ChannelClosed - Clear error boundaries at each layer
- Transport errors (broken pipe, read failures) →
Slice 4: Object Factory and Channel Owners
Status: ✅ Complete (2025-11-06)
User Value: Protocol objects (Browser, Page, etc.) are automatically created when server sends initializers, enabling the object model.
Acceptance Criteria:
- Connection creates objects from protocol messages
- Each object has a GUID and type
- Objects are stored in connection's object registry
- Events are routed to correct object by GUID
- Object lifecycle is managed (creation, deletion via create, dispose, adopt)
Core Library Implementation (playwright-core):
- Create
src/channel_owner.rs:trait ChannelOwner- Base for all protocol objectsfn guid(&self) -> &strfn on_event(&self, method: &str, params: JsonValue)fn connection(&self) -> Arc<dyn ConnectionLike>fn parent(),fn initializer(),fn channel(),fn dispose(),fn adopt(), etc.
struct ChannelOwnerImpl- Reusable base implementation
- Create
src/connection.rsadditions:trait ConnectionLike- Object-safe connection interface- Object registry:
objects: Arc<Mutex<HashMap<String, Arc<dyn ChannelOwner>>>> - Methods:
register_object(),unregister_object(),get_object()
- Create
src/channel.rs:struct Channel- RPC communication proxyfn send<P, R>()- Generic typed RPC calls
- Create
src/object_factory.rs:fn create_object(parent: ParentOrConnection, type_name: String, guid: String, initializer: Value) -> Result<Arc<dyn ChannelOwner>>- Match on
type_name:"Playwright"->Playwright::new()"BrowserType"->BrowserType::new()- Future:
"Browser","BrowserContext","Page", etc. (Version 0.2) - Unknown types return error with logging
- Create protocol objects:
src/protocol/mod.rs- Protocol modulesrc/protocol/playwright.rs- Root Playwright object with chromium(), firefox(), webkit()src/protocol/browser_type.rs- BrowserType object with name and executable_path
- Update
Connection::dispatch():- Handle
__create__messages viahandle_create() - Handle
__dispose__messages viahandle_dispose() - Handle
__adopt__messages viahandle_adopt() - Call
create_object()for new objects - Store in
objectsregistry by GUID - Route events to object by GUID via
on_event()
- Handle
Core Library Unit Tests:
- Connection unit tests (27 tests in connection.rs) - Request ID, dispatch, concurrent requests, error handling
- Transport unit tests (8 tests in transport.rs) - Message framing, encoding, large messages
- Server unit tests (2 tests in server.rs) - Launch, shutdown, kill
- Driver unit tests (1 test in driver.rs) - Node executable detection
- Note: Object creation/registration tested via integration tests (require real Connection and server)
Integration Tests:
-
test_connection_lifecycle_with_real_server- Server launches, connection starts, no panics -
test_connection_detects_server_crash_on_send- Broken pipe detection
Integration Tests Completed in Slice 5: The following tests were deferred to Slice 5 and are now complete:
- Verify root "Playwright" object creation from server create messages
- Verify "BrowserType" objects are initialized from server create messages
- Test sending protocol requests with valid object GUIDs (via initialize flow)
- Test full request/response cycle with object factory (via initialize flow)
Integration Tests Deferred to Version 0.2: These require additional protocol objects and browser launching - see Version 0.2 Implementation Plan:
- Test concurrent requests to different objects
- Test complex protocol message sequences (browser launch, page create, etc.)
- Test transport reconnection scenarios
Rationale for Deferral: These tests require:
- Complete initialization sequence (launch server → receive create messages → build object tree)
- Valid object GUIDs from initialized objects
Playwright::launch()API to orchestrate the flow
This functionality is implemented in Slice 5 (Entry Point), not Slice 4 (Object Factory). Slice 4 provides the infrastructure (object factory, ChannelOwner, protocol handlers). Slice 5 provides the orchestration (launch sequence, initialization, public API).
Documentation:
- Rustdoc for
ChannelOwnertrait with complete implementation example - Rustdoc for
ChannelOwnerImplwith usage pattern - Rustdoc for
Channelwith RPC examples - Rustdoc for
object_factory::create_object()with usage - Rustdoc for
PlaywrightandBrowserTypeprotocol objects - Code comments explaining object lifecycle, downcasting, RAII patterns
- Links to official Playwright implementations for reference
Notes:
- Start with minimal object types (Playwright, BrowserType) ✅
- Full Browser/Page implementation comes in Version 0.2
- Use
Arc<dyn ChannelOwner>for object references ✅ - Downcasting: Convert generic objects to specific types using
Anytrait ✅- Implemented via
as_any()method returning&dyn Any - Example:
object.as_any().downcast_ref::<BrowserType>() - Used in Playwright object to access BrowserType references
- Implemented via
Lessons Learned (Post-Implementation 2025-11-06):
-
Object-Safe Traits with Async Methods
- Challenge:
impl Futurein traits preventsdyn Traitusage - Solution: Use
Pin<Box<dyn Future>>for object-safe async methods - Applied in
ConnectionLike::send_message()to enableArc<dyn ConnectionLike>
- Challenge:
-
Lifetime Management with Boxed Futures
- Challenge: String slices in async blocks cause lifetime issues with Box::pin
- Solution: Convert to owned
Stringbefore boxing the future - Pattern: Clone strings into the async block to satisfy 'static requirement
-
Circular Dependencies Between Modules
- Challenge: Connection needs ChannelOwner, ChannelOwner needs Connection
- Solution: Create
ConnectionLiketrait that Connection implements - Pattern: Use trait abstraction to break circular type dependencies
-
Generic Type Parameters for Testability
- Continued from Slices 2-3:
Connection<W, R>generic over AsyncWrite/AsyncRead - Enables both unit tests (mock duplex pipes) and integration tests (real server)
- Pattern: Generic at low level, type alias for common case
- Continued from Slices 2-3:
-
Downcasting Pattern for Protocol Objects
- Pattern: Store as
Arc<dyn ChannelOwner>, downcast viaas_any() - Example:
object.as_any().downcast_ref::<BrowserType>()for concrete access - Matches pattern from official Playwright bindings (type-erased storage)
- Pattern: Store as
-
Testing Strategy: Integration Over Unit
- Object creation/registration requires real Connection + server
- Unit tests for isolated logic (message parsing, ID generation)
- Integration tests for object lifecycle and protocol flow
- Clear separation: what can be mocked vs. what needs real infrastructure
-
Documentation as Code
- Complete doctest examples serve as both docs and tests (15 doctests passing)
- Show full trait implementation pattern for future protocol objects
- Provides reference for contributors adding Browser, Page, etc.
Slice 5: Entry Point - Playwright::launch()
Status: ✅ Complete (2025-11-06)
User Value: Can write Playwright::launch().await? to get a working Playwright instance with access to browser types.
Acceptance Criteria:
-
Playwright::launch()returnsResult<Playwright> - Playwright instance provides access to
chromium(),firefox(),webkit() - Connection lifecycle is managed automatically
- Errors during initialization are propagated clearly
- Example code in README works end-to-end
Progress (2025-11-06):
- Research completed - documented in ADR-0002
- Root object implemented (
protocol/root.rs) - Connection::initialize_playwright() implemented
- Integration test written
- Transport deadlock fixed (split stdin/stdout Arc<Mutex<>>)
- Playwright::launch() API implemented
- Public API crate created
- Example code added and verified
- All tests passing
Core Library Implementation (playwright-core):
- Create
src/protocol/playwright.rswith Playwright objectpub struct Playwright- Public API entry pointbase: ChannelOwnerImplchromium: Arc<dyn ChannelOwner>firefox: Arc<dyn ChannelOwner>webkit: Arc<dyn ChannelOwner>
impl Playwright:pub async fn launch() -> Result<Self>pub fn chromium(&self) -> &BrowserTypepub fn firefox(&self) -> &BrowserTypepub fn webkit(&self) -> &BrowserType
- Implement launch flow:
- Download driver if needed (via build.rs)
- Launch server process
- Create transport
- Create connection
- Start connection dispatch loop
- Initialize Playwright (via Root object)
- Extract BrowserType objects
- Return Playwright instance
- Export in
playwright-core/src/lib.rs:pub use protocol::Playwright;pub use error::{Error, Result};
Public API Crate (playwright):
- Create
crates/playwright/workspace member - Add dependency on
playwright-core - Re-export public API in
src/lib.rs:pub use playwright_core::{Playwright, Error}; - Add basic example in
examples/basic.rs:use playwright::Playwright; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let playwright = Playwright::launch().await?; println!("Playwright launched successfully!"); println!("Chromium: {:?}", playwright.chromium()); Ok(()) }
Core Library Unit Tests:
- Test
Playwright::launch()returns Ok - Test browser types are available
- Test launch with driver not found (error) - verified error type exists
- Test launch with server crash (error) - covered by connection layer tests
Integration Tests:
- Test full launch flow with real server
- Verify all three browser types exist
- Test multiple Playwright instances
- Test graceful cleanup on drop
Documentation:
- Rustdoc for
Playwrightstruct and methods - Usage example in doc comments
- Update README.md with working example
- Document error scenarios
Notes:
- Consider implementing
Dropfor cleanup - RAII (Resource Acquisition Is Initialization): Automatic cleanup when objects go out of scope
- Example: Browser automatically closes when
browservariable is dropped - Implemented via Rust's
Droptrait:impl Drop for Browser { fn drop(&mut self) { ... } } - Challenge:
Dropis synchronous, but cleanup requires async calls to server - Solutions: Spawn background task in Drop, or require explicit
.close()calls - Matches Python's context manager pattern (
with sync_playwright() as p:)
- Example: Browser automatically closes when
- Connection dispatch loop should run in background task
- Need to handle Playwright object initialization timeout
Slice Priority and Dependencies
| Slice | Priority | Depends On | Status |
|---|---|---|---|
| Slice 1: Server Launch | Must Have | None | ✅ Complete |
| Slice 2: Stdio Transport | Must Have | Slice 1 | ✅ Complete |
| Slice 3: Connection Layer | Must Have | Slice 2 | ✅ Complete |
| Slice 4: Object Factory | Must Have | Slice 3 | ✅ Complete |
| Slice 5: Entry Point | Must Have | Slice 4 | ✅ Complete |
Critical Path: All slices are sequential and required for Version 0.1 completion.
Definition of Done
Version 0.1 is complete when ALL of the following are true:
- All acceptance criteria from all slices are met
- Can run:
Playwright::launch().await?successfully - Can access
chromium(),firefox(),webkit()browser types (objects exist, not yet launching browsers) - All tests passing:
cargo test --workspace - Example code in README.md works
- Core library documentation complete:
cargo doc --open - Code formatted:
cargo fmt --check - No clippy warnings:
cargo clippy --workspace -- -D warnings - Cross-platform compatibility (macOS, Linux) - Windows optional
- README.md updated with Version 0.1 status
- Playwright server downloads automatically on first run
- No unsafe code (or justified with SAFETY comments)
- Error messages are helpful and actionable
Success Metric: Can execute this code without errors:
use playwright::Playwright;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let playwright = Playwright::launch().await?;
println!("Chromium: {:?}", playwright.chromium());
println!("Firefox: {:?}", playwright.firefox());
println!("WebKit: {:?}", playwright.webkit());
Ok(())
}
Note on Cross-Browser Testing:
Version 0.1 establishes the protocol foundation and provides access to all three BrowserType objects (Chromium, Firefox, WebKit). Actual browser launching (e.g., chromium().launch().await?) and comprehensive cross-browser testing will be implemented in Version 0.2 (Browser API implementation). The architecture built in Version 0.1 is designed from the ground up to support all three browsers equally.
Learnings & Adjustments
What's Working Well
As of Slice 3 completion (2025-11-06):
-
Vertical Slicing Approach
- Each slice delivers end-to-end testable functionality
- Clear dependencies between slices enable incremental progress
- TDD workflow (Red → Green → Refactor) keeps quality high
-
Generic Type Parameters
Transport<W, R>andConnection<W, R>generic over AsyncWrite/AsyncRead- Enables both unit tests (mock duplex pipes) and integration tests (real server)
- Excellent testability without sacrificing production performance
-
Research-Driven Implementation
- Studying all three official bindings (Python, Java, .NET) before implementing
- Identified common patterns (sequential IDs, oneshot channels, untagged enums)
- Avoided pitfalls (std::sync::Mutex vs tokio::sync::Mutex)
-
Cross-Platform Support
- CI validates on macOS, Ubuntu, and Windows
- All 39 tests passing on all three platforms
- Platform detection and driver download working correctly
Challenges Encountered
-
Async Mutex Requirements
- Initial use of
std::sync::Mutexfailed when holding locks across.await - Solution: Use
tokio::sync::Mutexfor async operations - Learned: Check if locks are held across await points
- Initial use of
-
Test Timeout Issues
- Initial crash detection test used passive 5s timeout
- Solution: Actively send message to trigger broken pipe detection fast
- Result: Test time reduced from 5s to ~150ms
-
Transport Ownership in Connection
- Initially unclear whether transport should be spawned separately or owned by Connection
- Solution: Connection owns transport and spawns its loop internally
- Result: Simpler API - user only spawns Connection.run()
Adjustments Made to Plan
-
Deferred Test Clarification
- Originally said transport protocol tests "deferred to Slice 3"
- Realized they need Slice 4 (object initialization for valid GUIDs)
- Updated: All protocol interaction tests now correctly deferred to Slice 4
-
Integration Test Strategy
- Planned full protocol tests in Slice 3
- Realized we need object initialization first
- Adjusted: Basic lifecycle tests in Slice 3, full protocol tests in Slice 4
-
Message Loop Architecture
- Originally considered spawning transport and connection loops separately
- Decided: Connection spawns transport loop internally
- Benefit: Cleaner API, easier for users
Lessons for Future Features
-
Start with Research
- Always study official bindings first
- Document patterns before implementing
- Saves time and avoids design mistakes
-
Generic for Testability
- Generic type parameters enable both unit and integration tests
- Worth the complexity for excellent test coverage
- Pattern:
PipeTransport<W, R>,Connection<W, R>
-
Defer Appropriately
- Don't try to test everything in early slices
- Defer tests that require future infrastructure (e.g., full protocol tests)
- Document deferrals clearly in the plan
Created: 2025-11-05 Last Updated: 2025-11-06 Completed: 2025-11-06
Timeline:
- Slice 1: Completed 2025-11-05
- Slice 2: Completed 2025-11-05
- Slice 3: Completed 2025-11-06
- Slice 4: Completed 2025-11-06
- Slice 5: Completed 2025-11-06
- Total development time: 2 days