diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..ee1c220
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,65 @@
+# Changelog
+
+All notable user-facing changes to Rustwright are documented in this file.
+
+## [Unreleased]
+
+### Added
+
+- Added a persistent `rustwright` CLI for browser sessions, accessibility snapshots, and element-reference actions.
+- Added `rustwright-mcp`, an MCP stdio server that exposes browser automation tools to MCP clients.
+- Added alpha bindings for Go, Java, C#/.NET, Ruby, and PHP, plus a native Rust API, backed by the shared Rust engine.
+
+### Changed
+
+- Moved actionability waits for supported, optionless `AsyncPage.click()` and `AsyncPage.fill()` calls into the Rust core while preserving trusted browser input and a single action deadline.
+- Centralized evaluation value decoding in the Rust core for the Go/C-ABI and native Rust surfaces, and added structured timeout, closed, crashed, and disconnected errors for the Python API.
+
+### Fixed
+
+- Fixed locator waits so they re-arm after mid-wait navigation against the original timeout instead of surfacing execution-context errors.
+- Fixed remote-CDP actionability probes so they receive the full remaining action budget rather than a short per-probe cap.
+- Fixed Node.js evaluation decoding for special numeric values, BigInt, and regular expressions; Go/C-ABI and native Rust now use the core's canonical wire decoder.
+
+## [0.1.1] - 2026-07-15
+
+### Added
+
+- Published the experimental Node.js binding to npm.
+- Added native async execution for Chromium launch, context and page creation, and common page operations, with an executor fallback for unsupported cases.
+
+### Changed
+
+- Aligned Chromium launch defaults with Playwright while retaining Rustwright's automation-signal suppression and CDP transport choices.
+
+### Fixed
+
+- Fixed `Locator.fill()` for React-controlled inputs by using the browser input path for ordinary editable text.
+- Fixed trusted pointer actions and frame remapping during navigation in nested cross-origin iframes.
+
+## [0.1.0] - 2026-07-14
+
+### Added
+
+- Published the Python package on PyPI for installation with `pip install rustwright`.
+- Added a documented parity map for the Python sync and async Playwright API surfaces.
+
+## [0.1.0-alpha.4] - 2026-07-13
+
+### Fixed
+
+- Fixed the npm release command so the assembled package tarball is treated as a local file.
+
+## [0.1.0-alpha.3] - 2026-07-13
+
+### Added
+
+- Released the initial Chromium-only alpha with an in-process Rust CDP core and Playwright-shaped Python sync and async APIs.
+- Added trusted CDP input, cross-origin iframe support, and opt-in Python compatibility imports for existing Playwright code.
+- Added an experimental Node.js binding for launching Chromium and performing core page navigation, interaction, evaluation, screenshot, and lifecycle operations.
+
+[Unreleased]: https://github.com/Skyvern-AI/rustwright/commits/main
+[0.1.1]: https://github.com/Skyvern-AI/rustwright/releases/tag/v0.1.1
+[0.1.0]: https://github.com/Skyvern-AI/rustwright/releases/tag/v0.1.0
+[0.1.0-alpha.4]: https://github.com/Skyvern-AI/rustwright/releases/tag/v0.1.0-alpha.4
+[0.1.0-alpha.3]: https://github.com/Skyvern-AI/rustwright/releases/tag/v0.1.0-alpha.3
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5cfabb6..6f8ecd0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,6 +4,8 @@ Rustwright is an alpha Rust/PyO3 project with a Playwright-shaped Python API.
Expect some rough edges, large files, and compatibility behavior that is still
being proven.
+PRs with user-facing changes should add a bullet under `## [Unreleased]` in `CHANGELOG.md`.
+
## Local Setup
Use a virtual environment and build the native extension with maturin:
diff --git a/README.md b/README.md
index 3e8d060..c3ebbf0 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,8 @@ The Node binding drives an existing Chromium/Chrome — point Rustwright at it w
await browser.close();
```
+See the network-independent [`examples/quickstart.js`](examples/quickstart.js) example. After building the Node binding from source, run it from the repository root with `node examples/quickstart.js`.
+
Only a subset of the API surface is bridged — see [Limitations](#limitations).
## Why Rustwright?
diff --git a/examples/fill_form.py b/examples/fill_form.py
new file mode 100644
index 0000000..c88111b
--- /dev/null
+++ b/examples/fill_form.py
@@ -0,0 +1,48 @@
+"""Demonstrate filling and submitting a form with Rustwright's sync API."""
+
+from urllib.parse import quote
+
+from rustwright.sync_api import sync_playwright
+
+
+# Keeping the page inline makes this example deterministic and safe to run offline.
+html = """
+
+
+
+
+
+
+
+
+"""
+page_url = "data:text/html;charset=utf-8," + quote(html)
+
+
+with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True)
+ try:
+ page = browser.new_page()
+ page.goto(page_url)
+
+ # Label locators describe the form the same way a user does.
+ page.get_by_label("Name").fill("Ada Lovelace")
+ page.get_by_label("Email").fill("ada@example.test")
+
+ # Submit through the visible button, then read the result rendered by the page.
+ page.get_by_role("button", name="Submit").click()
+ submitted = page.locator("#submitted").inner_text()
+ print(f"submitted: {submitted}")
+ finally:
+ browser.close()
diff --git a/examples/quickstart.js b/examples/quickstart.js
new file mode 100644
index 0000000..f9f65bf
--- /dev/null
+++ b/examples/quickstart.js
@@ -0,0 +1,44 @@
+// --- Source-checkout bootstrap -------------------------------------------
+// Installed from npm? Delete this block and use:
+// const { chromium } = require('rustwright');
+// ESM projects ("type": "module") use: import { chromium } from 'rustwright';
+let rustwrightModule = 'rustwright';
+try {
+ require.resolve(rustwrightModule);
+} catch (error) {
+ const firstLine = error instanceof Error ? error.message.split(/\r?\n/, 1)[0] : '';
+ if (error?.code !== 'MODULE_NOT_FOUND' || firstLine !== "Cannot find module 'rustwright'") {
+ throw error;
+ }
+ rustwrightModule = '../node/index.cjs';
+}
+const { chromium } = require(rustwrightModule);
+// --- End source-checkout bootstrap ---------------------------------------
+
+async function main() {
+ const { join } = await import('node:path');
+ const { tmpdir } = await import('node:os');
+ const screenshotPath = join(tmpdir(), `rustwright-quickstart-${process.pid}.png`);
+
+ // Browser discovery checks RUSTWRIGHT_CHROMIUM, CHROME, then CHROMIUM.
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage();
+ try {
+ await page.goto('data:text/html,%3Ctitle%3ERustwright%20works%3C/title%3E');
+ const title = await page.title();
+ await page.screenshot({ path: screenshotPath });
+ console.log(`title: ${title}`);
+ console.log(`screenshot: ${screenshotPath}`);
+ } finally {
+ await page.close();
+ }
+ } finally {
+ await browser.close();
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/examples/scrape_table.py b/examples/scrape_table.py
new file mode 100644
index 0000000..62e1c9b
--- /dev/null
+++ b/examples/scrape_table.py
@@ -0,0 +1,46 @@
+"""Demonstrate scraping an HTML table into dictionaries with Rustwright's sync API."""
+
+from urllib.parse import quote
+
+from rustwright.sync_api import sync_playwright
+
+
+# This inline fixture keeps the scraping example independent of external websites.
+html = """
+
+
+
+
+
+ | Product | Price | Stock |
+
+
+ | Notebook | $4.50 | 12 |
+ | Pen | $1.25 | 40 |
+
+
+
+
+"""
+page_url = "data:text/html;charset=utf-8," + quote(html)
+
+
+with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True)
+ try:
+ page = browser.new_page()
+ page.goto(page_url)
+
+ # Read the headings once; they become the keys in every result dictionary.
+ headers = [text.strip() for text in page.locator("thead th").all_inner_texts()]
+
+ # A locator can represent many matches. Use count() and nth() to visit each row.
+ body_rows = page.locator("tbody tr")
+ rows = []
+ for index in range(body_rows.count()):
+ cells = [text.strip() for text in body_rows.nth(index).locator("td").all_inner_texts()]
+ rows.append(dict(zip(headers, cells)))
+
+ print(f"rows: {rows}")
+ finally:
+ browser.close()
diff --git a/examples/screenshot_element.py b/examples/screenshot_element.py
new file mode 100644
index 0000000..8b645d9
--- /dev/null
+++ b/examples/screenshot_element.py
@@ -0,0 +1,35 @@
+"""Demonstrate saving a screenshot of one CSS-selected element with Rustwright's sync API."""
+
+from pathlib import Path
+from urllib.parse import quote
+
+from rustwright.sync_api import sync_playwright
+
+
+# A styled inline card gives the element screenshot a visible, repeatable target.
+html = """
+
+
+
+
+ Order ready
+ Your notebook and pen are packed.
+
+
+
+"""
+page_url = "data:text/html;charset=utf-8," + quote(html)
+output_path = Path("screenshot_element.png")
+
+
+with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True)
+ try:
+ page = browser.new_page()
+ page.goto(page_url)
+
+ # Locator screenshots capture only the matching element, not the full page.
+ page.locator(".receipt").screenshot(path=str(output_path))
+ print(f"saved: {output_path}")
+ finally:
+ browser.close()
diff --git a/node/package.json b/node/package.json
index 4ddc732..0fcfcfb 100644
--- a/node/package.json
+++ b/node/package.json
@@ -41,7 +41,7 @@
"scripts": {
"build": "napi build --release -p rustwright-node --dts native.d.ts && node ./scripts/generate-types.mjs",
"smoke": "node ./smoke.mjs",
- "test": "node --test test/"
+ "test": "node --test"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.4"
diff --git a/node/test/quickstart.test.mjs b/node/test/quickstart.test.mjs
new file mode 100644
index 0000000..1c5c54b
--- /dev/null
+++ b/node/test/quickstart.test.mjs
@@ -0,0 +1,132 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const testDirectory = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(testDirectory, '../..');
+const examplePath = resolve(repoRoot, 'examples/quickstart.js');
+
+test('Node quickstart runs from the repository root', { timeout: 45_000 }, async (t) => {
+ assert.ok(
+ existsSync(examplePath),
+ 'examples/quickstart.js is missing; add the runnable Node.js quickstart example',
+ );
+
+ const quickstartSource = readFileSync(examplePath, 'utf8');
+ assert.match(
+ quickstartSource,
+ /^\/\/ const \{ chromium \} = require\('rustwright'\);$/m,
+ 'npm-user guidance must use CommonJS syntax because the example is CommonJS',
+ );
+
+ const probeRoot = mkdtempSync(join(tmpdir(), 'rustwright-quickstart-test-'));
+ try {
+ const commonjsProject = join(probeRoot, 'commonjs-project');
+ const commonjsPackage = join(commonjsProject, 'node_modules', 'rustwright');
+ mkdirSync(commonjsPackage, { recursive: true });
+ writeFileSync(join(commonjsProject, 'package.json'), '{"type":"commonjs"}\n');
+ writeFileSync(
+ join(commonjsPackage, 'package.json'),
+ '{"name":"rustwright","main":"index.cjs"}\n',
+ );
+ writeFileSync(join(commonjsPackage, 'index.cjs'), "exports.chromium = 'stub';\n");
+ const commonjsProbe = join(commonjsProject, 'quickstart.js');
+ writeFileSync(
+ commonjsProbe,
+ "const { chromium } = require('rustwright');\nif (chromium !== 'stub') process.exitCode = 1;\n",
+ );
+
+ const syntaxResult = spawnSync(process.execPath, ['--check', commonjsProbe], {
+ encoding: 'utf8',
+ });
+ assert.equal(syntaxResult.status, 0, syntaxResult.stderr);
+ const commonjsResult = spawnSync(process.execPath, [commonjsProbe], { encoding: 'utf8' });
+ assert.equal(commonjsResult.status, 0, commonjsResult.stderr);
+
+ const brokenProject = join(probeRoot, 'broken-package-project');
+ const brokenPackage = join(brokenProject, 'node_modules', 'rustwright');
+ mkdirSync(brokenPackage, { recursive: true });
+ writeFileSync(
+ join(brokenPackage, 'package.json'),
+ '{"name":"rustwright","main":"index.cjs"}\n',
+ );
+ writeFileSync(join(brokenPackage, 'index.cjs'), "require('./missing-internal.cjs');\n");
+ writeFileSync(join(brokenProject, 'quickstart.js'), quickstartSource);
+
+ const localFallback = join(probeRoot, 'node');
+ mkdirSync(localFallback);
+ writeFileSync(
+ join(localFallback, 'index.cjs'),
+ `exports.chromium = {
+ async launch() {
+ return {
+ async newPage() {
+ return {
+ async goto() {},
+ async title() { return 'unexpected fallback'; },
+ async screenshot() {},
+ async close() {},
+ };
+ },
+ async close() {},
+ };
+ },
+};
+`,
+ );
+
+ const brokenResult = spawnSync(process.execPath, ['quickstart.js'], {
+ cwd: brokenProject,
+ encoding: 'utf8',
+ });
+ assert.notEqual(brokenResult.status, 0, 'a broken installed package must not use the fallback');
+ assert.match(brokenResult.stderr, /Cannot find module '\.\/missing-internal\.cjs'/);
+ assert.match(brokenResult.stderr, /node_modules[\\/]rustwright[\\/]index\.cjs/);
+ } finally {
+ rmSync(probeRoot, { recursive: true, force: true });
+ }
+
+ let chromium;
+ try {
+ ({ chromium } = await import('../index.mjs'));
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('native addon is not built')) {
+ t.skip('Rustwright native addon is not built');
+ return;
+ }
+ throw error;
+ }
+
+ if (!await chromium.executablePath()) {
+ t.skip('Chromium/Chrome executable not found');
+ return;
+ }
+
+ const result = spawnSync(process.execPath, ['examples/quickstart.js'], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ env: process.env,
+ timeout: 30_000,
+ });
+
+ assert.equal(result.error, undefined, `quickstart process error: ${result.error?.message}`);
+ assert.equal(
+ result.status,
+ 0,
+ `quickstart exited with status ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
+ );
+
+ // Output contract: the quickstart prints the title from its inline page.
+ assert.match(result.stdout, /^title: Rustwright works$/m);
+});
diff --git a/tests/test_changelog.py b/tests/test_changelog.py
new file mode 100644
index 0000000..27f3668
--- /dev/null
+++ b/tests/test_changelog.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+import re
+from datetime import date
+from pathlib import Path
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CHANGELOG = ROOT / "CHANGELOG.md"
+REQUIRED_VERSIONS = (
+ "0.1.1",
+ "0.1.0",
+ "0.1.0-alpha.4",
+ "0.1.0-alpha.3",
+)
+PRERELEASE_IDENTIFIER = r"(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)"
+BUILD_IDENTIFIER = r"[0-9A-Za-z-]+"
+VERSION_RE = re.compile(
+ rf"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
+ rf"(?:-({PRERELEASE_IDENTIFIER}(?:\.{PRERELEASE_IDENTIFIER})*))?"
+ rf"(?:\+({BUILD_IDENTIFIER}(?:\.{BUILD_IDENTIFIER})*))?"
+)
+SECTION_RE = re.compile(
+ r"^## \[([^\]]+)\](?: - ([^\r\n]+))?\s*$",
+ re.MULTILINE,
+)
+CATEGORY_RE = re.compile(
+ r"^### (?:Added|Changed|Fixed|Deprecated|Removed|Security)\s*$",
+ re.MULTILINE,
+)
+NEXT_H2_RE = re.compile(r"^## ", re.MULTILINE)
+NEXT_H3_RE = re.compile(r"^### ", re.MULTILINE)
+BULLET_RE = re.compile(r"^- .+", re.MULTILINE)
+
+
+def _version_key(
+ version: str,
+) -> tuple[int, int, int, tuple[int, tuple[tuple[int, int | str], ...]]]:
+ match = VERSION_RE.fullmatch(version)
+ assert match is not None, f"unsupported changelog version: {version}"
+ major, minor, patch, prerelease, _build = match.groups()
+ prerelease_key = (
+ (1, ())
+ if prerelease is None
+ else (
+ 0,
+ tuple(
+ (0, int(identifier)) if identifier.isdigit() else (1, identifier)
+ for identifier in prerelease.split(".")
+ ),
+ )
+ )
+ return int(major), int(minor), int(patch), prerelease_key
+
+
+def _version_sections(text: str) -> list[re.Match[str]]:
+ sections = [
+ section
+ for section in SECTION_RE.finditer(text)
+ if section.group(1) != "Unreleased"
+ ]
+ unsupported = [
+ section.group(1)
+ for section in sections
+ if VERSION_RE.fullmatch(section.group(1)) is None
+ ]
+ assert not unsupported, (
+ "unsupported changelog version heading: " + ", ".join(unsupported)
+ )
+ return sections
+
+
+def _assert_version_sections_strictly_descending(text: str) -> None:
+ versions = [section.group(1) for section in _version_sections(text)]
+ duplicates = sorted({version for version in versions if versions.count(version) > 1})
+ assert not duplicates, "version sections must be unique: " + ", ".join(duplicates)
+
+ for newer, older in zip(versions, versions[1:]):
+ assert _version_key(newer) > _version_key(older), (
+ "version sections must appear newest first: "
+ f"{newer} must be newer than {older}"
+ )
+
+
+def _has_category_with_bullet(section_body: str) -> bool:
+ for category in CATEGORY_RE.finditer(section_body):
+ next_category = NEXT_H3_RE.search(section_body, category.end())
+ category_end = next_category.start() if next_category else len(section_body)
+ if BULLET_RE.search(section_body, category.end(), category_end):
+ return True
+ return False
+
+
+def test_changelog_has_required_structure() -> None:
+ assert CHANGELOG.is_file(), "CHANGELOG.md must exist at the repository root"
+ text = CHANGELOG.read_text(encoding="utf-8")
+
+ headings = re.findall(r"^#{1,6} .+$", text, re.MULTILINE)
+ assert headings, "CHANGELOG.md must contain Markdown headings"
+ assert headings[0] == "# Changelog", "the first heading must be '# Changelog'"
+
+ sections = list(SECTION_RE.finditer(text))
+ unreleased = [section for section in sections if section.group(1) == "Unreleased"]
+ assert unreleased, "CHANGELOG.md must contain an exact '## [Unreleased]' section"
+ assert unreleased[0].group(2) is None, "the Unreleased section must not have a date"
+
+ version_sections = _version_sections(text)
+ assert version_sections, "CHANGELOG.md must contain version sections"
+ assert unreleased[0].start() < version_sections[0].start(), (
+ "the Unreleased section must appear before every version section"
+ )
+
+ version_names = [section.group(1) for section in version_sections]
+ missing = [version for version in REQUIRED_VERSIONS if version not in version_names]
+ assert not missing, f"CHANGELOG.md is missing published versions: {', '.join(missing)}"
+ _assert_version_sections_strictly_descending(text)
+
+ for section in version_sections:
+ version, released_on = section.groups()
+ assert released_on is not None, f"version {version} must have a YYYY-MM-DD date"
+ try:
+ parsed_date = date.fromisoformat(released_on)
+ except ValueError:
+ parsed_date = None
+ assert parsed_date is not None and parsed_date.isoformat() == released_on, (
+ f"version {version} must have a valid YYYY-MM-DD date"
+ )
+
+ next_section = NEXT_H2_RE.search(text, section.end())
+ section_end = next_section.start() if next_section else len(text)
+ section_body = text[section.end() : section_end]
+ assert _has_category_with_bullet(section_body), (
+ f"version {version} must contain a recognized category with a '- ' bullet"
+ )
+
+
+def test_version_order_rejects_out_of_order_fixture() -> None:
+ fixture = """\
+## [0.1.1] - 2026-07-15
+## [9.0.0] - 2026-07-16
+## [0.1.0] - 2026-07-14
+"""
+
+ with pytest.raises(AssertionError, match="newest first"):
+ _assert_version_sections_strictly_descending(fixture)
+
+
+def test_version_order_rejects_duplicate_fixture() -> None:
+ fixture = """\
+## [1.0.0] - 2026-07-16
+## [1.0.0-alpha.1] - 2026-07-15
+## [1.0.0-alpha.1] - 2026-07-14
+"""
+
+ with pytest.raises(AssertionError, match="must be unique"):
+ _assert_version_sections_strictly_descending(fixture)
+
+
+def test_version_order_rejects_misordered_beta_fixture() -> None:
+ fixture = """\
+## [1.0.0-beta.2] - 2026-07-16
+## [1.0.0-beta.10] - 2026-07-15
+## [1.0.0-alpha.1] - 2026-07-14
+"""
+
+ with pytest.raises(AssertionError, match="newest first"):
+ _assert_version_sections_strictly_descending(fixture)
+
+
+def test_version_order_rejects_unrecognized_heading_fixture() -> None:
+ fixture = """\
+## [1.0.0] - 2026-07-16
+## [release-next] - 2026-07-15
+"""
+
+ with pytest.raises(AssertionError, match="unsupported changelog version heading"):
+ _assert_version_sections_strictly_descending(fixture)
diff --git a/tests/test_examples.py b/tests/test_examples.py
new file mode 100644
index 0000000..8203c11
--- /dev/null
+++ b/tests/test_examples.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import ast
+import struct
+import subprocess
+import sys
+from functools import lru_cache
+from pathlib import Path
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+EXAMPLES = ROOT / "examples"
+EXAMPLE_TIMEOUT_SECONDS = 60
+SCREENSHOT_OUTPUT = ROOT / "screenshot_element.png"
+
+
+def _required_example(filename: str) -> Path:
+ script = EXAMPLES / filename
+ assert script.is_file(), f"Missing required example script: examples/{filename}"
+ return script
+
+
+@lru_cache(maxsize=1)
+def _chromium_probe() -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ (
+ "from rustwright.sync_api import sync_playwright; "
+ "playwright = sync_playwright().start(); "
+ "print('available' if playwright.chromium.executable_path else 'missing'); "
+ "playwright.stop()"
+ ),
+ ],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ timeout=20,
+ )
+
+
+def _require_chromium() -> None:
+ probe = _chromium_probe()
+ assert probe.returncode == 0, (
+ "Could not inspect Rustwright's Chromium installation.\n"
+ f"stdout:\n{probe.stdout}\n"
+ f"stderr:\n{probe.stderr}"
+ )
+ if probe.stdout.strip() == "missing":
+ pytest.skip("Chromium/Chrome executable not found")
+ assert probe.stdout.strip() == "available", probe.stdout
+
+
+def _run_example(filename: str) -> subprocess.CompletedProcess[str]:
+ script = _required_example(filename)
+ _require_chromium()
+ result = subprocess.run(
+ [sys.executable, str(script)],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ timeout=EXAMPLE_TIMEOUT_SECONDS,
+ )
+ assert result.returncode == 0, (
+ f"examples/{filename} exited with status {result.returncode}.\n"
+ f"stdout:\n{result.stdout}\n"
+ f"stderr:\n{result.stderr}"
+ )
+ return result
+
+
+def _marker_value(stdout: str, marker: str) -> str:
+ matching_lines = [line for line in stdout.splitlines() if line.startswith(marker)]
+ assert matching_lines, f"Expected stdout to contain a line starting with {marker!r}.\nstdout:\n{stdout}"
+ assert len(matching_lines) == 1, f"Expected exactly one {marker!r} line, got {matching_lines!r}"
+ return matching_lines[0][len(marker) :].strip()
+
+
+def test_fill_form_example() -> None:
+ """Contract: submit two fields and print `submitted: Ada Lovelace (ada@example.test)`."""
+ result = _run_example("fill_form.py")
+
+ submitted = _marker_value(result.stdout, "submitted: ")
+ assert submitted == "Ada Lovelace (ada@example.test)"
+
+
+def test_scrape_table_example() -> None:
+ """Contract: print `rows: ` followed by this fixture table as a list of dictionaries."""
+ result = _run_example("scrape_table.py")
+
+ rendered_rows = _marker_value(result.stdout, "rows: ")
+ try:
+ rows = ast.literal_eval(rendered_rows)
+ except (SyntaxError, ValueError) as exc:
+ raise AssertionError(f"The rows marker must contain a Python literal, got {rendered_rows!r}") from exc
+ assert rows == [
+ {"Product": "Notebook", "Price": "$4.50", "Stock": "12"},
+ {"Product": "Pen", "Price": "$1.25", "Stock": "40"},
+ ]
+
+
+def test_screenshot_element_example() -> None:
+ """Contract: save one element to `screenshot_element.png` and print `saved: `."""
+ _required_example("screenshot_element.py")
+ assert not SCREENSHOT_OUTPUT.exists(), (
+ f"Refusing to overwrite or remove an existing artifact: {SCREENSHOT_OUTPUT.name}"
+ )
+
+ try:
+ result = _run_example("screenshot_element.py")
+ rendered_path = _marker_value(result.stdout, "saved: ")
+ saved_path = Path(rendered_path)
+ if not saved_path.is_absolute():
+ saved_path = ROOT / saved_path
+
+ assert saved_path.resolve() == SCREENSHOT_OUTPUT.resolve(), (
+ f"The example must save {SCREENSHOT_OUTPUT.name}, got {rendered_path!r}"
+ )
+ assert SCREENSHOT_OUTPUT.is_file(), f"Screenshot was not created: {SCREENSHOT_OUTPUT.name}"
+ assert SCREENSHOT_OUTPUT.stat().st_size > 0, "Screenshot PNG is empty"
+ png = SCREENSHOT_OUTPUT.read_bytes()
+ assert png[:8] == b"\x89PNG\r\n\x1a\n", "Screenshot does not have a valid PNG signature"
+ width, height = struct.unpack(">II", png[16:24])
+ # The 260px content width plus 24px padding on both sides produces a 308px border box.
+ assert width == 308
+ # Font rendering varies across Linux CI environments, so require a sane height instead of an exact value.
+ assert 40 < height < 600
+ finally:
+ SCREENSHOT_OUTPUT.unlink(missing_ok=True)