-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
LegionForge edited this page Aug 25, 2026
·
23 revisions
Screenwright has two entry points (CLI, MCP server) that both call into the same
capture/vision/output core. Neither entry point talks to Playwright directly —
capture.py owns the browser lifecycle.
flowchart TD
subgraph Entry points
CLI["cli.py<br/>(Typer CLI)"]
MCP["mcp_server.py<br/>(stdio MCP server)"]
end
TOML["TOML config<br/>(config.py — Pydantic models)"]
CAP["capture.py<br/>Playwright browser/context/page lifecycle"]
VIS["vision.py<br/>pluggable describe()"]
OUT["output.py<br/>PNG sidecar JSON + markdown index"]
CLI -->|load_config| TOML
MCP -->|load_config| TOML
TOML --> CAP
CLI -->|run_flow| CAP
MCP -->|run_flow_tool / capture_url| CAP
CAP -->|CaptureResult per screenshot| VIS
VIS -->|ScreenshotMetadata| OUT
CAP -->|video_path / video_mp4_path| OUT
OUT --> DOCS["docs/screenshots/<br/>{flow}/*.png *.json index.md<br/>+ README.md"]
classDef entry fill:#2563eb,color:#fff,stroke:none
classDef core fill:#0f172a,color:#fff,stroke:none
class CLI,MCP entry
class TOML,CAP,VIS,OUT core
| Module | Owns |
|---|---|
config.py |
TOML → Pydantic models (ScreenwrightConfig, Flow, Step discriminated union, VisionConfig). No I/O beyond reading the TOML file. |
capture.py |
The only module that touches Playwright. run_flow drives one Browser + (optionally) one recording BrowserContext through a flow's steps, closes them, and finalizes video. capture_single_url is the one-shot path used by the MCP capture_url/capture_element tools. |
vision.py |
describe(image_path, cfg) -> ScreenshotMetadata. Three private per-provider implementations (_describe_anthropic, _describe_openai, _describe_ollama) share prompt-building (_build_prompt) and response-parsing (_parse_response, which degrades gracefully to a raw-text description if the model doesn't return valid JSON). |
output.py |
Turns FlowResult/CaptureResult objects into the on-disk docs structure: {name}.json sidecars, {flow}/index.md, root README.md. |
cli.py |
Typer commands (run, flows) — orchestrates load_config → run_flow → describe (if enabled) → write_flow_output, one flow at a time, with a Rich progress display. |
mcp_server.py |
FastMCP server exposing capture_url, capture_element, run_flow_tool, list_flows, describe_flow, describe_screenshot as MCP tools over stdio. Config resolution falls back to SCREENWRIGHT_CONFIG env var when a tool call doesn't pass config_path. |
sequenceDiagram
participant Caller as CLI or MCP tool
participant Cap as capture.py
participant PW as Playwright Page
participant Vis as vision.py
participant Out as output.py
Caller->>Cap: run_flow(flow, config, output_root)
Cap->>PW: navigate / fill / click / hover / check / select ...
Cap->>PW: page.screenshot() or element.screenshot()
PW-->>Cap: PNG written to {flow}/{name}.png
Cap-->>Caller: FlowResult{captures, video_path?}
opt vision_describe = true
Caller->>Vis: describe(png_path, vision_cfg)
Vis-->>Caller: ScreenshotMetadata
end
Caller->>Out: write_flow_output(result, output_root)
Out-->>Caller: index.md path
-
One capture core, two entry points. The CLI and the MCP server are both thin — all the
actual Playwright/vision/output logic lives in
capture.py/vision.py/output.pyso the two entry points can't drift out of sync. -
Video recording is flow-scoped, not step-scoped, because Playwright ties video capture to
a
BrowserContext, which can't be paused/resumed mid-flow. SeeFlow.recordinconfig.pyand the context-vs-page branch incapture.py::run_flow. -
Vision is fully optional and provider-swappable so a private/air-gapped flow (Moondream2
via Ollama) and a cloud flow (Claude Haiku / GPT-4o-mini) use the exact same
describe()interface and the exact sameScreenshotMetadatashape downstream. -
Neither browser/context/page setup nor a step failure ever raises out of
run_flow. Setup (including loadingFlow.storage_state) and the step loop are both wrapped — a badstorage_statepath, a missing/expired session, or a bad selector on step 4 of 5 all land onFlowResult.error/failed_step_index, and video is always finalized + the browser always closed in afinallyblock regardless of where things stopped.run_flow_toolsurfaces this as a{captures, error, failed_step_index, ...}dict so an agent driving Screenwright sees a partial result it can act on, not a failed tool call or an unhandled exception. -
Browser launch itself is now inside that same contract, too.
p.chromium.launch()used to sit outside every setup/step/finalize try block inrun_flow— a missing/corrupted Chromium install or a resource-exhausted host would crashrun_flow_toolwith an unhandled exception instead of the clean partialFlowResultevery other failure path already returns. Fixed by wrapping the launch call and returning early withresult.errorset on failure — there's nothing to close in that case, since Playwright'slaunch()never leaves an orphaned process behind on its own failure. -
Auth is session injection, not scripted login.
Flow.storage_stateloads Playwright's own cookie/localStorage export before any step runs — the standard, robust way to capture an already-authenticated session for an internal app, instead of re-running a fragile fill/click login sequence (2FA, CAPTCHAs, rate limits) on every capture. -
Accessibility snapshots are a first-class capture output, not a vision-model afterthought.
CaptureStep.accessibility_snapshotwrites Playwright'saria_snapshot()— the exact semantic tree assistive technology sees — to{name}.aria.yaml, free and deterministic, versus a vision model's paid, approximate guess at the same PNG. Always whole-page: Playwright exposesaria_snapshot()onPage/Locator, not on theElementHandlethis step's selector-scoped screenshot path uses. -
PDF export follows the same shape, for the same reason.
CaptureStep.pdfcallspage.pdf()— also whole-page-only, also Chromium-only — so it's not scoped toselectoreither, matchingaccessibility_snapshot's constraint rather than pretending otherwise. -
Capture variants change the existing page in place, not the browser/context lifecycle.
CaptureStep.variantsloops within the existingCaptureStepbranch and callspage.set_viewport_size()/page.emulate_media()on the already-open page before each variant's capture — no new context or page is created. This was a deliberate choice over the alternative (spin up a fresh context per variant) specifically to avoid touching the browser/context setup path at all, keeping this feature low-risk against the well-tested video-recording and error-handling code that setup path shares. One real gotcha this uncovered:page.emulate_media(color_scheme=None)is a no-op, not a reset — restoring the flow's default after acolor_schemevariant requires an explicit"light", notNone. -
Determinism is the default, not opt-in.
CaptureStep.animationsdefaults to"disabled"(Playwright's own screenshot default is"allow") — a deliberate departure, since a documentation tool capturing a random mid-animation frame is rarely intended and is the single biggest source of unnecessary pixel diffs between otherwise-identical runs.CaptureStep.maskfills selected elements with a solid color before capturing (live clocks, avatars, etc.); unlikeselector, amaskentry matching nothing is a silent no-op, verified directly against the installed Playwright before relying on it — an optional masking target not being present everywhere a flow runs isn't a flow failure. -
Concurrency is opt-in, not the default, in the CLI.
cli.py run --concurrency Nbounds concurrent flows with anasyncio.Semaphore, defaulting to 1 — identical sequential behavior to before this option existed. This meant restructuringrunfrom "callasyncio.run()once per flow in a sync loop" to oneasyncio.run()wrapping the whole command, and wrapping the synchronousdescribe()call inasyncio.to_thread()so it doesn't block other flows' progress under--concurrency > 1. The per-flow progress task is created only after the semaphore is acquired (not upfront for every flow), so the default case's progress display is pixel-for-pixel the same as before — tasks appear one at a time, in order. -
HAR capture required broadening the page-close logic, not just adding a Playwright kwarg.
Flow.harpassesrecord_har_pathtobrowser.new_page()/new_context()the same waystorage_statedoes, but the.harfile — like.webm— only flushes when the page (and context, if any) is explicitly closed, not onbrowser.close()alone. Before this, the non-recordpath never closedpageexplicitly (relying onbrowser.close()to sweep it up), which is exactly the case where HAR would have silently produced an empty/missing file. Verified directly against the installed Playwright before writing any code, and the finalize block's guard broadened fromcontext is not None and page is not Noneto justpage is not Nonesopage.close()always runs — harmless when neither video nor HAR is active, required when either is. -
--checkis an exact-byte diff living entirely incli.py, not a perceptual-diff feature in the capture engine.run_flow/capture.pyare unchanged;_process_flowhashes a flow's output directory's PNGs (SHA256) before running it, then again after, and reports any filename whose hash changed or is new. Hashing is skipped entirely when--checkisn't passed (beforeis{},changedis always[]), so the common case pays no cost for a feature it doesn't use. This intentionally pairs with (and depends on) the determinism work above — a page with residual non-determinism will report false positives under--check; the fix is tighteninganimations/mask, not adding pixel tolerance here. -
Navigation retries with backoff, mirroring
vision.py's existing retry pattern but async.vision.py's_with_retryis synchronous (the vendor SDK clients it wraps are synchronous);page.goto()is async, socapture.pygained its own_goto_with_retry/_is_transient_navigation_errorpair rather than sharing code across the sync/async divide. Retries up to 2x with the same 1s/2s exponential backoff as vision, only onplaywright.async_api.TimeoutErroror anErrorwhose message containsnet::ERR_(DNS hiccup, connection reset) — anything else (a 404, a malformed selector-bearing URL) is a real error that retrying would just delay reporting. Applied to bothrun_flow's per-stepNavigateStephandling andcapture_single_url's initial navigation. -
The mp4-conversion call was the one finalize-block step never brought under the
"never raise" contract.
run_flow's browser/context setup and its per-step loop both catch exceptions and report them viaFlowResult.error, but_convert_to_mp4(final_path)(called whenrecord_mp4 = true) was still unguarded — a missingffmpegor a failed conversion propagated straight out ofrun_flow, crashing the whole CLI run or MCP tool call rather than reporting a partial result. Fixed by wrapping just that call in try/except and appending toresult.error—result.video_pathand every capture already written stay intact. -
capture_single_urlnever hadrun_flow's browser-close guarantee. It calledawait browser.close()as its literal last line instead of in try/finally, so any exception fromnew_page, navigation, or the capture itself leaked the Chromium process. Real risk on the MCP surface specifically: an agent plausibly retriescapture_url/capture_elementwith a different selector after a "Selector not found" error, leaking one more browser per failed attempt. Fixed by wrapping the body in try/finally, matchingrun_flow's existing pattern. -
The video/HAR finalize block's
page.close()/context.close()/.webmrename could still raise past the mp4-conversion fix. That fix wrapped only_convert_to_mp4; a full disk, a page that crashed mid-flow, or a Playwright internal error on close would still propagate out ofrun_flowunhandled. Wrapped the whole block in an outer try/except that appendsFailed to finalize video/HAR: ...toresult.error, finally completing the "never raise" contract for every path throughrun_flow's finalize logic. -
A partial flow's failure was invisible in the generated docs themselves. Every fix above
ensures a mid-flow failure returns a
FlowResultwith.errorset instead of raising, butoutput.pynever rendered that field — a partial run'sindex.md/rootREADME.mdlooked identical to a fully successful one. Fixed by adding a⚠️ Flow stopped early: ...banner above a flow's capture table when.erroris set, and a✅/⚠️ PartialStatus column to the root README's flow table. -
ScreenwrightConfignever rejected duplicate flow names. Every flow's output path is derived from its name, so two flows sharing a name silently wrote to the same directory — worse, under--concurrency > 1both would record video/HAR to the same directory concurrently, which can corrupt either file. Fixed with amodel_validator(mode="after")onScreenwrightConfigthat rejects (doesn't silently dedupe) any duplicate name. The same failure mode existed one and two levels down too —CaptureStep.variantssharing a name, and twocapturesteps in the sameFlowsharing a name — closed with the same validator pattern onCaptureStepandFlowrespectively. -
A variant's
color_schemecould leak into a later variant in the same step. The capture loop only calledpage.emulate_media(color_scheme=...)when a variant explicitly setcolor_scheme; when unset, the call was skipped entirely rather than resolving to"light"(the documented fallback). Adarkvariant followed by one that doesn't setcolor_schemerendered that later variant still in dark mode. The existing post-step restore only resets state after the whole loop finishes, which doesn't help variant-to-variant leakage within the same step. Fixed by always resolvingcolor_schemeexplicitly per variant, mirroring how viewport width/height already resolve per-variant with a flow-default fallback. -
screenwright runnever exited non-zero for a failed flow — the one gap that mattered most for the CI pre-flight use case the README markets. Every crash-fix above routes a mid-flow failure throughFlowResult.errorinstead of raising, andrunalready printed a"stopped early"warning for it — but nothing ever calledraise typer.Exit(1)for that case, only for--concurrency < 1and (separately)--checkfinding a diff. A CI pipeline invokingscreenwright runas a pre-flight check would see exit code 0 and pass the build even when a flow genuinely failed. Fixed by raisingtyper.Exit(1)whenever any flow failed, combined with (not replacing) the existing--check-diff exit condition. -
run_flow_toolhad no path to populatedescribe_flow's metadata at all. Before this, nothing on the MCP surface ever wrote a.jsonsidecar —run_flow_toolis pure capture, anddescribe_screenshot(the only tool that calls a vision provider) never persists its result to disk. Fixed by adding an opt-invision_describe: boolparam (defaultfalse) torun_flow_toolthat, when true, runs the config's vision provider on each capture and callswrite_flow_outputafterward — the exact same capture-then-describe-then-write orderingcli.py'sruncommand already uses. A per-capturedescribe()failure is swallowed (matchingcli.py's own per-capture tolerance), not surfaced onresult.error. -
write_flow_output/write_root_readmeweren't guarded either. Bothcli.py's_process_flowandrun_flow_tool'svision_describe=truepath callwrite_flow_outputafter capturing; neither wrapped it, so a disk-full or permission-denied failure there would crash the whole call and discard every already-captured screenshot. Fixed by wrapping both call sites and appending toresult.error, and wrappingcli.py's outerwrite_root_readmecall with a clean error message + exit instead of a raw traceback. -
capture_url/capture_elementnever wired throughcapture_single_url's own configurability.capture_single_urlhas long supportedwait_until/timeout_ms/viewport_width/viewport_height/animations, but the two MCP tool wrappers called it with only three positional args, always falling back to hardcoded defaults regardless of what an agent needed. Fixed by adding the same five params to both tool signatures, typed asLiterals (matching the discoverability lesson from typingdescribe_screenshot'sprovider) rather than barestr, and passing them straight through. -
describe_screenshothad the same param-parity gap, one field over.VisionConfig.promptis a real, TOML-configurable field, but the tool wrapper only ever exposedprovider/model/structured_metadata, silently falling back to the built-in generic prompt no matter what an agent needed. Fixed by adding an opt-inprompt: Optional[str] = Noneparam, only forwarded into theVisionConfig(...)call when given — passingprompt=Noneexplicitly would instead fail validation, since the field is typedstr, notstr | None. -
describe_screenshotwould read and forward the contents of any file on disk, not just PNGs, to a third-party vision API.screenshot_pathis an absolute path an MCP client passes in, and on this surface an agent's next tool call can be shaped by untrusted page content it just captured — the same threat modelvalidate_safe_namealready takes seriously for flow/capture names. Nothing stoppeddescribe_screenshot("/home/project/.env")or a renamed secrets file: the tool base64-encodes the whole file and ships it to Anthropic/ OpenAI/Ollama regardless of content, a real exfiltration primitive if an agent is manipulated via a prompt-injection payload on a captured page. An extension check alone would be trivial to defeat by renaming the file. Fixed by checking the file's first 8 bytes against the real PNG magic number before any base64-encoding or provider call happens. -
capture_single_url(the one-shot path behindcapture_url/capture_element) never exposedmask/mask_color, even though_capture_page_or_element— the helper it already calls — has supported both since the deterministic-capture work.CaptureStep-based flows could mask a live clock or an avatar; the MCP one-shot tools had no equivalent, since the params never got threaded through whenmaskwas added (capture_single_urlpredates it). Fixed by addingmask/mask_colortocapture_single_urland both MCP tool signatures, passed straight through exactly asCaptureStepalready does — proven with a real behavioral test (masked vs. unmasked capture of the same page produce different bytes). -
_convert_to_mp4's ffmpeg subprocess had no timeout.proc.communicate()was awaited directly — a hung/runaway ffmpeg process (a malformed.webm, a pathological codec edge case) would block the wholerun_flow/run_flow_toolcall forever and leak the subprocess, the same class of failure the browser-closefinallyblocks elsewhere already guard against, just for a different resource. Fixed by wrappingcommunicate()inasyncio.wait_for(..., timeout=300s)and, on timeout, killing and reaping the process before raising a clear error — caught by the existing try/except that already turns a conversion failure intoresult.errorrather than an unhandled exception. -
_DEFAULT_OUTPUT(the fallback when nooutput_diris given) was created with normalmkdir()permissions in the shared system temp directory. It's a fixed, predictable path (/tmp/screenwright-output) any local user can see — left at default permissions, captured screenshots (potentially showing sensitive UI) were readable by every other local user on a shared machine, and a symlink pre-planted at that exact path could redirect writes somewhere unintended. Fixed by_ensure_private_default_output_dir(), called only when the resolved output root is_DEFAULT_OUTPUTitself (an explicitoutput_dirthe caller chose is left untouched): refuses to proceed if the path is a symlink, then creates/chmods it to0700. Applied at all three MCP write paths (capture_url,capture_element,run_flow_tool). Hardened again the same day: the first version checkedpath.is_symlink()before callingpath.mkdir(exist_ok=True)— a check-then-create race, sincePath.mkdir'sexist_okhandling follows symlinks when deciding whether the target "is a directory," so a symlink planted in the gap between the check and themkdir()call would be silently written through rather than rejected. Rewritten to attemptos.mkdir()first (atomic — nothing exists there yet succeeds outright, no window to race) and only inspect what's already present onFileExistsErrorviaos.lstat(which never follows symlinks), closing the race instead of just narrowing it. -
WaitStep.mshad no upper bound — the last remaining truly-unbounded-hang vector in flow execution. It's a rawasyncio.sleep(), unliketimeout_mselsewhere (a ceiling only waited out if something actually hangs) — awaitstep always sleeps its full duration deterministically. The realistic trigger is a seconds-vs-milliseconds units-confusion typo (ms = 60000000meaning "one minute"), tying up a browser for hours with no way to distinguish it from a legitimate long capture. Bounded to 5 minutes (ms: int = Field(ge=0, le=300_000)), caught at config-validation time the same place duplicate-name/secret-without-${ENV_VAR}mistakes already are.