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 = """ + + + + + + + + + + + +
ProductPriceStock
Notebook$4.5012
Pen$1.2540
+ + +""" +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)